Compare commits

..
Author SHA1 Message Date
ji ruo 080a46eb2d feat: make task global asset sharing opt-in 2026-11-05 16:27:44 +08:00
25 changed files with 838 additions and 328 deletions

No files matched your search

+2 -2
View File
@@ -309,10 +309,10 @@ const plannerDefaultTmpl = `你是一个网络安全平台授权渗透测试系
**每次唤醒的决策流程**: **每次唤醒的决策流程**:
1. **完整态势已附在本提示下方**(就是 graph_overview 的返回,无需再调它):task(原始标题+目标/根节点)、资产计数、goals+状态、open/running/recent_done 意图、sites_without_endpoints(无端点的站点,提示可能待探的方向)、facts(探索事实数,与漏洞是两类)、recent_facts({id,summary,confidence?})。 1. **完整态势已附在本提示下方**(就是 graph_overview 的返回,无需再调它):task(原始标题+目标/根节点)、资产计数、goals+状态、open/running/recent_done 意图、sites_without_endpoints(无端点的站点,提示可能待探的方向)、facts(探索事实数,与漏洞是两类)、recent_facts({id,summary,confidence?})。
- **范围**:探索节点(goals/意图/facts/findings)只含本任务;**资产图全局共享**(多任务同一份,资产计数是全局在范围内的、非本任务独有)——出现非本任务相关的资产时忽略。 - **范围**:探索节点(goals/意图/facts/findings)只含本任务;**资产默认按任务隔离**(仅本任务及明确关联任务;用户开启「共享全局资产」后可查询全库)——出现非本任务相关的资产时忽略。
- **血缘**:每个意图带 parents(上游:派生自哪些事实/意图)和 yields(下游:产生了哪些事实/发现),recent_facts 每条带 from_intent;据此理解"哪些事实来自哪个方向、能否综合出新方向"。 - **血缘**:每个意图带 parents(上游:派生自哪些事实/意图)和 yields(下游:产生了哪些事实/发现),recent_facts 每条带 from_intent;据此理解"哪些事实来自哪个方向、能否综合出新方向"。
- **否定/存疑观察**(recent_facts 里"端口关闭/不可注入"等)是 worker 的观察、不是定论:采信前先 node_detail(id) 看 evidence——evidence 扎实、confidence=observed 且手段已穷尽的才视为该方向暂时封住;evidence 缺失、只是"看起来像/只探一次"、或 confidence=inferred 的,按【尚未探明】处理,若在范围内且无其它意图覆盖,默认派一条复核意图去证实或推翻(**同一否定方向至多复核一次**;复核后仍为否定、且证据合理,就尊重该结论、不再派)。 - **否定/存疑观察**(recent_facts 里"端口关闭/不可注入"等)是 worker 的观察、不是定论:采信前先 node_detail(id) 看 evidence——evidence 扎实、confidence=observed 且手段已穷尽的才视为该方向暂时封住;evidence 缺失、只是"看起来像/只探一次"、或 confidence=inferred 的,按【尚未探明】处理,若在范围内且无其它意图覆盖,默认派一条复核意图去证实或推翻(**同一否定方向至多复核一次**;复核后仍为否定、且证据合理,就尊重该结论、不再派)。
- **要更深细节才按需调**:list_facts(分页,最新在前,默认 20,可 q 过滤、before 翻页,带 total/has_more)、list_findings(全部漏洞)、node_detail(id)(完整证据/详情;列表/recent_facts 只给摘要)、list_assets(pull:q 搜索、type/company_id/task_id 过滤、分页,或 id/ids 直取)、asset_neighbors。资产全局共享,别默认拉全量。 - **要更深细节才按需调**:list_facts(分页,最新在前,默认 20,可 q 过滤、before 翻页,带 total/has_more)、list_findings(全部漏洞)、node_detail(id)(完整证据/详情;列表/recent_facts 只给摘要)、list_assets(pull:q 搜索、type/company_id/task_id 过滤、分页,或 id/ids 直取)、asset_neighbors。资产是否共享由用户设置决定,不能自行开启;共享不代表扩大测试授权。
2. **判目标(核心职责)**:goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id, evidence_id, reason) 标 met。**当你标记的恰是最后一个未完成目标时,系统自动判定整个任务完成**——收官只由逐个 prove_goal 驱动,没有别的"一键完成"手段。 2. **判目标(核心职责)**:goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id, evidence_id, reason) 标 met。**当你标记的恰是最后一个未完成目标时,系统自动判定整个任务完成**——收官只由逐个 prove_goal 驱动,没有别的"一键完成"手段。
- ⚠️ **量化验收核对(严禁提前盖章)**:目标含可量化条件(覆盖度达 X%、达到指定数量、获得某权限)时,prove_goal 前【必须】核对上方 graph_overview 的实测值(coverage.pct、findings_total 计数等):未达标就【禁止】prove_goal,改派意图补差;不得以"大体达成/核心已拿下"为由提前标 met。例:要求覆盖度 100% 而实测 coverage.pct=40% → 未达成,继续派补测意图。 - ⚠️ **量化验收核对(严禁提前盖章)**:目标含可量化条件(覆盖度达 X%、达到指定数量、获得某权限)时,prove_goal 前【必须】核对上方 graph_overview 的实测值(coverage.pct、findings_total 计数等):未达标就【禁止】prove_goal,改派意图补差;不得以"大体达成/核心已拿下"为由提前标 met。例:要求覆盖度 100% 而实测 coverage.pct=40% → 未达成,继续派补测意图。
+76
View File
@@ -0,0 +1,76 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/Autumn-27/artex/db"
)
func TestTaskAssetSharingLiveToolPolicy(t *testing.T) {
d := testDB(t)
defer d.Close()
task, err := d.CreateTask("tool sharing", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer d.DeleteTask(task.ID)
assets := d.Assets()
company, _, err := d.Companies().UpsertCompany(fmt.Sprintf("sharing-tool-%d", task.ID), "")
if err != nil {
t.Fatal(err)
}
defer d.Companies().DeleteCompany(company)
foreign, err := assets.UpsertRootDomain(db.UpsertRootDomainReq{Domain: fmt.Sprintf("sharing-tool-%d.invalid", task.ID)})
if err != nil {
t.Fatal(err)
}
defer assets.DeleteByIDs([]int64{foreign})
if _, err = d.Exec(`UPDATE assets SET company_id=$2 WHERE id=$1`, foreign, company); err != nil {
t.Fatal(err)
}
tools := NewToolSet(d.Exploration(task.ExplorationID), "planner")
tools.SetTaskID(task.ID)
tools.SetAssetStore(assets, d.Companies())
idJSON, _ := json.Marshal(foreign)
intent := intentItem{Summary: "reference foreign target", AssetIDs: []json.RawMessage{idJSON, idJSON}}
if _, err := tools.addOneIntent(intent); err == nil {
t.Fatal("foreign intent should fail while sharing off")
}
query := fmt.Sprintf(`{"id":%d}`, foreign)
if got := callReadJSON(t, tools.listAssets(), query); strings.Contains(fmt.Sprint(got), fmt.Sprintf("sharing-tool-%d.invalid", task.ID)) {
t.Fatalf("foreign asset leaked: %v", got)
}
for _, enabled := range []bool{true, false} {
if _, err := d.UpdateTask(task.ID, db.TaskPatch{ShareGlobalAssets: &enabled}); err != nil {
t.Fatal(err)
}
got := callReadJSON(t, tools.listAssets(), query)
visible := strings.Contains(fmt.Sprint(got), fmt.Sprintf("sharing-tool-%d.invalid", task.ID))
if visible != enabled {
t.Fatalf("live list_assets enabled=%v result=%v", enabled, got)
}
companies, err := d.Companies().ListCompaniesForTask(task.ID)
if err != nil {
t.Fatal(err)
}
found := false
for _, c := range companies {
if c.ID == company {
found = true
}
}
if found != enabled {
t.Fatalf("live company policy enabled=%v found=%v", enabled, found)
}
}
enabled := true
if _, err := d.UpdateTask(task.ID, db.TaskPatch{ShareGlobalAssets: &enabled}); err != nil {
t.Fatal(err)
}
if _, err := tools.addOneIntent(intent); err != nil {
t.Fatalf("explicit opt-in should allow target and duplicate IDs: %v", err)
}
}
+21 -4
View File
@@ -226,13 +226,12 @@ func (t *ToolSet) AddHintTool() actool.CoreTool { return t.addHint()
func (t *ToolSet) SetEnrich(e EnrichTrigger) { t.enrich = e } func (t *ToolSet) SetEnrich(e EnrichTrigger) { t.enrich = e }
// SetOwnerNode sets the exploration node that writes anchor to (worker: its // SetOwnerNode sets the exploration node that writes anchor to (worker: its
// intent node; planner/main: the begin root). Assets created/referenced while // intent node; planner/main: the begin root). Anchors preserve explicit asset
// ownerNode is set are anchored to it as lineage (not visibility). // references and make them available in this task context.
func (t *ToolSet) SetOwnerNode(id int64) { t.ownerNode = id } func (t *ToolSet) SetOwnerNode(id int64) { t.ownerNode = id }
// anchorOwner records a lineage edge from this run's owner node to an asset // anchorOwner records a lineage edge from this run's owner node to an asset
// (no-op if unset). Provenance only — the asset graph is global and shared, so // (no-op if unset). These explicit references also contribute to task visibility.
// this no longer affects which assets a task can read.
func (t *ToolSet) anchorOwner(assetID int64) { func (t *ToolSet) anchorOwner(assetID int64) {
if t.ts != nil && t.ownerNode > 0 && assetID > 0 { if t.ts != nil && t.ownerNode > 0 && assetID > 0 {
_ = t.ts.Anchor(t.ownerNode, assetID) _ = t.ts.Anchor(t.ownerNode, assetID)
@@ -1054,6 +1053,24 @@ func (t *ToolSet) addOneIntent(it intentItem) (int64, error) {
priority = 5 priority = 5
} }
anchors := pidList(it.AssetIDs) anchors := pidList(it.AssetIDs)
seenAnchors := map[int64]bool{}
uniqueAnchors := anchors[:0]
for _, id := range anchors {
if !seenAnchors[id] {
seenAnchors[id] = true
uniqueAnchors = append(uniqueAnchors, id)
}
}
anchors = uniqueAnchors
if t.as != nil && t.taskID > 0 && len(anchors) > 0 {
visible, err := t.as.GetByIDsInScope(t.taskID, anchors)
if err != nil {
return 0, fmt.Errorf("资产可见范围校验失败:%w", err)
}
if len(visible) != len(anchors) {
return 0, fmt.Errorf("目标资产不属于本任务或直接关联任务;如需使用全局资产,请由用户开启「共享全局资产」")
}
}
// 资产拦截:意图绑定的资产若命中系统资产拦截规则,则禁止下发该意图。 // 资产拦截:意图绑定的资产若命中系统资产拦截规则,则禁止下发该意图。
if t.as != nil && len(anchors) > 0 { if t.as != nil && len(anchors) > 0 {
hits, err := t.as.CheckAssetsIntercept(t.taskID, anchors) hits, err := t.as.CheckAssetsIntercept(t.taskID, anchors)
+3 -3
View File
@@ -539,7 +539,7 @@ func (t *ToolSet) listUntestedAssets() actool.CoreTool {
func (t *ToolSet) listAssets() actool.CoreTool { func (t *ToolSet) listAssets() actool.CoreTool {
return readTool( return readTool(
"list_assets", "list_assets",
"查询资产库:DSL 表达式搜索,或按 id/ids 直取;支持分页。只返回【本任务及直接关联任务】测试范围内的资产。\n"+ "查询资产库:DSL 表达式搜索,或按 id/ids 直取;支持分页。默认只返回本任务及直接关联任务的资产;用户开启「共享全局资产」后可查询全局资产。\n"+
"DSL:field=value 模糊(ILIKE) | field==value 精确 | field!=value 排除 | 数字字段支持 > >= < <= | 裸词=全文模糊;AND/OR 组合(AND 优先级高),可用括号分组。资产类型用独立 type 参数,不写进 DSL。\n"+ "DSL:field=value 模糊(ILIKE) | field==value 精确 | field!=value 排除 | 数字字段支持 > >= < <= | 裸词=全文模糊;AND/OR 组合(AND 优先级高),可用括号分组。资产类型用独立 type 参数,不写进 DSL。\n"+
"未传 id/ids 时 dsl 必须非空(不允许无条件全量查询)。\n"+ "未传 id/ids 时 dsl 必须非空(不允许无条件全量查询)。\n"+
"可用字段:domain(根/子/服务域名)、root_domain、ip、url、page_title、icp、service_name、app_name、method(如 GET/POST)、service_type(http|other)、record_type(如 A/CNAME)、technology(数组,=模糊 ==精确)、port/status_code/company_id(整数)。\n"+ "可用字段:domain(根/子/服务域名)、root_domain、ip、url、page_title、icp、service_name、app_name、method(如 GET/POST)、service_type(http|other)、record_type(如 A/CNAME)、technology(数组,=模糊 ==精确)、port/status_code/company_id(整数)。\n"+
@@ -596,7 +596,7 @@ func (t *ToolSet) listAssets() actool.CoreTool {
func (t *ToolSet) listCompanies() actool.CoreTool { func (t *ToolSet) listCompanies() actool.CoreTool {
return readTool( return readTool(
"list_companies", "list_companies",
"列出资产库中的【企业/公司】及其资产范围(scope)与已归属资产数。用于查看有哪些公司、"+ "列出当前任务可见的【企业/公司】及其资产范围(scope)与可见资产数;开启共享全局资产后可查询全局企业。用于查看有哪些公司、"+
"拿到 company_id(insert_assets 关联 app、list_assets 按 company_id 过滤时用)。"+ "拿到 company_id(insert_assets 关联 app、list_assets 按 company_id 过滤时用)。"+
"可选 search 按公司名模糊过滤(不区分大小写),留空返回全部。", "可选 search 按公司名模糊过滤(不区分大小写),留空返回全部。",
obj(map[string]any{ obj(map[string]any{
@@ -610,7 +610,7 @@ func (t *ToolSet) listCompanies() actool.CoreTool {
Search string `json:"search"` Search string `json:"search"`
} }
_ = json.Unmarshal(in, &a) _ = json.Unmarshal(in, &a)
cos, err := t.cs.ListCompanies() cos, err := t.cs.ListCompaniesForTask(t.taskID)
if err != nil { if err != nil {
return actool.Errorf("查询公司失败: " + err.Error()), nil return actool.Errorf("查询公司失败: " + err.Error()), nil
} }
+1 -1
View File
@@ -398,7 +398,7 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.
sysBody += renderIntentTask(intent) sysBody += renderIntentTask(intent)
if as != nil { if as != nil {
if ids := intentAssetIDs(intent); len(ids) > 0 { if ids := intentAssetIDs(intent); len(ids) > 0 {
if assets, err := as.GetByIDs(ids); err == nil && len(assets) > 0 { if assets, err := as.GetByIDsInScope(taskID, ids); err == nil && len(assets) > 0 {
if b, err := json.Marshal(assets); err == nil { if b, err := json.Marshal(assets); err == nil {
sysBody += "\n\n本意图 asset_ids 对应的目标资产:\n" + string(b) sysBody += "\n\n本意图 asset_ids 对应的目标资产:\n" + string(b)
} }
+8 -11
View File
@@ -490,6 +490,7 @@ func (s *AssetStore) CountDSL(dsl, typ string, taskID int64) (int, error) {
if err != nil { if err != nil {
return 0, err return 0, err
} }
where = "(" + where + ")"
if typ != "" { if typ != "" {
args = append(args, typ) args = append(args, typ)
where += fmt.Sprintf(" AND type = $%d", len(args)) where += fmt.Sprintf(" AND type = $%d", len(args))
@@ -522,6 +523,7 @@ func (s *AssetStore) QueryDSL(dsl, typ string, taskID int64, limit, offset int)
if err != nil { if err != nil {
return nil, err return nil, err
} }
where = "(" + where + ")"
if typ != "" { if typ != "" {
args = append(args, typ) args = append(args, typ)
where += fmt.Sprintf(" AND type = $%d", len(args)) where += fmt.Sprintf(" AND type = $%d", len(args))
@@ -550,13 +552,9 @@ func (s *AssetStore) QueryDSL(dsl, typ string, taskID int64, limit, offset int)
return assets, nil return assets, nil
} }
// QueryDSLInScope is QueryDSL restricted to assets that BELONG to taskID's (and its // QueryDSLInScope applies the task's live asset-sharing policy. By default only
// direct source tasks') declared scope — membership, not literal value: a // owned and directly related assets are visible; sharing enables global reads.
// root_domain scope returns every subdomain / service / endpoint under it. This is // Non-task contexts (taskID<=0) retain global reads.
// the agent-facing list_assets path, so an agent queries the task's relevant assets
// instead of the whole shared库. taskID<=0 (non-task contexts: Auto / pentest / chat)
// has no scope to honor and falls back to the plain global QueryDSL. Rows carry the
// same per-task source metadata as QueryByTask.
func (s *AssetStore) QueryDSLInScope(dsl, typ string, taskID int64, limit, offset int) ([]*Asset, error) { func (s *AssetStore) QueryDSLInScope(dsl, typ string, taskID int64, limit, offset int) ([]*Asset, error) {
if taskID <= 0 { if taskID <= 0 {
return s.QueryDSL(dsl, typ, 0, limit, offset) return s.QueryDSL(dsl, typ, 0, limit, offset)
@@ -578,6 +576,7 @@ func (s *AssetStore) QueryDSLInScope(dsl, typ string, taskID int64, limit, offse
} }
args := []any{taskID} args := []any{taskID}
args = append(args, dslArgs...) args = append(args, dslArgs...)
where = "(" + where + ")"
if typ != "" { if typ != "" {
args = append(args, typ) args = append(args, typ)
where += fmt.Sprintf(" AND type = $%d", len(args)) where += fmt.Sprintf(" AND type = $%d", len(args))
@@ -601,10 +600,8 @@ func (s *AssetStore) QueryDSLInScope(dsl, typ string, taskID int64, limit, offse
return assets, nil return assets, nil
} }
// GetByIDsInScope is GetByIDs restricted to ids that BELONG to taskID's (and its // GetByIDsInScope applies the same visibility policy to explicit ID reads.
// direct source tasks') declared scope, so an agent cannot reach out-of-scope // Invisible IDs are omitted. Non-task contexts retain global reads.
// assets by id. taskID<=0 (non-task contexts) falls back to the global GetByIDs.
// Out-of-scope ids are silently dropped from the result (not an error).
func (s *AssetStore) GetByIDsInScope(taskID int64, ids []int64) ([]*Asset, error) { func (s *AssetStore) GetByIDsInScope(taskID int64, ids []int64) ([]*Asset, error) {
if taskID <= 0 { if taskID <= 0 {
return s.GetByIDs(ids) return s.GetByIDs(ids)
+126 -95
View File
@@ -1000,127 +1000,158 @@ func TestAssetIPRejectsHostname(t *testing.T) {
deleteAsset(d, id) deleteAsset(d, id)
} }
// TestQueryDSLInScopeMembership pins the agent-facing list_assets behavior: it // TestQueryDSLInScopeMembership verifies that scope matching alone cannot import
// returns assets that BELONG to the task's declared scope (membership, not literal // unrelated assets, and that the live opt-in switch applies to DSL and ID reads.
// value) — a root_domain scope surfaces its subdomains/services/endpoints even when
// those rows were produced by a different task — and honors ip/cidr scope for
// IP-literal hosts (the try_inet(domain) arm). Out-of-scope assets, including by id,
// are excluded. Direct source tasks' scope is included.
func TestQueryDSLInScopeMembership(t *testing.T) { func TestQueryDSLInScopeMembership(t *testing.T) {
d, assets, _ := testSetup(t) d, assets, companies := testSetup(t)
defer d.Close() t.Cleanup(func() { _ = d.Close() })
// Older asset fixtures tag synthetic task IDs 1/2; use a task with no existing membership.
task, err := d.CreateTask("scope membership", "goal", nil, 0, 0) task, err := d.CreateTask("isolated task", "goal", nil, 0, 0)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
src, err := d.CreateTask("scope source", "goal", nil, 0, 0) source, err := d.CreateTaskWithOptions("source", "goal", TaskCreateOptions{ShareGlobalAssets: true})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { _ = d.DeleteTask(task.ID); _ = d.DeleteTask(src.ID) }) for _, candidate := range []*Task{task, source} {
// task ← src is a direct source relation. for {
if _, err := d.Exec(`INSERT INTO task_relations(task_id, source_task_id) VALUES ($1,$2)`, task.ID, src.ID); err != nil { var count int
t.Fatal(err) if err := d.QueryRow(`SELECT count(*) FROM assets WHERE $1=ANY(task_ids)`, candidate.ID).Scan(&count); err != nil {
} t.Fatal(err)
}
stamp := time.Now().UnixNano() if count == 0 {
root := fmt.Sprintf("sc%d.invalid", stamp) // in-scope root domain (task) break
srcRoot := fmt.Sprintf("src%d.invalid", stamp) // in-scope via source task }
out := fmt.Sprintf("out%d.invalid", stamp) // out of every scope shared := candidate.ShareGlobalAssets
marker := fmt.Sprintf("mk%d", stamp) // bare-text token present in all rows _ = d.DeleteTask(candidate.ID)
foreignTask := stamp + 777 // asset produced by an unrelated task replacement, err := d.CreateTaskWithOptions("clean membership", "goal", TaskCreateOptions{ShareGlobalAssets: shared})
if err != nil {
// Scope: task owns root; source task owns srcRoot; task owns an IP /24. t.Fatal(err)
for _, sc := range []struct { }
tid int64 *candidate = *replacement
kind string
dom string
net string
}{
{task.ID, "root_domain", root, ""},
{src.ID, "root_domain", srcRoot, ""},
{task.ID, "ip", "", "198.51.100.0/24"},
} {
if err := assets.upsertTaskScope(TaskScope{TaskID: sc.tid, Kind: sc.kind, Domain: sc.dom, Net: sc.net}); err != nil {
t.Fatalf("seed scope: %v", err)
} }
} }
if !source.ShareGlobalAssets || task.ShareGlobalAssets {
// Insert rows directly so root_domain/task_ids are controlled exactly. Every t.Fatal("incorrect creation defaults")
// in-scope row carries foreignTask in task_ids (never the current task) to prove
// membership is by scope, not by producer.
type row struct {
typ, domain, rootDom, url, method, ip, title string
} }
rows := []row{ if _, err = d.Exec(`INSERT INTO task_relations(task_id,source_task_id) VALUES ($1,$2)`, task.ID, source.ID); err != nil {
{"subdomain", "api." + root, root, "", "", "", marker}, // under task root t.Fatal(err)
{"service", "www." + root, root, "https://www." + root + "/", "", "", marker}, // service under task root
{"endpoint", "www." + root, root, "https://www." + root + "/a?" + marker + "=1", "GET", "", ""},
{"subdomain", "dev." + srcRoot, srcRoot, "", "", "", marker}, // under source-task root
{"endpoint", "198.51.100.9", "198.51.100.9", "http://198.51.100.9:8080/" + marker, "GET", "", ""}, // IP-literal host, ip col empty
{"subdomain", "x." + out, out, "", "", "", marker}, // out of scope
} }
var inScopeIDs, outIDs []int64 root := fmt.Sprintf("sharing-%d.invalid", time.Now().UnixNano())
for _, r := range rows { companyID, _, err := companies.UpsertCompany(root, "")
if err != nil {
t.Fatal(err)
}
var ids []int64
insert := func(typ, domain, url string, owner int64) int64 {
t.Helper()
var id int64 var id int64
if err := d.QueryRow(`INSERT INTO assets(type,domain,root_domain,url,method,ip,page_title,task_ids,last_seen) err := d.QueryRow(`INSERT INTO assets(type,domain,root_domain,url,ip,company_id,task_ids,last_seen)
VALUES ($1,NULLIF($2,''),NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),NULLIF($6,''),NULLIF($7,''),ARRAY[$8]::bigint[],now()) VALUES($1,$2,$3,NULLIF($4,''),'198.51.100.9',$5,ARRAY[$6]::bigint[],now()) RETURNING id`, typ, domain, root, url, companyID, owner).Scan(&id)
RETURNING id`, if err != nil {
r.typ, r.domain, r.rootDom, r.url, r.method, r.ip, r.title, foreignTask).Scan(&id); err != nil { t.Fatal(err)
t.Fatalf("insert %s: %v", r.domain, err)
}
if r.rootDom == out {
outIDs = append(outIDs, id)
} else {
inScopeIDs = append(inScopeIDs, id)
} }
ids = append(ids, id)
return id
} }
own := insert("subdomain", "www."+root, "", task.ID)
inherited := insert("service", "www."+root, "http://www."+root+":8080/", source.ID)
foreign := insert("service", "www."+root, "http://www."+root+":9090/", 0)
connector := insert("root_domain", root, "", 0)
t.Cleanup(func() { t.Cleanup(func() {
_, _ = d.Exec(`DELETE FROM assets WHERE id=ANY($1::bigint[]) OR id=ANY($2::bigint[])`, inScopeIDs, outIDs) _ = d.DeleteTask(task.ID)
_ = d.DeleteTask(source.ID)
_, _ = assets.DeleteByIDs(ids)
_, _ = d.Exec(`DELETE FROM companies WHERE id=$1`, companyID)
}) })
if err := assets.upsertTaskScope(TaskScope{TaskID: task.ID, Kind: "root_domain", Domain: root}); err != nil {
got, err := assets.QueryDSLInScope(marker, "", task.ID, 50, 0) t.Fatal(err)
}
assertIDs := func(got []*Asset, err error, want ...int64) {
t.Helper()
if err != nil {
t.Fatal(err)
}
seen := map[int64]bool{}
for _, a := range got {
seen[a.ID] = true
}
if len(seen) != len(want) {
t.Fatalf("got ids %v want %v", seen, want)
}
for _, id := range want {
if !seen[id] {
t.Fatalf("missing %d from %v", id, seen)
}
}
}
query := fmt.Sprintf("id==%d OR id==%d OR id==%d OR id==%d", foreign, own, inherited, connector)
got, err := assets.QueryDSLInScope(query, "", task.ID, 50, 0)
assertIDs(got, err, own, inherited)
got, err = assets.GetByIDsInScope(task.ID, ids)
assertIDs(got, err, own, inherited)
got, err = assets.QueryDSL(query, "subdomain", task.ID, 50, 0)
assertIDs(got, err, own)
count, err := assets.CountDSL(query, "subdomain", task.ID)
if err != nil || count != 1 {
t.Fatalf("OR bypassed count filters: %d %v", count, err)
}
// A source's opt-in never grants this task global reads.
cov, err := assets.TaskCoverageWithSources(task.ID)
if err != nil || cov.Denominator != 2 {
t.Fatalf("coverage leak: %+v %v", cov, err)
}
legacy, err := assets.TaskCoverage(task.ID, task.ExplorationID)
if err != nil || legacy.Denominator != 2 {
t.Fatalf("legacy coverage leak: %+v %v", legacy, err)
}
graph, err := assets.BuildCoverageGraph(task.ID, task.ExplorationID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
gotIDs := map[int64]bool{} for _, n := range graph.Nodes {
for _, a := range got { if n.AssetID == foreign || n.AssetID == connector {
gotIDs[a.ID] = true t.Fatalf("foreign graph asset: %+v", n)
}
for _, id := range inScopeIDs {
if !gotIDs[id] {
t.Fatalf("in-scope asset %d missing from QueryDSLInScope result %v", id, gotIDs)
} }
} }
for _, id := range outIDs { cos, err := companies.ListCompaniesForTask(task.ID)
if gotIDs[id] { if err != nil || len(cos) != 1 || cos[0].AssetCount != 2 {
t.Fatalf("out-of-scope asset %d leaked into scoped query", id) t.Fatalf("company isolation: %+v %v", cos, err)
}
} }
for _, enabled := range []bool{true, false} {
// GetByIDsInScope: in-scope id returns, out-of-scope id is dropped. updated, err := d.UpdateTask(task.ID, TaskPatch{ShareGlobalAssets: &enabled})
mixed := append(append([]int64{}, inScopeIDs[0]), outIDs[0]) if err != nil || updated.ShareGlobalAssets != enabled {
byID, err := assets.GetByIDsInScope(task.ID, mixed) t.Fatalf("update: %+v %v", updated, err)
}
saved, err := d.GetTask(task.ID)
if err != nil || saved.ShareGlobalAssets != enabled {
t.Fatalf("reload: %+v %v", saved, err)
}
want := []int64{own, inherited}
if enabled {
want = ids
}
got, err = assets.QueryDSLInScope(query, "", task.ID, 50, 0)
assertIDs(got, err, want...)
got, err = assets.GetByIDsInScope(task.ID, ids)
assertIDs(got, err, want...)
}
got, err = assets.QueryDSLInScope(query, "", 0, 50, 0)
assertIDs(got, err, ids...)
empty, err := d.CreateTask("empty task", "goal", nil, 0, 0)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(byID) != 1 || byID[0].ID != inScopeIDs[0] { defer d.DeleteTask(empty.ID)
t.Fatalf("GetByIDsInScope mixed ids → %+v, want only %d", byID, inScopeIDs[0]) got, err = assets.QueryDSLInScope(query, "", empty.ID, 50, 0)
} assertIDs(got, err)
// Explicit company selection imports a snapshot even with global sharing off.
// taskID<=0 (non-task context) falls back to global: the out-of-scope row is reachable. associated, err := d.CreateTaskWithOptions("company task", "goal", TaskCreateOptions{CompanyIDs: []int64{companyID}})
global, err := assets.QueryDSLInScope(marker, "", 0, 50, 0)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var sawOut bool defer d.DeleteTask(associated.ID)
for _, a := range global { got, err = assets.GetByIDsInScope(associated.ID, ids)
if a.ID == outIDs[0] { assertIDs(got, err, ids...)
sawOut = true
}
}
if !sawOut {
t.Fatalf("taskID<=0 should fall back to global and include out-of-scope asset %d", outIDs[0])
}
} }
+49
View File
@@ -293,6 +293,55 @@ ORDER BY c.name`)
return out, nil return out, nil
} }
// ListCompaniesForTask exposes only task-visible ownership and explicitly
// associated companies. Isolated tasks never receive unrelated company scopes.
func (s *CompanyStore) ListCompaniesForTask(taskID int64) ([]*CompanyWithScope, error) {
if taskID <= 0 {
return s.ListCompanies()
}
var shared bool
if err := s.db.QueryRow(`SELECT share_global_assets FROM tasks WHERE id=$1 AND deleted_at IS NULL`, taskID).Scan(&shared); err != nil {
return nil, err
}
if shared {
return s.ListCompanies()
}
rows, err := s.db.Query(`WITH `+directTaskContextCTE+taskAssetVisibilityCTE+`
SELECT c.id,c.name,c.nkey,c.logo,c.created_at::text,c.updated_at::text,COUNT(a.id),
EXISTS(SELECT 1 FROM task_scope ts JOIN context_tasks ctx ON ctx.task_id=ts.task_id WHERE ts.company_id=c.id AND ts.kind='company')
FROM companies c LEFT JOIN assets a ON a.company_id=c.id AND a.id IN (SELECT id FROM visible_assets)
WHERE a.id IS NOT NULL OR EXISTS(SELECT 1 FROM task_scope ts JOIN context_tasks ctx ON ctx.task_id=ts.task_id WHERE ts.company_id=c.id AND ts.kind='company')
GROUP BY c.id ORDER BY c.name`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*CompanyWithScope{}
associated := map[int64]bool{}
for rows.Next() {
c := &CompanyWithScope{Scope: []ScopeRule{}}
var explicit bool
if err := rows.Scan(&c.ID, &c.Name, &c.NKey, &c.Logo, &c.CreatedAt, &c.UpdatedAt, &c.AssetCount, &explicit); err != nil {
return nil, err
}
out = append(out, c)
associated[c.ID] = explicit
}
if err := rows.Err(); err != nil {
return nil, err
}
// Only explicit company associations grant access to the company's scope.
for _, c := range out {
if associated[c.ID] {
c.Scope, err = s.GetScope(c.ID)
if err != nil {
return nil, err
}
}
}
return out, nil
}
// GetScope returns all scope rules for a company. // GetScope returns all scope rules for a company.
func (s *CompanyStore) GetScope(companyID int64) ([]ScopeRule, error) { func (s *CompanyStore) GetScope(companyID int64) ([]ScopeRule, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
+2
View File
@@ -550,6 +550,8 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false
-- 给 agent 开放 add_task_scope/list_untested_assets;false=全部关闭(见 task_scope.go)。 -- 给 agent 开放 add_task_scope/list_untested_assets;false=全部关闭(见 task_scope.go)。
-- 存量任务默认 true 保持原行为;company 关联(task_scope kind=company)不受此开关影响。 -- 存量任务默认 true 保持原行为;company 关联(task_scope kind=company)不受此开关影响。
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS coverage_enabled BOOLEAN NOT NULL DEFAULT true; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS coverage_enabled BOOLEAN NOT NULL DEFAULT true;
-- Global asset reuse is opt-in, including for pre-existing tasks.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS share_global_assets BOOLEAN NOT NULL DEFAULT false;
-- 任务级 Worker 运行数量;0=用全局默认(settings.workers)。补旧库。 -- 任务级 Worker 运行数量;0=用全局默认(settings.workers)。补旧库。
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS worker_count INTEGER NOT NULL DEFAULT 0; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS worker_count INTEGER NOT NULL DEFAULT 0;
-- queued_at makes admission FIFO reflect the actual enqueue order rather than the -- queued_at makes admission FIFO reflect the actual enqueue order rather than the
+2 -2
View File
@@ -122,7 +122,7 @@ AND NOT EXISTS (
if _, err := tx.Exec(`UPDATE tasks SET if _, err := tx.Exec(`UPDATE tasks SET
name='',remark='',category_id=NULL,description='',goal='',paused=true,queued=false,queued_at=NULL,queue_mode='', name='',remark='',category_id=NULL,description='',goal='',paused=true,queued=false,queued_at=NULL,queue_mode='',
llm_profile_id=NULL,active_llm_profile_id=NULL,llm_chain_revision=llm_chain_revision+1, llm_profile_id=NULL,active_llm_profile_id=NULL,llm_chain_revision=llm_chain_revision+1,
company_id=NULL,parent_ref=NULL,timeout_seconds=0,coverage_enabled=true,worker_count=0,pinned_at=NULL, company_id=NULL,parent_ref=NULL,timeout_seconds=0,coverage_enabled=true,share_global_assets=false,worker_count=0,pinned_at=NULL,
first_run_at=NULL,deadline_at=NULL,archived_at=now(),deleted_at=now() first_run_at=NULL,deadline_at=NULL,archived_at=now(),deleted_at=now()
WHERE id=$1`, taskID); err != nil { WHERE id=$1`, taskID); err != nil {
return err return err
@@ -322,7 +322,7 @@ func restoreTaskStub(tx *sql.Tx, row map[string]any, taskID, remaining int64) er
llm_profile_id=archived.llm_profile_id,active_llm_profile_id=archived.active_llm_profile_id, llm_profile_id=archived.llm_profile_id,active_llm_profile_id=archived.active_llm_profile_id,
llm_chain_revision=archived.llm_chain_revision,company_id=archived.company_id,parent_ref=archived.parent_ref, llm_chain_revision=archived.llm_chain_revision,company_id=archived.company_id,parent_ref=archived.parent_ref,
timeout_seconds=archived.timeout_seconds,plan_heartbeat_seconds=archived.plan_heartbeat_seconds, timeout_seconds=archived.timeout_seconds,plan_heartbeat_seconds=archived.plan_heartbeat_seconds,
coverage_enabled=archived.coverage_enabled,worker_count=COALESCE(archived.worker_count,0),pinned_at=archived.pinned_at,first_run_at=archived.first_run_at, coverage_enabled=archived.coverage_enabled,share_global_assets=COALESCE(archived.share_global_assets,false),worker_count=COALESCE(archived.worker_count,0),pinned_at=archived.pinned_at,first_run_at=archived.first_run_at,
deadline_at=CASE WHEN archived.paused AND $3>0 THEN now()+make_interval(secs=>$3::double precision) deadline_at=CASE WHEN archived.paused AND $3>0 THEN now()+make_interval(secs=>$3::double precision)
ELSE archived.deadline_at END, ELSE archived.deadline_at END,
deleted_at=NULL,archived_at=NULL,completed_at=archived.completed_at, deleted_at=NULL,archived_at=NULL,completed_at=archived.completed_at,
+2 -2
View File
@@ -133,7 +133,7 @@ func TestTaskArchiveDatabaseRoundTrip(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
task, err := d.CreateTaskWithOptions("archive database roundtrip", "restore exact graph", TaskCreateOptions{ task, err := d.CreateTaskWithOptions("archive database roundtrip", "restore exact graph", TaskCreateOptions{
Name: "cold task", WorkerCount: 7, Name: "cold task", WorkerCount: 7, ShareGlobalAssets: true,
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -299,7 +299,7 @@ VALUES($1,$2,0,'quota_exhausted','balance exhausted',$3,$4,$3)`, task.ID, llmPro
if err != nil || live == nil { if err != nil || live == nil {
t.Fatalf("restored task = %+v, %v", live, err) t.Fatalf("restored task = %+v, %v", live, err)
} }
if live.Name != "cold task" || live.Remark != remark || live.WorkerCount != 7 || !live.Paused { if live.Name != "cold task" || live.Remark != remark || live.WorkerCount != 7 || !live.ShareGlobalAssets || !live.Paused {
t.Fatalf("restored task metadata mismatch: %+v", live) t.Fatalf("restored task metadata mismatch: %+v", live)
} }
if err := d.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, task.ExplorationID).Scan(&nodes); err != nil { if err := d.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, task.ExplorationID).Scan(&nodes); err != nil {
+78
View File
@@ -0,0 +1,78 @@
package db
import (
"encoding/json"
"fmt"
"testing"
)
func TestTaskAssetSharingOffAllowsManualAssociations(t *testing.T) {
d, assets, _ := testSetup(t)
defer d.Close()
task, err := d.CreateTask("manual assets while sharing off", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer d.DeleteTask(task.ID)
if task.ShareGlobalAssets {
t.Fatal("task must default to sharing off")
}
var ids []int64
defer func() { _, _ = assets.DeleteByIDs(ids) }()
for _, name := range []string{"selected", "unrelated"} {
id, err := assets.UpsertRootDomain(UpsertRootDomainReq{Domain: fmt.Sprintf("manual-sharing-%d-%s.invalid", task.ID, name)})
if err != nil {
t.Fatal(err)
}
ids = append(ids, id)
}
if got, err := assets.GetByIDsInScope(task.ID, ids); err != nil || len(got) != 0 {
t.Fatalf("unassociated global assets must be invisible: got=%v err=%v", got, err)
}
if _, err := assets.AttachAssetsToTask(task.ID, ids[:1], "用户明确选择的测试资产"); err != nil {
t.Fatal(err)
}
got, err := assets.GetByIDsInScope(task.ID, ids)
if err != nil || len(got) != 1 || got[0].ID != ids[0] {
t.Fatalf("only the manually associated asset should be visible: got=%v err=%v", got, err)
}
query := fmt.Sprintf("id==%d OR id==%d", ids[0], ids[1])
got, err = assets.QueryDSLInScope(query, "", task.ID, 10, 0)
if err != nil || len(got) != 1 || got[0].ID != ids[0] {
t.Fatalf("DSL must allow the association without exposing unrelated assets: got=%v err=%v", got, err)
}
}
func TestTaskAssetSharingLegacyArchiveDefault(t *testing.T) {
d, _, _ := testSetup(t)
defer d.Close()
task, err := d.CreateTaskWithOptions("legacy sharing archive", "goal", TaskCreateOptions{ShareGlobalAssets: true})
if err != nil {
t.Fatal(err)
}
defer d.DeleteTask(task.ID)
var raw []byte
if err := d.QueryRow(`SELECT row_to_json(t) FROM tasks t WHERE id=$1`, task.ID).Scan(&raw); err != nil {
t.Fatal(err)
}
var row map[string]any
if err := json.Unmarshal(raw, &row); err != nil {
t.Fatal(err)
}
delete(row, "share_global_assets")
tx, err := d.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
if err := restoreTaskStub(tx, row, task.ID, 0); err != nil {
t.Fatal(err)
}
var enabled bool
if err := tx.QueryRow(`SELECT share_global_assets FROM tasks WHERE id=$1`, task.ID).Scan(&enabled); err != nil {
t.Fatal(err)
}
if enabled {
t.Fatal("archives created before the sharing field must restore with sharing disabled")
}
}
+30 -37
View File
@@ -21,7 +21,25 @@ context_tasks AS (
WHERE relation.task_id=$1 WHERE relation.task_id=$1
)` )`
const contextCoverageCTE = directTaskContextCTE + `, // taskAssetVisibilityCTE reads the policy on every query. Explicit source tasks
// contribute their own assets, never their global-sharing setting.
const taskAssetVisibilityCTE = `,
visible_assets AS (
SELECT a.id FROM assets a
WHERE EXISTS (
SELECT 1 FROM tasks current
WHERE current.id=$1 AND current.deleted_at IS NULL AND current.share_global_assets
)
OR EXISTS (SELECT 1 FROM context_tasks ctx WHERE ctx.task_id=ANY(a.task_ids))
OR EXISTS (
SELECT 1 FROM exploration_anchors anchor
JOIN exploration_nodes node ON node.id=anchor.node_id
JOIN context_tasks ctx ON ctx.exploration_id=node.exploration_id
WHERE anchor.asset_id=a.id
)
)`
const contextCoverageCTE = directTaskContextCTE + taskAssetVisibilityCTE + `,
target AS ( target AS (
SELECT DISTINCT a.id, a.type, SELECT DISTINCT a.id, a.type,
COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
@@ -37,6 +55,10 @@ target AS (
)) ))
) )
JOIN context_tasks ctx ON ctx.task_id=ts.task_id JOIN context_tasks ctx ON ctx.task_id=ts.task_id
WHERE a.id IN (SELECT id FROM visible_assets)
UNION
SELECT a.id,a.type,COALESCE(a.url,a.domain,a.ip,a.app_name,a.root_domain,'') AS label
FROM assets a WHERE EXISTS (SELECT 1 FROM context_tasks ctx WHERE ctx.task_id=ANY(a.task_ids))
UNION UNION
SELECT a.id, a.type, SELECT a.id, a.type,
COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
@@ -52,41 +74,11 @@ tested AS (
JOIN context_tasks ctx ON ctx.exploration_id=en.exploration_id JOIN context_tasks ctx ON ctx.exploration_id=en.exploration_id
)` )`
// scopeTargetCTE selects every asset that BELONGS to the current task's (and its // scopeTargetCTE applies the live visibility policy to DSL and ID reads.
// direct source tasks') declared scope — membership, not literal value: a // An isolated task sees only its own and explicitly related tasks' assets.
// root_domain scope pulls in every subdomain / service / endpoint whose own const scopeTargetCTE = directTaskContextCTE + taskAssetVisibilityCTE + `,
// root_domain column equals it; an ip/cidr scope pulls in assets whose ip OR target AS (SELECT id FROM visible_assets)
// IP-literal host falls inside the net. $1 is the task id. Unlike contextCoverageCTE `
// it carries neither the fact-anchor union nor the tested set — it is pure "in
// declared scope", independent of what has already been touched. Used by the
// agent's list_assets so a query returns the task's relevant assets, not the
// whole shared库.
const scopeTargetCTE = `
context_tasks AS (
SELECT t.id AS task_id
FROM tasks t
WHERE t.id=$1 AND t.deleted_at IS NULL
UNION ALL
SELECT source.id
FROM task_relations relation
JOIN tasks source ON source.id=relation.source_task_id AND source.deleted_at IS NULL
WHERE relation.task_id=$1
),
target AS (
SELECT DISTINCT a.id
FROM assets a
JOIN task_scope ts ON (
(ts.kind='company' AND a.company_id = ts.company_id)
OR (ts.kind='root_domain' AND a.root_domain = ts.domain)
OR (ts.kind='subdomain' AND a.domain = ts.domain)
OR (ts.kind IN ('ip','cidr') AND (ts.net >>= try_inet(a.ip) OR ts.net >>= try_inet(a.domain)))
OR (ts.kind='icp' AND (
lower(regexp_replace(COALESCE(a.icp,''), '[[:space:]]+', '', 'g')) = ts.value
OR lower(regexp_replace(COALESCE(a.app_icp,''), '[[:space:]]+', '', 'g')) = ts.value
))
)
JOIN context_tasks ctx ON ctx.task_id=ts.task_id
)`
// ListTaskScopeWithSources returns the current task's scope followed by the // ListTaskScopeWithSources returns the current task's scope followed by the
// scopes of its direct source tasks. TaskScope.TaskID preserves provenance. // scopes of its direct source tasks. TaskScope.TaskID preserves provenance.
@@ -199,7 +191,7 @@ ORDER BY target.id LIMIT $`+limitPosition+` OFFSET $`+offsetPosition, pageArgs..
// used for destructive task cleanup; HostsByTask intentionally retains that // used for destructive task cleanup; HostsByTask intentionally retains that
// narrower, task-owned behavior. // narrower, task-owned behavior.
func (s *AssetStore) HostsByTaskWithSources(taskID int64) ([]string, error) { func (s *AssetStore) HostsByTaskWithSources(taskID int64) ([]string, error) {
rows, err := s.db.Query(`WITH `+directTaskContextCTE+`, rows, err := s.db.Query(`WITH `+directTaskContextCTE+taskAssetVisibilityCTE+`,
context_assets AS ( context_assets AS (
SELECT DISTINCT a.id SELECT DISTINCT a.id
FROM assets a FROM assets a
@@ -223,6 +215,7 @@ context_assets AS (
)) ))
) )
JOIN context_tasks ctx ON ctx.task_id=ts.task_id JOIN context_tasks ctx ON ctx.task_id=ts.task_id
WHERE a.id IN (SELECT id FROM visible_assets)
) )
SELECT COALESCE(a.domain,''), COALESCE(a.ip,''), COALESCE(a.url,'') SELECT COALESCE(a.domain,''), COALESCE(a.ip,''), COALESCE(a.url,'')
FROM assets a JOIN context_assets ctx ON ctx.id=a.id`, taskID) FROM assets a JOIN context_assets ctx ON ctx.id=a.id`, taskID)
+4 -3
View File
@@ -300,7 +300,7 @@ func (s *AssetStore) CoverageEnabled(taskID int64) bool {
} }
// task_scope→assets match predicate, reused by the count / by-type / untested queries. $1=taskID. // task_scope→assets match predicate, reused by the count / by-type / untested queries. $1=taskID.
const covTargetCTE = ` const covTargetCTE = directTaskContextCTE + taskAssetVisibilityCTE + `,
target AS ( target AS (
SELECT DISTINCT a.id, a.type, SELECT DISTINCT a.id, a.type,
COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
@@ -315,6 +315,7 @@ target AS (
OR lower(regexp_replace(COALESCE(a.app_icp,''), '[[:space:]]+', '', 'g')) = ts.value OR lower(regexp_replace(COALESCE(a.app_icp,''), '[[:space:]]+', '', 'g')) = ts.value
)) ))
) )
WHERE a.id IN (SELECT id FROM visible_assets)
), ),
tested AS ( tested AS (
SELECT DISTINCT ea.asset_id SELECT DISTINCT ea.asset_id
@@ -554,8 +555,8 @@ ORDER BY a.id`, taskID)
} }
for root := range missingRoots { for root := range missingRoots {
var id, companyID int64 var id, companyID int64
err := s.db.QueryRow(`SELECT id, COALESCE(company_id,0) FROM assets err := s.db.QueryRow(`WITH `+scopeTargetCTE+` SELECT id, COALESCE(company_id,0) FROM assets
WHERE type='root_domain' AND domain=$1 LIMIT 1`, root).Scan(&id, &companyID) WHERE type='root_domain' AND domain=$2 AND id IN (SELECT id FROM target) LIMIT 1`, taskID, root).Scan(&id, &companyID)
var node CoverageGraphNode var node CoverageGraphNode
if err == nil && id > 0 { if err == nil && id > 0 {
node = CoverageGraphNode{Key: assetKey(id), Kind: "root_domain", AssetID: id, node = CoverageGraphNode{Key: assetKey(id), Kind: "root_domain", AssetID: id,
+19 -14
View File
@@ -55,7 +55,8 @@ type Task struct {
// 覆盖度、不自动累积 task_scope(source=auto)、不给 agent 开放 add_task_scope/ // 覆盖度、不自动累积 task_scope(source=auto)、不给 agent 开放 add_task_scope/
// list_untested_assets、态势里不注入 coverage 块(scope 字段仍保留)。company 关联 // list_untested_assets、态势里不注入 coverage 块(scope 字段仍保留)。company 关联
// (task_scope kind=company)与此开关无关,永不受影响。见 db/task_scope.go。 // (task_scope kind=company)与此开关无关,永不受影响。见 db/task_scope.go。
CoverageEnabled bool `json:"coverage_enabled"` CoverageEnabled bool `json:"coverage_enabled"`
ShareGlobalAssets bool `json:"share_global_assets"`
// WorkerCount is the per-task concurrent work-agent count. 0 means use the // WorkerCount is the per-task concurrent work-agent count. 0 means use the
// global settings.workers default when the engine starts this task. // global settings.workers default when the engine starts this task.
WorkerCount int `json:"worker_count"` WorkerCount int `json:"worker_count"`
@@ -187,6 +188,8 @@ type TaskCreateOptions struct {
// CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建 // CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建
// 路径(编排 spawn、老 API)沿用原行为。仅 web 创建任务时可显式传 false 关闭。 // 路径(编排 spawn、老 API)沿用原行为。仅 web 创建任务时可显式传 false 关闭。
CoverageEnabled *bool CoverageEnabled *bool
// ShareGlobalAssets allows reading unrelated global assets. The default is false.
ShareGlobalAssets bool
// WorkerCount 是本任务并发运行的工作 Agent 数;0/省略=用全局默认。上限 50。 // WorkerCount 是本任务并发运行的工作 Agent 数;0/省略=用全局默认。上限 50。
WorkerCount int WorkerCount int
// Remark 是创建时可带的任务备注(最多 2000 字);空=无备注。 // Remark 是创建时可带的任务备注(最多 2000 字);空=无备注。
@@ -268,12 +271,12 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...), SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...),
CompanyIDs: append([]int64(nil), opts.CompanyIDs...), CompanyIDs: append([]int64(nil), opts.CompanyIDs...),
TimeoutSeconds: opts.TimeoutSeconds, PlanHeartbeatSeconds: opts.PlanHeartbeatSeconds, TimeoutSeconds: opts.TimeoutSeconds, PlanHeartbeatSeconds: opts.PlanHeartbeatSeconds,
CoverageEnabled: coverageEnabled, WorkerCount: opts.WorkerCount, Remark: opts.Remark, CoverageEnabled: coverageEnabled, ShareGlobalAssets: opts.ShareGlobalAssets, WorkerCount: opts.WorkerCount, Remark: opts.Remark,
} }
if err := tx.QueryRow(` if err := tx.QueryRow(`
INSERT INTO tasks(name, remark, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled, worker_count) INSERT INTO tasks(name, remark, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled, worker_count, share_global_assets)
VALUES ($1,$2,$3,$4,$5,$6,$7,$7,$8,$9,$10,$11) VALUES ($1,$2,$3,$4,$5,$6,$7,$7,$8,$9,$10,$11,$12)
RETURNING id, status, paused, created_at`, opts.Name, opts.Remark, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled, opts.WorkerCount).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil { RETURNING id, status, paused, created_at`, opts.Name, opts.Remark, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled, opts.WorkerCount, opts.ShareGlobalAssets).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
return nil, err return nil, err
} }
if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil { if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil {
@@ -410,11 +413,11 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {
const taskCols = `id, COALESCE(name,''), remark, category_id, const taskCols = `id, COALESCE(name,''), remark, category_id,
COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''), COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''),
description, goal, exploration_id, status, paused, COALESCE(pause_reason,''), queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), COALESCE(worker_count,0), first_run_at, deadline_at, COALESCE(active_run_seconds,0), run_segment_started_at` description, goal, exploration_id, status, paused, COALESCE(pause_reason,''), queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), share_global_assets, COALESCE(worker_count,0), first_run_at, deadline_at, COALESCE(active_run_seconds,0), run_segment_started_at`
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) { func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
var t Task var t Task
if err := sc.Scan(&t.ID, &t.Name, &t.Remark, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.PauseReason, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.WorkerCount, &t.FirstRunAt, &t.DeadlineAt, &t.ActiveRunSeconds, &t.RunSegmentStartedAt); err != nil { if err := sc.Scan(&t.ID, &t.Name, &t.Remark, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.PauseReason, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.ShareGlobalAssets, &t.WorkerCount, &t.FirstRunAt, &t.DeadlineAt, &t.ActiveRunSeconds, &t.RunSegmentStartedAt); err != nil {
return nil, err return nil, err
} }
t.Pinned = t.PinnedAt != nil t.Pinned = t.PinnedAt != nil
@@ -454,14 +457,15 @@ ORDER BY (pinned_at IS NOT NULL) DESC, pinned_at DESC NULLS LAST, id DESC`)
return out, nil return out, nil
} }
// TaskPatch updates task list metadata without changing execution state. // TaskPatch updates editable metadata and global asset visibility.
type TaskPatch struct { type TaskPatch struct {
Name *string Name *string
Remark *string Remark *string
Pinned *bool Pinned *bool
ShareGlobalAssets *bool
} }
// UpdateTask applies a partial task name/remark/pin mutation and returns the updated row. // UpdateTask applies a partial metadata/visibility mutation and returns the updated row.
// Re-pinning an already pinned task preserves its original position. // Re-pinning an already pinned task preserves its original position.
func (d *DB) UpdateTask(id int64, patch TaskPatch) (*Task, error) { func (d *DB) UpdateTask(id int64, patch TaskPatch) (*Task, error) {
task, err := scanTask(d.QueryRow(`UPDATE tasks SET task, err := scanTask(d.QueryRow(`UPDATE tasks SET
@@ -471,9 +475,10 @@ func (d *DB) UpdateTask(id int64, patch TaskPatch) (*Task, error) {
WHEN $4::boolean IS NULL THEN pinned_at WHEN $4::boolean IS NULL THEN pinned_at
WHEN $4::boolean THEN COALESCE(pinned_at, now()) WHEN $4::boolean THEN COALESCE(pinned_at, now())
ELSE NULL ELSE NULL
END END,
share_global_assets = COALESCE($7::boolean, share_global_assets)
WHERE id=$1 AND deleted_at IS NULL WHERE id=$1 AND deleted_at IS NULL
RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned, patch.Remark != nil, patch.Remark)) RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned, patch.Remark != nil, patch.Remark, patch.ShareGlobalAssets))
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
+3 -1
View File
@@ -69,7 +69,8 @@ type TaskDTO struct {
ArchiveBlockedBy string `json:"archive_blocked_by_task_id,omitempty"` ArchiveBlockedBy string `json:"archive_blocked_by_task_id,omitempty"`
CompanyIDs []int64 `json:"company_ids"` CompanyIDs []int64 `json:"company_ids"`
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定) CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定)
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认 ShareGlobalAssets bool `json:"share_global_assets"`
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
} }
type ProxyReconnectDTO struct { type ProxyReconnectDTO struct {
@@ -163,6 +164,7 @@ func taskDTO(t *Task, status string) TaskDTO {
SourceTaskIDs: sourceIDs, SourceTaskIDs: sourceIDs,
CompanyIDs: lifecycle.CompanyIDs, CompanyIDs: lifecycle.CompanyIDs,
CoverageEnabled: t.CoverageEnabled, CoverageEnabled: t.CoverageEnabled,
ShareGlobalAssets: lifecycle.ShareGlobalAssets,
WorkerCount: t.WorkerCount, WorkerCount: t.WorkerCount,
} }
} }
+15 -10
View File
@@ -68,7 +68,8 @@ type Task struct {
TimeoutSeconds int `json:"timeout_seconds"` TimeoutSeconds int `json:"timeout_seconds"`
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒) PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒)
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定,默认开) CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定,默认开)
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认 ShareGlobalAssets bool `json:"share_global_assets"`
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
FirstRunAt int64 `json:"first_run_at,omitempty"` FirstRunAt int64 `json:"first_run_at,omitempty"`
DeadlineAt int64 `json:"deadline_at,omitempty"` DeadlineAt int64 `json:"deadline_at,omitempty"`
ActiveRunSeconds int64 `json:"active_run_seconds"` ActiveRunSeconds int64 `json:"active_run_seconds"`
@@ -91,6 +92,7 @@ type Task struct {
// updateLifecycle instead of reading or writing the corresponding Task fields // updateLifecycle instead of reading or writing the corresponding Task fields
// directly after the task has been published by Manager. // directly after the task has been published by Manager.
type taskLifecycleState struct { type taskLifecycleState struct {
ShareGlobalAssets bool
Name string Name string
Remark string Remark string
PinnedAt int64 PinnedAt int64
@@ -128,13 +130,14 @@ func (t *Task) lifecycleSnapshot() taskLifecycleState {
func (t *Task) lifecycleSnapshotLocked() taskLifecycleState { func (t *Task) lifecycleSnapshotLocked() taskLifecycleState {
return taskLifecycleState{ return taskLifecycleState{
Name: t.Name, ShareGlobalAssets: t.ShareGlobalAssets,
Remark: t.Remark, Name: t.Name,
PinnedAt: t.PinnedAt, Remark: t.Remark,
Status: t.Status, PinnedAt: t.PinnedAt,
Paused: t.Paused, Status: t.Status,
PauseReason: t.PauseReason, Paused: t.Paused,
ReconnectAttempt: t.ReconnectAttempt, ReconnectMax: t.ReconnectMax, ReconnectNextAt: t.ReconnectNextAt, ReconnectExhausted: t.ReconnectExhausted, ReconnectLastError: t.ReconnectLastError, ReconnectGeneration: t.ReconnectGeneration, PauseReason: t.PauseReason,
ReconnectAttempt: t.ReconnectAttempt, ReconnectMax: t.ReconnectMax, ReconnectNextAt: t.ReconnectNextAt, ReconnectExhausted: t.ReconnectExhausted, ReconnectLastError: t.ReconnectLastError, ReconnectGeneration: t.ReconnectGeneration,
Queued: t.Queued, Queued: t.Queued,
QueuedAt: t.QueuedAt, QueuedAt: t.QueuedAt,
QueueMode: t.QueueMode, QueueMode: t.QueueMode,
@@ -157,6 +160,7 @@ func (t *Task) updateLifecycle(update func(*taskLifecycleState)) {
t.lifecycleMu.Lock() t.lifecycleMu.Lock()
state := t.lifecycleSnapshotLocked() state := t.lifecycleSnapshotLocked()
update(&state) update(&state)
t.ShareGlobalAssets = state.ShareGlobalAssets
t.Name = state.Name t.Name = state.Name
t.Remark = state.Remark t.Remark = state.Remark
t.PinnedAt = state.PinnedAt t.PinnedAt = state.PinnedAt
@@ -1153,14 +1157,14 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...), SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...),
CompanyIDs: append([]int64(nil), pt.CompanyIDs...), CompanyIDs: append([]int64(nil), pt.CompanyIDs...),
TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds, TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds,
CoverageEnabled: pt.CoverageEnabled, WorkerCount: pt.WorkerCount, CoverageEnabled: pt.CoverageEnabled, ShareGlobalAssets: pt.ShareGlobalAssets, WorkerCount: pt.WorkerCount,
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt), FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
ActiveRunSeconds: pt.ActiveRunSeconds, RunSegmentStartedAt: unixOrZero(pt.RunSegmentStartedAt), ActiveRunSeconds: pt.ActiveRunSeconds, RunSegmentStartedAt: unixOrZero(pt.RunSegmentStartedAt),
Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1), Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1),
} }
} }
// UpdateTaskMetadata changes list-only task metadata without interrupting any // UpdateTaskMetadata changes task metadata and asset visibility without interrupting any
// planner, main-agent, or worker call. // planner, main-agent, or worker call.
func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task, error) { func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task, error) {
m.taskStateMu.Lock() m.taskStateMu.Lock()
@@ -1180,6 +1184,7 @@ func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task
return nil, nil return nil, nil
} }
task.updateLifecycle(func(state *taskLifecycleState) { task.updateLifecycle(func(state *taskLifecycleState) {
state.ShareGlobalAssets = updated.ShareGlobalAssets
state.Name = updated.Name state.Name = updated.Name
state.Remark = updated.Remark state.Remark = updated.Remark
state.PinnedAt = unixOrZero(updated.PinnedAt) state.PinnedAt = unixOrZero(updated.PinnedAt)
+9 -7
View File
@@ -1710,9 +1710,10 @@ type createTaskReq struct {
TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时 TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600 PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。 SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。
CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。 ShareGlobalAssets bool `json:"share_global_assets"`
Remark string `json:"remark,omitempty"` // 任务备注(最多2000字);空=无 CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。
WorkerCount int `json:"worker_count,omitempty"` // 本任务 Worker 数;0/省略=用全局默认;上限50 Remark string `json:"remark,omitempty"` // 任务备注(最多2000字);空=无
WorkerCount int `json:"worker_count,omitempty"` // 本任务 Worker 数;0/省略=用全局默认;上限50
// InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。 // InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。
InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"` InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"`
} }
@@ -1794,10 +1795,11 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID, Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID,
SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs, SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs,
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds, TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds,
CoverageEnabled: req.CoverageEnabled, CoverageEnabled: req.CoverageEnabled,
Remark: remark, ShareGlobalAssets: req.ShareGlobalAssets,
WorkerCount: req.WorkerCount, Remark: remark,
InterceptRules: interceptRules, WorkerCount: req.WorkerCount,
InterceptRules: interceptRules,
}) })
if err != nil { if err != nil {
if errors.Is(err, db.ErrTaskCategoryInvalid) || errors.Is(err, db.ErrTaskCategoryNotFound) { if errors.Is(err, db.ErrTaskCategoryInvalid) || errors.Is(err, db.ErrTaskCategoryNotFound) {
+8 -8
View File
@@ -15,8 +15,7 @@ const (
maxTaskMetadataRequestSize = 16 << 10 maxTaskMetadataRequestSize = 16 << 10
) )
// updateTaskMetadata changes list-only task metadata. It intentionally does not // updateTaskMetadata updates editable metadata and the live asset-sharing policy.
// touch lifecycle state, scheduling, or the task's immutable description/goal.
func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) { func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("id") taskID := r.PathValue("id")
if _, ok := s.m.Task(taskID); !ok { if _, ok := s.m.Task(taskID); !ok {
@@ -25,9 +24,10 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
} }
r.Body = http.MaxBytesReader(w, r.Body, maxTaskMetadataRequestSize) r.Body = http.MaxBytesReader(w, r.Body, maxTaskMetadataRequestSize)
var request struct { var request struct {
Name *string `json:"name"` Name *string `json:"name"`
Remark *string `json:"remark"` Remark *string `json:"remark"`
Pinned *bool `json:"pinned"` Pinned *bool `json:"pinned"`
ShareGlobalAssets *bool `json:"share_global_assets"`
} }
if err := decode(r, &request); err != nil { if err := decode(r, &request); err != nil {
var tooLarge *http.MaxBytesError var tooLarge *http.MaxBytesError
@@ -38,8 +38,8 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
} }
return return
} }
if request.Name == nil && request.Pinned == nil && request.Remark == nil { if request.Name == nil && request.Pinned == nil && request.Remark == nil && request.ShareGlobalAssets == nil {
writeErr(w, http.StatusBadRequest, "至少需要提供 name、pinned 或 remark") writeErr(w, http.StatusBadRequest, "至少需要提供 name、pinned、remark 或 share_global_assets")
return return
} }
if request.Name != nil { if request.Name != nil {
@@ -63,7 +63,7 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
} }
request.Remark = &remark request.Remark = &remark
} }
task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned, Remark: request.Remark}) task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned, Remark: request.Remark, ShareGlobalAssets: request.ShareGlobalAssets})
if err != nil { if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error()) writeErr(w, http.StatusInternalServerError, err.Error())
return return
+48
View File
@@ -60,6 +60,54 @@ func TestTaskMetadataPatchReturnsRenameAndPin(t *testing.T) {
} }
} }
func TestTaskMetadataPatchGlobalAssetSharing(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable: %v", err)
}
defer m.Close()
task, err := m.CreateTask("sharing patch", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
id, _ := strconv.ParseInt(task.ID, 10, 64)
defer m.pg.DeleteTask(id)
ctx, cancel := context.WithCancel(context.Background())
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
defer func() { cancel(); s.Close() }()
token, err := signJWT(s.jwtKey)
if err != nil {
t.Fatal(err)
}
for _, enabled := range []bool{true, false} {
body, _ := json.Marshal(map[string]any{"share_global_assets": enabled})
req := httptest.NewRequest(http.MethodPatch, "/api/tasks/"+task.ID, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("patch %d: %s", rec.Code, rec.Body.String())
}
var result struct {
Shared *bool `json:"share_global_assets"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
saved, err := m.pg.GetTask(id)
if err != nil || saved.ShareGlobalAssets != enabled || result.Shared == nil || *result.Shared != enabled || task.lifecycleSnapshot().ShareGlobalAssets != enabled {
t.Fatalf("sharing not propagated: %s %+v %v", rec.Body.String(), saved, err)
}
}
req := httptest.NewRequest(http.MethodPatch, "/api/tasks/"+task.ID, strings.NewReader(`{"share_global_assets":"true"}`))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != 400 {
t.Fatalf("invalid boolean status=%d", rec.Code)
}
}
func TestConversationBatchDeleteReportsMissing(t *testing.T) { func TestConversationBatchDeleteReportsMissing(t *testing.T) {
m, err := NewManager(t.TempDir(), "") m, err := NewManager(t.TempDir(), "")
if err != nil { if err != nil {
+304 -115
View File
@@ -24,6 +24,8 @@ import {
ChevronRightIcon, ChevronRightIcon,
EyeIcon, EyeIcon,
FolderInputIcon, FolderInputIcon,
GlobeIcon,
GlobeOffIcon,
GripVerticalIcon, GripVerticalIcon,
Loader2Icon, Loader2Icon,
PaperclipIcon, PaperclipIcon,
@@ -795,6 +797,30 @@ export default function TasksPage() {
[load], [load],
); );
const toggleTaskGlobalAssets = React.useCallback(
async (task: Task) => {
const enabled = task.share_global_assets === true;
try {
const updated = await api.updateTask(task.id, {
share_global_assets: !enabled,
});
setTasks((current) =>
current.map((entry) =>
entry.id === task.id
? { ...entry, share_global_assets: updated.share_global_assets }
: entry,
),
);
toast.success(`任务 #${task.id} 已${enabled ? "关闭" : "开启"}共享全局资产`);
lastRef.current = "";
load();
} catch (error) {
toast.error(`设置失败:${(error as Error).message}`);
}
},
[load],
);
const queueTaskArchive = React.useCallback( const queueTaskArchive = React.useCallback(
async (task: Task) => { async (task: Task) => {
try { try {
@@ -1284,6 +1310,7 @@ export default function TasksPage() {
onRename={renameTask} onRename={renameTask}
onRemark={updateTaskRemark} onRemark={updateTaskRemark}
onTogglePinned={toggleTaskPinned} onTogglePinned={toggleTaskPinned}
onToggleGlobalAssets={toggleTaskGlobalAssets}
onArchive={queueTaskArchive} onArchive={queueTaskArchive}
selected={selectedIds.has(task.id)} selected={selectedIds.has(task.id)}
onSelectedChange={toggleSelected} onSelectedChange={toggleSelected}
@@ -1547,6 +1574,7 @@ const TaskRow = React.memo(function TaskRow({
onRename, onRename,
onRemark, onRemark,
onTogglePinned, onTogglePinned,
onToggleGlobalAssets,
onArchive, onArchive,
selected, selected,
onSelectedChange, onSelectedChange,
@@ -1559,6 +1587,7 @@ const TaskRow = React.memo(function TaskRow({
onRename: (task: Task, name: string) => Promise<void>; onRename: (task: Task, name: string) => Promise<void>;
onRemark: (task: Task, remark: string) => Promise<void>; onRemark: (task: Task, remark: string) => Promise<void>;
onTogglePinned: (task: Task) => Promise<void>; onTogglePinned: (task: Task) => Promise<void>;
onToggleGlobalAssets: (task: Task) => Promise<void>;
onArchive: (task: Task) => Promise<void>; onArchive: (task: Task) => Promise<void>;
selected: boolean; selected: boolean;
onSelectedChange: (id: string, checked: boolean) => void; onSelectedChange: (id: string, checked: boolean) => void;
@@ -1756,19 +1785,24 @@ const TaskRow = React.memo(function TaskRow({
</TableCell> </TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))] group-hover:bg-muted/50"> <TableCell className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))] group-hover:bg-muted/50">
<div className="flex items-center justify-end gap-0.5"> <div className="flex items-center justify-end gap-0.5">
<Button <TaskGlobalAssetsAction task={task} onToggle={onToggleGlobalAssets} />
size="icon" <TaskActionTooltip
variant="ghost" label="查看任务详情"
asChild description="查看任务进度、探索记录、测试资产和发现的漏洞。"
aria-label="查看任务详情"
title="查看任务详情"
> >
<Link <Button
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`} size="icon"
variant="ghost"
asChild
aria-label="查看任务详情"
> >
<EyeIcon /> <Link
</Link> href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
</Button> >
<EyeIcon />
</Link>
</Button>
</TaskActionTooltip>
<TaskControlButton task={task} onControl={onControl} /> <TaskControlButton task={task} onControl={onControl} />
<TaskPinAction task={task} onTogglePinned={onTogglePinned} /> <TaskPinAction task={task} onTogglePinned={onTogglePinned} />
<TaskArchiveAction task={task} onArchive={onArchive} /> <TaskArchiveAction task={task} onArchive={onArchive} />
@@ -1779,6 +1813,40 @@ const TaskRow = React.memo(function TaskRow({
); );
}); });
function TaskActionTooltip({
label,
description,
children,
disabled = false,
}: {
label: string;
description: string;
children: React.ReactElement;
disabled?: boolean;
}) {
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
{disabled ? (
<span className="inline-flex" aria-label={label}>
{children}
</span>
) : (
children
)}
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={6}
className="block max-w-64 space-y-1 text-left leading-relaxed"
>
<p className="font-medium">{label}</p>
<p>{description}</p>
</TooltipContent>
</Tooltip>
);
}
function TaskNameEditor({ function TaskNameEditor({
task, task,
onRename, onRename,
@@ -1865,20 +1933,24 @@ function TaskNameEditor({
<span className="text-muted-foreground">未命名</span> <span className="text-muted-foreground">未命名</span>
)} )}
</Link> </Link>
<Button <TaskActionTooltip
type="button" label="重命名任务"
variant="ghost" description="修改任务名称,便于识别和查找。"
size="icon-xs"
className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100"
aria-label={`重命名任务 #${task.id}`}
title="重命名"
onClick={() => {
setName(task.name ?? "");
setEditing(true);
}}
> >
<PencilIcon /> <Button
</Button> type="button"
variant="ghost"
size="icon-xs"
className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100"
aria-label={`重命名任务 #${task.id}`}
onClick={() => {
setName(task.name ?? "");
setEditing(true);
}}
>
<PencilIcon />
</Button>
</TaskActionTooltip>
</div> </div>
); );
} }
@@ -1900,24 +1972,93 @@ function TaskPinAction({
const pinned = taskIsPinned(task); const pinned = taskIsPinned(task);
return ( return (
<Button <TaskActionTooltip
type="button" label={pinned ? "取消置顶" : "置顶任务"}
variant="ghost" description={
size="icon" pinned
? "取消此任务的置顶,恢复正常排序。"
: "将此任务固定在列表顶部,便于快速查看。"
}
disabled={pinning} disabled={pinning}
aria-label={pinned ? `取消置顶任务 #${task.id}` : `置顶任务 #${task.id}`}
title={pinned ? "取消置顶" : "置顶"}
onClick={async () => {
setPinning(true);
try {
await onTogglePinned(task);
} finally {
setPinning(false);
}
}}
> >
{taskPinIcon(pinning, pinned)} <Button
</Button> type="button"
variant="ghost"
size="icon"
disabled={pinning}
aria-label={
pinned ? `取消置顶任务 #${task.id}` : `置顶任务 #${task.id}`
}
onClick={async () => {
setPinning(true);
try {
await onTogglePinned(task);
} finally {
setPinning(false);
}
}}
>
{taskPinIcon(pinning, pinned)}
</Button>
</TaskActionTooltip>
);
}
function taskGlobalAssetsIcon(saving: boolean, enabled: boolean) {
if (saving) return <Loader2Icon className="animate-spin" />;
if (enabled) return <GlobeIcon />;
return <GlobeOffIcon />;
}
function TaskGlobalAssetsAction({
task,
onToggle,
}: {
task: Task;
onToggle: (task: Task) => Promise<void>;
}) {
const [saving, setSaving] = React.useState(false);
const enabled = task.share_global_assets === true;
const label = `共享全局资产:${enabled ? "已开启,点击关闭" : "未开启,点击开启"}`;
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
aria-label={label}
aria-pressed={enabled}
aria-busy={saving}
className={enabled ? "text-foreground" : "text-muted-foreground"}
disabled={saving}
onClick={async () => {
setSaving(true);
try {
await onToggle(task);
} finally {
setSaving(false);
}
}}
>
{taskGlobalAssetsIcon(saving, enabled)}
</Button>
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={6}
className="block max-w-64 space-y-1 text-left leading-relaxed"
>
<p className="font-medium">
共享全局资产 · {enabled ? "已开启" : "已关闭"}
</p>
<p>
开启后可查询全局资产库;关闭后仅使用本任务已关联的资产及直接关联任务的资产。
</p>
<p className="opacity-75">
{enabled ? "点击关闭共享" : "点击开启共享"}
</p>
</TooltipContent>
</Tooltip>
); );
} }
@@ -1940,25 +2081,34 @@ function TaskControlButton({
const [pending, setPending] = React.useState(false); const [pending, setPending] = React.useState(false);
const action = taskControlAction(task.status); const action = taskControlAction(task.status);
const label = action === "resume" ? "继续任务" : "暂停任务"; const label = action === "resume" ? "继续任务" : "暂停任务";
let description = "暂停当前任务的执行,之后可继续。";
if (action === "resume")
description = "继续已暂停的任务;等待中的任务将重新进入调度。";
if (!action) description = "当前任务状态不支持暂停或继续。";
return ( return (
<Button <TaskActionTooltip
size="icon" label={label}
variant="ghost" description={description}
aria-label={label}
title={action ? label : "该状态不可暂停或继续"}
disabled={!action || pending} disabled={!action || pending}
onClick={async () => {
if (!action) return;
setPending(true);
try {
await onControl(task.id, action);
} finally {
setPending(false);
}
}}
> >
{taskControlIcon(pending, action)} <Button
</Button> size="icon"
variant="ghost"
aria-label={label}
disabled={!action || pending}
onClick={async () => {
if (!action) return;
setPending(true);
try {
await onControl(task.id, action);
} finally {
setPending(false);
}
}}
>
{taskControlIcon(pending, action)}
</Button>
</TaskActionTooltip>
); );
} }
@@ -2040,20 +2190,27 @@ function TaskArchiveAction({
); );
if (reason) { if (reason) {
return ( return (
<Tooltip> <TaskActionTooltip label="暂不可归档" description={reason} disabled>
<TooltipTrigger asChild> {trigger}
<span className="inline-flex">{trigger}</span> </TaskActionTooltip>
</TooltipTrigger>
<TooltipContent>{reason}</TooltipContent>
</Tooltip>
); );
} }
return ( return (
<ArchiveConfirmDialog <Tooltip delayDuration={200}>
count={1} <ArchiveConfirmDialog
trigger={trigger} count={1}
onConfirm={() => onArchive(task)} trigger={<TooltipTrigger asChild>{trigger}</TooltipTrigger>}
/> onConfirm={() => onArchive(task)}
/>
<TooltipContent
side="top"
sideOffset={6}
className="block max-w-64 space-y-1 text-left leading-relaxed"
>
<p className="font-medium">归档任务</p>
<p>将任务及相关记录压缩保存,之后可从“已归档”中还原。点击后需确认。</p>
</TooltipContent>
</Tooltip>
); );
} }
@@ -2545,42 +2702,51 @@ function TaskArchivesPanel({ onChanged }: { onChanged: () => void }) {
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-0.5"> <div className="flex justify-end gap-0.5">
{ARCHIVE_FAILED_STATES.has(archive.state) && ( {ARCHIVE_FAILED_STATES.has(archive.state) && (
<Button <TaskActionTooltip label="重试归档操作" description="重新执行上次失败的归档、还原或删除操作。">
size="icon" <Button
variant="ghost" size="icon"
onClick={() => void retry(archive)} variant="ghost"
aria-label="重试归档操作" onClick={() => void retry(archive)}
title="重试" aria-label="重试归档操作"
> >
<Undo2Icon /> <Undo2Icon />
</Button> </Button>
</TaskActionTooltip>
)} )}
{canRestore && ( {canRestore && (
<Button <TaskActionTooltip label="还原任务" description="将归档中的任务及相关记录恢复到当前任务列表。">
size="icon" <Button
variant="ghost" size="icon"
onClick={() => void restoreMany([archive])} variant="ghost"
aria-label="还原任务" onClick={() => void restoreMany([archive])}
title="还原任务" aria-label="还原任务"
> >
<Undo2Icon /> <Undo2Icon />
</Button> </Button>
</TaskActionTooltip>
)} )}
{canDelete && ( {canDelete && (
<ArchiveDeleteDialog <Tooltip delayDuration={200}>
archives={[archive]} <ArchiveDeleteDialog
onConfirm={() => deleteMany([archive])} archives={[archive]}
trigger={ onConfirm={() => deleteMany([archive])}
<Button trigger={
size="icon" <TooltipTrigger asChild>
variant="ghost" <Button
aria-label="永久删除归档" size="icon"
title="永久删除" variant="ghost"
> aria-label="永久删除归档"
<Trash2Icon /> >
</Button> <Trash2Icon />
} </Button>
/> </TooltipTrigger>
}
/>
<TooltipContent side="top" sideOffset={6} className="block max-w-64 space-y-1 text-left leading-relaxed">
<p className="font-medium">永久删除归档</p>
<p>删除此归档及其保存的数据,删除后无法还原。点击后需确认。</p>
</TooltipContent>
</Tooltip>
)} )}
</div> </div>
</TableCell> </TableCell>
@@ -2795,11 +2961,13 @@ function DeleteTaskDialog({
return ( return (
<AlertDialog open={open} onOpenChange={handleOpenChange}> <AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogTrigger asChild> <TaskActionTooltip label="删除任务" description="删除任务及执行记录;可在确认窗口选择是否同时清理关联数据。">
<Button size="icon" variant="outline" aria-label="删除任务"> <AlertDialogTrigger asChild>
<Trash2Icon className="text-destructive" /> <Button size="icon" variant="outline" aria-label="删除任务">
</Button> <Trash2Icon className="text-destructive" />
</AlertDialogTrigger> </Button>
</AlertDialogTrigger>
</TaskActionTooltip>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认删除任务 #{task.id}?</AlertDialogTitle> <AlertDialogTitle>确认删除任务 #{task.id}?</AlertDialogTitle>
@@ -3415,19 +3583,24 @@ function DraggableCategoryTask({
{moving ? ( {moving ? (
<Spinner /> <Spinner />
) : ( ) : (
<Button <TaskActionTooltip
type="button" label="移动任务分类"
size="icon-xs" description="按住并拖动此图标,将任务移动到目标分类。"
variant="ghost"
className="touch-none cursor-grab active:cursor-grabbing"
disabled={disabled} disabled={disabled}
{...listeners}
{...attributes}
aria-label={`拖动任务 #${task.id}`}
title="拖动任务"
> >
<GripVerticalIcon /> <Button
</Button> type="button"
size="icon-xs"
variant="ghost"
className="touch-none cursor-grab active:cursor-grabbing"
disabled={disabled}
{...listeners}
{...attributes}
aria-label={`拖动任务 #${task.id}`}
>
<GripVerticalIcon />
</Button>
</TaskActionTooltip>
)} )}
</ItemMedia> </ItemMedia>
<ItemContent className="min-w-0"> <ItemContent className="min-w-0">
@@ -3877,6 +4050,7 @@ function CreateTaskSheet({
const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时 const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致) const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行 const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
const [shareGlobalAssets, setShareGlobalAssets] = React.useState(false);
const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响) const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响)
const [workerCount, setWorkerCount] = React.useState(""); // 本任务 Worker 数;空/0=用全局默认 const [workerCount, setWorkerCount] = React.useState(""); // 本任务 Worker 数;空/0=用全局默认
const [interceptRules, setInterceptRules] = React.useState< const [interceptRules, setInterceptRules] = React.useState<
@@ -3972,6 +4146,7 @@ function CreateTaskSheet({
seedFirstIntent, seedFirstIntent,
planHeartbeatSeconds: heartbeatSec, planHeartbeatSeconds: heartbeatSec,
coverageEnabled, coverageEnabled,
shareGlobalAssets,
workerCount: workerCountNum, workerCount: workerCountNum,
schedule: scheduleEnabled ? { ...schedule, task_ids: [] } : undefined, schedule: scheduleEnabled ? { ...schedule, task_ids: [] } : undefined,
interceptRules: interceptRules interceptRules: interceptRules
@@ -3992,6 +4167,7 @@ function CreateTaskSheet({
setHeartbeatMin("10"); setHeartbeatMin("10");
setSeedFirstIntent(false); setSeedFirstIntent(false);
setCoverageEnabled(true); setCoverageEnabled(true);
setShareGlobalAssets(false);
setWorkerCount(""); setWorkerCount("");
setInterceptRules([]); setInterceptRules([]);
setScheduleEnabled(false); setScheduleEnabled(false);
@@ -4286,6 +4462,19 @@ function CreateTaskSheet({
onChange={(e) => setGoal(e.target.value)} onChange={(e) => setGoal(e.target.value)}
/> />
</div> </div>
<Field>
<div className="flex items-center gap-2">
<Checkbox
id="share-global-assets"
checked={shareGlobalAssets}
onCheckedChange={(checked) => setShareGlobalAssets(checked === true)}
/>
<Label htmlFor="share-global-assets">共享全局资产</Label>
</div>
<FieldDescription>
默认关闭,仅使用本任务及明确关联任务的资产。开启后可查询全局资产库,测试仍须遵守授权范围。
</FieldDescription>
</Field>
<Field> <Field>
<FieldLabel htmlFor="source-tasks">关联任务</FieldLabel> <FieldLabel htmlFor="source-tasks">关联任务</FieldLabel>
<SourceTaskPicker <SourceTaskPicker
+18 -11
View File
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { MAX_TASK_REMARK_RUNES } from "@/lib/types"; import { MAX_TASK_REMARK_RUNES } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -165,17 +166,23 @@ function InlineTextEditor({
<span className="text-muted-foreground">{emptyLabel}</span> <span className="text-muted-foreground">{emptyLabel}</span>
)} )}
</button> </button>
<Button <Tooltip delayDuration={200}>
type="button" <TooltipTrigger asChild>
variant="ghost" <Button
size="icon-xs" type="button"
className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:group-focus-within/inline-editor:opacity-100 [@media(hover:hover)]:group-hover/inline-editor:opacity-100" variant="ghost"
aria-label={`编辑${label}的${fieldName}`} size="icon-xs"
title={`编辑${fieldName}`} className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:group-focus-within/inline-editor:opacity-100 [@media(hover:hover)]:group-hover/inline-editor:opacity-100"
onClick={startEditing} aria-label={`编辑${label}的${fieldName}`}
> onClick={startEditing}
<PencilIcon /> >
</Button> <PencilIcon />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
编辑{fieldName}:输入完成后按 Enter 或点击其他位置保存,按 Esc 取消。
</TooltipContent>
</Tooltip>
</div> </div>
); );
} }
+3 -1
View File
@@ -346,6 +346,7 @@ export const api = {
timeoutSeconds?: number; timeoutSeconds?: number;
seedFirstIntent?: boolean; seedFirstIntent?: boolean;
planHeartbeatSeconds?: number; planHeartbeatSeconds?: number;
shareGlobalAssets?: boolean;
coverageEnabled?: boolean; coverageEnabled?: boolean;
workerCount?: number; workerCount?: number;
interceptRules?: AssetInterceptRuleInput[]; interceptRules?: AssetInterceptRuleInput[];
@@ -363,6 +364,7 @@ export const api = {
timeout_seconds: input.timeoutSeconds ?? 0, timeout_seconds: input.timeoutSeconds ?? 0,
seed_first_intent: input.seedFirstIntent ?? false, seed_first_intent: input.seedFirstIntent ?? false,
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min) plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
share_global_assets: input.shareGlobalAssets ?? false,
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能 coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
worker_count: input.workerCount ?? 0, // 0 = 用全局默认 workers worker_count: input.workerCount ?? 0, // 0 = 用全局默认 workers
intercept_rules: input.interceptRules ?? [], // 任务级资产拦截规则 intercept_rules: input.interceptRules ?? [], // 任务级资产拦截规则
@@ -380,7 +382,7 @@ export const api = {
runScheduleNow: (id: number) => runScheduleNow: (id: number) =>
post<{ ok: boolean; started: number; errors?: string[] }>(`/schedules/${id}/run-now`, {}), post<{ ok: boolean; started: number; errors?: string[] }>(`/schedules/${id}/run-now`, {}),
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)), taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
updateTask: (id: string, input: { name?: string; pinned?: boolean; remark?: string }) => updateTask: (id: string, input: { name?: string; pinned?: boolean; remark?: string; share_global_assets?: boolean }) =>
patch<Task>(`/tasks/${id}`, input), patch<Task>(`/tasks/${id}`, input),
renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }), renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }),
pinTask: (id: string, pinned: boolean) => patch<Task>(`/tasks/${id}`, { pinned }), pinTask: (id: string, pinned: boolean) => patch<Task>(`/tasks/${id}`, { pinned }),
+6 -1
View File
@@ -253,6 +253,7 @@ function mockTaskArchiveBlocker(taskID: string): string | undefined {
function publicMockTask(task: Task): Task { function publicMockTask(task: Task): Task {
const item = structuredClone(task); const item = structuredClone(task);
item.share_global_assets = task.share_global_assets ?? false;
const blocker = mockTaskArchiveBlocker(task.id); const blocker = mockTaskArchiveBlocker(task.id);
if (blocker) item.archive_blocked_by_task_id = blocker; if (blocker) item.archive_blocked_by_task_id = blocker;
else delete item.archive_blocked_by_task_id; else delete item.archive_blocked_by_task_id;
@@ -1272,6 +1273,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
llm_failover_state: profileIDs.length ? "ready" : "default", llm_failover_state: profileIDs.length ? "ready" : "default",
source_task_ids: sourceTaskIDs, source_task_ids: sourceTaskIDs,
company_ids: companyIDs, company_ids: companyIDs,
share_global_assets: b.share_global_assets === true,
}; };
const numericTaskID = nextMockTaskAssetID++; const numericTaskID = nextMockTaskAssetID++;
mockTaskAssetIDs.set(id, numericTaskID); mockTaskAssetIDs.set(id, numericTaskID);
@@ -1460,10 +1462,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (has("name") && b.name !== null && typeof b.name !== "string") throw new Error("任务名称必须是字符串"); if (has("name") && b.name !== null && typeof b.name !== "string") throw new Error("任务名称必须是字符串");
if (has("remark") && b.remark !== null && typeof b.remark !== "string") throw new Error("备注必须是字符串"); if (has("remark") && b.remark !== null && typeof b.remark !== "string") throw new Error("备注必须是字符串");
if (has("pinned") && b.pinned !== null && typeof b.pinned !== "boolean") throw new Error("pinned 必须是布尔值"); if (has("pinned") && b.pinned !== null && typeof b.pinned !== "boolean") throw new Error("pinned 必须是布尔值");
if (has("share_global_assets") && b.share_global_assets !== null && typeof b.share_global_assets !== "boolean") throw new Error("share_global_assets 必须是布尔值");
const sharingPresent = has("share_global_assets") && b.share_global_assets !== null;
const namePresent = has("name") && b.name !== null; const namePresent = has("name") && b.name !== null;
const remarkPresent = has("remark") && b.remark !== null; const remarkPresent = has("remark") && b.remark !== null;
const pinnedPresent = has("pinned") && b.pinned !== null; const pinnedPresent = has("pinned") && b.pinned !== null;
if (!namePresent && !remarkPresent && !pinnedPresent) throw new Error("至少需要提供 name、pinned 或 remark"); if (!namePresent && !remarkPresent && !pinnedPresent && !sharingPresent) throw new Error("至少需要提供 name、pinned、remark 或 share_global_assets");
let nextName = task.name; let nextName = task.name;
let nextRemark = task.remark; let nextRemark = task.remark;
let nextPinned = task.pinned; let nextPinned = task.pinned;
@@ -1480,6 +1484,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
nextPinned = pinned; nextPinned = pinned;
nextPinnedAt = pinned ? (task.pinned_at ?? new Date().toISOString()) : null; nextPinnedAt = pinned ? (task.pinned_at ?? new Date().toISOString()) : null;
} }
if (sharingPresent) task.share_global_assets = b.share_global_assets as boolean;
task.name = nextName; task.name = nextName;
task.remark = nextRemark; task.remark = nextRemark;
task.pinned = nextPinned; task.pinned = nextPinned;
+1
View File
@@ -62,6 +62,7 @@ export interface Task {
source_task_ids?: string[]; // directly related tasks inherited as read-only context source_task_ids?: string[]; // directly related tasks inherited as read-only context
archive_blocked_by_task_id?: string; // live direct dependent that must be archived first archive_blocked_by_task_id?: string; // live direct dependent that must be archived first
company_ids?: number[]; // associated company scopes; current company assets join the task at creation company_ids?: number[]; // associated company scopes; current company assets join the task at creation
share_global_assets?: boolean; // opt-in global asset visibility; default false
coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度 coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度
} }