From 080a46eb2d3d698a580efbc26badd905f229b206 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:27:44 +0800 Subject: [PATCH] feat: make task global asset sharing opt-in --- agent/planner.go | 4 +- agent/task_asset_sharing_test.go | 76 ++++ agent/tools.go | 25 +- agent/tools_insert.go | 6 +- agent/worker.go | 2 +- db/asset_dsl.go | 19 +- db/assets_test.go | 221 ++++++----- db/companies.go | 49 +++ db/schema.sql | 2 + db/task_archives_restore.go | 4 +- db/task_archives_test.go | 4 +- db/task_asset_sharing_test.go | 78 ++++ db/task_assets_context.go | 67 ++-- db/task_scope.go | 7 +- db/tasks.go | 33 +- server/dto.go | 4 +- server/manager.go | 25 +- server/server.go | 16 +- server/task_metadata.go | 16 +- server/task_metadata_test.go | 48 +++ web/src/app/(main)/function/tasks/page.tsx | 419 +++++++++++++++------ web/src/components/remark-editor.tsx | 29 +- web/src/lib/api.ts | 4 +- web/src/lib/mock/handler.ts | 7 +- web/src/lib/types.ts | 1 + 25 files changed, 838 insertions(+), 328 deletions(-) create mode 100644 agent/task_asset_sharing_test.go create mode 100644 db/task_asset_sharing_test.go diff --git a/agent/planner.go b/agent/planner.go index cec6755..8383c6c 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -309,10 +309,10 @@ const plannerDefaultTmpl = `你是一个网络安全平台授权渗透测试系 **每次唤醒的决策流程**: 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;据此理解"哪些事实来自哪个方向、能否综合出新方向"。 - **否定/存疑观察**(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 驱动,没有别的"一键完成"手段。 - ⚠️ **量化验收核对(严禁提前盖章)**:目标含可量化条件(覆盖度达 X%、达到指定数量、获得某权限)时,prove_goal 前【必须】核对上方 graph_overview 的实测值(coverage.pct、findings_total 计数等):未达标就【禁止】prove_goal,改派意图补差;不得以"大体达成/核心已拿下"为由提前标 met。例:要求覆盖度 100% 而实测 coverage.pct=40% → 未达成,继续派补测意图。 diff --git a/agent/task_asset_sharing_test.go b/agent/task_asset_sharing_test.go new file mode 100644 index 0000000..8fb4a85 --- /dev/null +++ b/agent/task_asset_sharing_test.go @@ -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) + } +} diff --git a/agent/tools.go b/agent/tools.go index 52774b2..7012afa 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -226,13 +226,12 @@ func (t *ToolSet) AddHintTool() actool.CoreTool { return t.addHint() func (t *ToolSet) SetEnrich(e EnrichTrigger) { t.enrich = e } // SetOwnerNode sets the exploration node that writes anchor to (worker: its -// intent node; planner/main: the begin root). Assets created/referenced while -// ownerNode is set are anchored to it as lineage (not visibility). +// intent node; planner/main: the begin root). Anchors preserve explicit asset +// references and make them available in this task context. func (t *ToolSet) SetOwnerNode(id int64) { t.ownerNode = id } // 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 -// this no longer affects which assets a task can read. +// (no-op if unset). These explicit references also contribute to task visibility. func (t *ToolSet) anchorOwner(assetID int64) { if t.ts != nil && t.ownerNode > 0 && assetID > 0 { _ = t.ts.Anchor(t.ownerNode, assetID) @@ -1054,6 +1053,24 @@ func (t *ToolSet) addOneIntent(it intentItem) (int64, error) { priority = 5 } 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 { hits, err := t.as.CheckAssetsIntercept(t.taskID, anchors) diff --git a/agent/tools_insert.go b/agent/tools_insert.go index 91366aa..7022bb9 100644 --- a/agent/tools_insert.go +++ b/agent/tools_insert.go @@ -539,7 +539,7 @@ func (t *ToolSet) listUntestedAssets() actool.CoreTool { func (t *ToolSet) listAssets() actool.CoreTool { return readTool( "list_assets", - "查询资产库:DSL 表达式搜索,或按 id/ids 直取;支持分页。只返回【本任务及直接关联任务】测试范围内的资产。\n"+ + "查询资产库:DSL 表达式搜索,或按 id/ids 直取;支持分页。默认只返回本任务及直接关联任务的资产;用户开启「共享全局资产」后可查询全局资产。\n"+ "DSL:field=value 模糊(ILIKE) | field==value 精确 | field!=value 排除 | 数字字段支持 > >= < <= | 裸词=全文模糊;AND/OR 组合(AND 优先级高),可用括号分组。资产类型用独立 type 参数,不写进 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"+ @@ -596,7 +596,7 @@ func (t *ToolSet) listAssets() actool.CoreTool { func (t *ToolSet) listCompanies() actool.CoreTool { return readTool( "list_companies", - "列出资产库中的【企业/公司】及其资产范围(scope)与已归属资产数。用于查看有哪些公司、"+ + "列出当前任务可见的【企业/公司】及其资产范围(scope)与可见资产数;开启共享全局资产后可查询全局企业。用于查看有哪些公司、"+ "拿到 company_id(insert_assets 关联 app、list_assets 按 company_id 过滤时用)。"+ "可选 search 按公司名模糊过滤(不区分大小写),留空返回全部。", obj(map[string]any{ @@ -610,7 +610,7 @@ func (t *ToolSet) listCompanies() actool.CoreTool { Search string `json:"search"` } _ = json.Unmarshal(in, &a) - cos, err := t.cs.ListCompanies() + cos, err := t.cs.ListCompaniesForTask(t.taskID) if err != nil { return actool.Errorf("查询公司失败: " + err.Error()), nil } diff --git a/agent/worker.go b/agent/worker.go index 213c726..4aa31c2 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -398,7 +398,7 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db. sysBody += renderIntentTask(intent) if as != nil { 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 { sysBody += "\n\n本意图 asset_ids 对应的目标资产:\n" + string(b) } diff --git a/db/asset_dsl.go b/db/asset_dsl.go index a8fcb06..4403070 100644 --- a/db/asset_dsl.go +++ b/db/asset_dsl.go @@ -490,6 +490,7 @@ func (s *AssetStore) CountDSL(dsl, typ string, taskID int64) (int, error) { if err != nil { return 0, err } + where = "(" + where + ")" if typ != "" { args = append(args, typ) 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 { return nil, err } + where = "(" + where + ")" if typ != "" { args = append(args, typ) 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 } -// QueryDSLInScope is QueryDSL restricted to assets that BELONG to taskID's (and its -// direct source tasks') declared scope — membership, not literal value: a -// root_domain scope returns every subdomain / service / endpoint under it. This is -// 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. +// QueryDSLInScope applies the task's live asset-sharing policy. By default only +// owned and directly related assets are visible; sharing enables global reads. +// Non-task contexts (taskID<=0) retain global reads. func (s *AssetStore) QueryDSLInScope(dsl, typ string, taskID int64, limit, offset int) ([]*Asset, error) { if taskID <= 0 { 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 = append(args, dslArgs...) + where = "(" + where + ")" if typ != "" { args = append(args, typ) 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 } -// GetByIDsInScope is GetByIDs restricted to ids that BELONG to taskID's (and its -// direct source tasks') declared scope, so an agent cannot reach out-of-scope -// 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). +// GetByIDsInScope applies the same visibility policy to explicit ID reads. +// Invisible IDs are omitted. Non-task contexts retain global reads. func (s *AssetStore) GetByIDsInScope(taskID int64, ids []int64) ([]*Asset, error) { if taskID <= 0 { return s.GetByIDs(ids) diff --git a/db/assets_test.go b/db/assets_test.go index 657ca39..ed1f04d 100644 --- a/db/assets_test.go +++ b/db/assets_test.go @@ -1000,127 +1000,158 @@ func TestAssetIPRejectsHostname(t *testing.T) { deleteAsset(d, id) } -// TestQueryDSLInScopeMembership pins the agent-facing list_assets behavior: it -// returns assets that BELONG to the task's declared scope (membership, not literal -// 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. +// TestQueryDSLInScopeMembership verifies that scope matching alone cannot import +// unrelated assets, and that the live opt-in switch applies to DSL and ID reads. func TestQueryDSLInScopeMembership(t *testing.T) { - d, assets, _ := testSetup(t) - defer d.Close() - - task, err := d.CreateTask("scope membership", "goal", nil, 0, 0) + d, assets, companies := testSetup(t) + 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("isolated task", "goal", nil, 0, 0) if err != nil { 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 { t.Fatal(err) } - t.Cleanup(func() { _ = d.DeleteTask(task.ID); _ = d.DeleteTask(src.ID) }) - // task ← src is a direct source relation. - if _, err := d.Exec(`INSERT INTO task_relations(task_id, source_task_id) VALUES ($1,$2)`, task.ID, src.ID); err != nil { - t.Fatal(err) - } - - stamp := time.Now().UnixNano() - root := fmt.Sprintf("sc%d.invalid", stamp) // in-scope root domain (task) - srcRoot := fmt.Sprintf("src%d.invalid", stamp) // in-scope via source task - out := fmt.Sprintf("out%d.invalid", stamp) // out of every scope - marker := fmt.Sprintf("mk%d", stamp) // bare-text token present in all rows - foreignTask := stamp + 777 // asset produced by an unrelated task - - // Scope: task owns root; source task owns srcRoot; task owns an IP /24. - for _, sc := range []struct { - tid int64 - 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) + for _, candidate := range []*Task{task, source} { + for { + var count int + if err := d.QueryRow(`SELECT count(*) FROM assets WHERE $1=ANY(task_ids)`, candidate.ID).Scan(&count); err != nil { + t.Fatal(err) + } + if count == 0 { + break + } + shared := candidate.ShareGlobalAssets + _ = d.DeleteTask(candidate.ID) + replacement, err := d.CreateTaskWithOptions("clean membership", "goal", TaskCreateOptions{ShareGlobalAssets: shared}) + if err != nil { + t.Fatal(err) + } + *candidate = *replacement } } - - // Insert rows directly so root_domain/task_ids are controlled exactly. Every - // 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 + if !source.ShareGlobalAssets || task.ShareGlobalAssets { + t.Fatal("incorrect creation defaults") } - rows := []row{ - {"subdomain", "api." + root, root, "", "", "", marker}, // under task root - {"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 + if _, err = d.Exec(`INSERT INTO task_relations(task_id,source_task_id) VALUES ($1,$2)`, task.ID, source.ID); err != nil { + t.Fatal(err) } - var inScopeIDs, outIDs []int64 - for _, r := range rows { + root := fmt.Sprintf("sharing-%d.invalid", time.Now().UnixNano()) + 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 - if err := d.QueryRow(`INSERT INTO assets(type,domain,root_domain,url,method,ip,page_title,task_ids,last_seen) - VALUES ($1,NULLIF($2,''),NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),NULLIF($6,''),NULLIF($7,''),ARRAY[$8]::bigint[],now()) - RETURNING id`, - r.typ, r.domain, r.rootDom, r.url, r.method, r.ip, r.title, foreignTask).Scan(&id); err != nil { - t.Fatalf("insert %s: %v", r.domain, err) - } - if r.rootDom == out { - outIDs = append(outIDs, id) - } else { - inScopeIDs = append(inScopeIDs, id) + err := d.QueryRow(`INSERT INTO assets(type,domain,root_domain,url,ip,company_id,task_ids,last_seen) + 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) + if err != nil { + t.Fatal(err) } + 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() { - _, _ = 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) }) - - got, err := assets.QueryDSLInScope(marker, "", task.ID, 50, 0) + if err := assets.upsertTaskScope(TaskScope{TaskID: task.ID, Kind: "root_domain", Domain: root}); err != nil { + 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 { t.Fatal(err) } - gotIDs := map[int64]bool{} - for _, a := range got { - gotIDs[a.ID] = true - } - for _, id := range inScopeIDs { - if !gotIDs[id] { - t.Fatalf("in-scope asset %d missing from QueryDSLInScope result %v", id, gotIDs) + for _, n := range graph.Nodes { + if n.AssetID == foreign || n.AssetID == connector { + t.Fatalf("foreign graph asset: %+v", n) } } - for _, id := range outIDs { - if gotIDs[id] { - t.Fatalf("out-of-scope asset %d leaked into scoped query", id) - } + cos, err := companies.ListCompaniesForTask(task.ID) + if err != nil || len(cos) != 1 || cos[0].AssetCount != 2 { + t.Fatalf("company isolation: %+v %v", cos, err) } - - // GetByIDsInScope: in-scope id returns, out-of-scope id is dropped. - mixed := append(append([]int64{}, inScopeIDs[0]), outIDs[0]) - byID, err := assets.GetByIDsInScope(task.ID, mixed) + for _, enabled := range []bool{true, false} { + updated, err := d.UpdateTask(task.ID, TaskPatch{ShareGlobalAssets: &enabled}) + if err != nil || updated.ShareGlobalAssets != enabled { + 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 { t.Fatal(err) } - if len(byID) != 1 || byID[0].ID != inScopeIDs[0] { - t.Fatalf("GetByIDsInScope mixed ids → %+v, want only %d", byID, inScopeIDs[0]) - } - - // taskID<=0 (non-task context) falls back to global: the out-of-scope row is reachable. - global, err := assets.QueryDSLInScope(marker, "", 0, 50, 0) + defer d.DeleteTask(empty.ID) + got, err = assets.QueryDSLInScope(query, "", empty.ID, 50, 0) + assertIDs(got, err) + // Explicit company selection imports a snapshot even with global sharing off. + associated, err := d.CreateTaskWithOptions("company task", "goal", TaskCreateOptions{CompanyIDs: []int64{companyID}}) if err != nil { t.Fatal(err) } - var sawOut bool - for _, a := range global { - if a.ID == outIDs[0] { - sawOut = true - } - } - if !sawOut { - t.Fatalf("taskID<=0 should fall back to global and include out-of-scope asset %d", outIDs[0]) - } + defer d.DeleteTask(associated.ID) + got, err = assets.GetByIDsInScope(associated.ID, ids) + assertIDs(got, err, ids...) } diff --git a/db/companies.go b/db/companies.go index f7b122b..99bbf19 100644 --- a/db/companies.go +++ b/db/companies.go @@ -293,6 +293,55 @@ ORDER BY c.name`) 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. func (s *CompanyStore) GetScope(companyID int64) ([]ScopeRule, error) { rows, err := s.db.Query(` diff --git a/db/schema.sql b/db/schema.sql index 37f4041..f94d858 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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)。 -- 存量任务默认 true 保持原行为;company 关联(task_scope kind=company)不受此开关影响。 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)。补旧库。 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 diff --git a/db/task_archives_restore.go b/db/task_archives_restore.go index 56f102b..4ae0b6d 100644 --- a/db/task_archives_restore.go +++ b/db/task_archives_restore.go @@ -122,7 +122,7 @@ AND NOT EXISTS ( if _, err := tx.Exec(`UPDATE tasks SET 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, - 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() WHERE id=$1`, taskID); err != nil { 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_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, - 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) ELSE archived.deadline_at END, deleted_at=NULL,archived_at=NULL,completed_at=archived.completed_at, diff --git a/db/task_archives_test.go b/db/task_archives_test.go index bc4274a..d15fd41 100644 --- a/db/task_archives_test.go +++ b/db/task_archives_test.go @@ -133,7 +133,7 @@ func TestTaskArchiveDatabaseRoundTrip(t *testing.T) { t.Fatal(err) } 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 { 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 { 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) } if err := d.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, task.ExplorationID).Scan(&nodes); err != nil { diff --git a/db/task_asset_sharing_test.go b/db/task_asset_sharing_test.go new file mode 100644 index 0000000..0d223b6 --- /dev/null +++ b/db/task_asset_sharing_test.go @@ -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") + } +} diff --git a/db/task_assets_context.go b/db/task_assets_context.go index aed6076..9f42de0 100644 --- a/db/task_assets_context.go +++ b/db/task_assets_context.go @@ -21,7 +21,25 @@ context_tasks AS ( 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 ( SELECT DISTINCT a.id, a.type, 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 + 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 SELECT a.id, a.type, 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 )` -// scopeTargetCTE selects every asset that BELONGS to the current task's (and its -// direct source tasks') declared scope — membership, not literal value: a -// root_domain scope pulls in every subdomain / service / endpoint whose own -// root_domain column equals it; an ip/cidr scope pulls in assets whose ip OR -// 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 -)` +// scopeTargetCTE applies the live visibility policy to DSL and ID reads. +// An isolated task sees only its own and explicitly related tasks' assets. +const scopeTargetCTE = directTaskContextCTE + taskAssetVisibilityCTE + `, +target AS (SELECT id FROM visible_assets) +` // ListTaskScopeWithSources returns the current task's scope followed by the // 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 // narrower, task-owned behavior. 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 ( SELECT DISTINCT a.id FROM assets a @@ -223,6 +215,7 @@ context_assets AS ( )) ) 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,'') FROM assets a JOIN context_assets ctx ON ctx.id=a.id`, taskID) diff --git a/db/task_scope.go b/db/task_scope.go index ccb5208..237d5e2 100644 --- a/db/task_scope.go +++ b/db/task_scope.go @@ -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. -const covTargetCTE = ` +const covTargetCTE = directTaskContextCTE + taskAssetVisibilityCTE + `, target AS ( SELECT DISTINCT a.id, a.type, 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 )) ) + WHERE a.id IN (SELECT id FROM visible_assets) ), tested AS ( SELECT DISTINCT ea.asset_id @@ -554,8 +555,8 @@ ORDER BY a.id`, taskID) } for root := range missingRoots { var id, companyID int64 - err := s.db.QueryRow(`SELECT id, COALESCE(company_id,0) FROM assets -WHERE type='root_domain' AND domain=$1 LIMIT 1`, root).Scan(&id, &companyID) + err := s.db.QueryRow(`WITH `+scopeTargetCTE+` SELECT id, COALESCE(company_id,0) FROM assets +WHERE type='root_domain' AND domain=$2 AND id IN (SELECT id FROM target) LIMIT 1`, taskID, root).Scan(&id, &companyID) var node CoverageGraphNode if err == nil && id > 0 { node = CoverageGraphNode{Key: assetKey(id), Kind: "root_domain", AssetID: id, diff --git a/db/tasks.go b/db/tasks.go index 4f0007f..91afddd 100644 --- a/db/tasks.go +++ b/db/tasks.go @@ -55,7 +55,8 @@ type Task struct { // 覆盖度、不自动累积 task_scope(source=auto)、不给 agent 开放 add_task_scope/ // list_untested_assets、态势里不注入 coverage 块(scope 字段仍保留)。company 关联 // (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 // global settings.workers default when the engine starts this task. WorkerCount int `json:"worker_count"` @@ -187,6 +188,8 @@ type TaskCreateOptions struct { // CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建 // 路径(编排 spawn、老 API)沿用原行为。仅 web 创建任务时可显式传 false 关闭。 CoverageEnabled *bool + // ShareGlobalAssets allows reading unrelated global assets. The default is false. + ShareGlobalAssets bool // WorkerCount 是本任务并发运行的工作 Agent 数;0/省略=用全局默认。上限 50。 WorkerCount int // Remark 是创建时可带的任务备注(最多 2000 字);空=无备注。 @@ -268,12 +271,12 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload)); SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...), CompanyIDs: append([]int64(nil), opts.CompanyIDs...), 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(` -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) -VALUES ($1,$2,$3,$4,$5,$6,$7,$7,$8,$9,$10,$11) -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 { +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,$12) +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 } 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, 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) { 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 } 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 } -// TaskPatch updates task list metadata without changing execution state. +// TaskPatch updates editable metadata and global asset visibility. type TaskPatch struct { - Name *string - Remark *string - Pinned *bool + Name *string + Remark *string + 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. func (d *DB) UpdateTask(id int64, patch TaskPatch) (*Task, error) { 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 THEN COALESCE(pinned_at, now()) ELSE NULL - END + END, + share_global_assets = COALESCE($7::boolean, share_global_assets) 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 { return nil, nil } diff --git a/server/dto.go b/server/dto.go index 9eead79..a3f7b8b 100644 --- a/server/dto.go +++ b/server/dto.go @@ -69,7 +69,8 @@ type TaskDTO struct { ArchiveBlockedBy string `json:"archive_blocked_by_task_id,omitempty"` CompanyIDs []int64 `json:"company_ids"` 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 { @@ -163,6 +164,7 @@ func taskDTO(t *Task, status string) TaskDTO { SourceTaskIDs: sourceIDs, CompanyIDs: lifecycle.CompanyIDs, CoverageEnabled: t.CoverageEnabled, + ShareGlobalAssets: lifecycle.ShareGlobalAssets, WorkerCount: t.WorkerCount, } } diff --git a/server/manager.go b/server/manager.go index 9a3acb6..3374ed9 100644 --- a/server/manager.go +++ b/server/manager.go @@ -68,7 +68,8 @@ type Task struct { TimeoutSeconds int `json:"timeout_seconds"` PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒) 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"` DeadlineAt int64 `json:"deadline_at,omitempty"` ActiveRunSeconds int64 `json:"active_run_seconds"` @@ -91,6 +92,7 @@ type Task struct { // updateLifecycle instead of reading or writing the corresponding Task fields // directly after the task has been published by Manager. type taskLifecycleState struct { + ShareGlobalAssets bool Name string Remark string PinnedAt int64 @@ -128,13 +130,14 @@ func (t *Task) lifecycleSnapshot() taskLifecycleState { func (t *Task) lifecycleSnapshotLocked() taskLifecycleState { return taskLifecycleState{ - Name: t.Name, - Remark: t.Remark, - PinnedAt: t.PinnedAt, - Status: t.Status, - Paused: t.Paused, - PauseReason: t.PauseReason, - ReconnectAttempt: t.ReconnectAttempt, ReconnectMax: t.ReconnectMax, ReconnectNextAt: t.ReconnectNextAt, ReconnectExhausted: t.ReconnectExhausted, ReconnectLastError: t.ReconnectLastError, ReconnectGeneration: t.ReconnectGeneration, + ShareGlobalAssets: t.ShareGlobalAssets, + Name: t.Name, + Remark: t.Remark, + PinnedAt: t.PinnedAt, + Status: t.Status, + Paused: t.Paused, + PauseReason: t.PauseReason, + ReconnectAttempt: t.ReconnectAttempt, ReconnectMax: t.ReconnectMax, ReconnectNextAt: t.ReconnectNextAt, ReconnectExhausted: t.ReconnectExhausted, ReconnectLastError: t.ReconnectLastError, ReconnectGeneration: t.ReconnectGeneration, Queued: t.Queued, QueuedAt: t.QueuedAt, QueueMode: t.QueueMode, @@ -157,6 +160,7 @@ func (t *Task) updateLifecycle(update func(*taskLifecycleState)) { t.lifecycleMu.Lock() state := t.lifecycleSnapshotLocked() update(&state) + t.ShareGlobalAssets = state.ShareGlobalAssets t.Name = state.Name t.Remark = state.Remark 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...), CompanyIDs: append([]int64(nil), pt.CompanyIDs...), 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), ActiveRunSeconds: pt.ActiveRunSeconds, RunSegmentStartedAt: unixOrZero(pt.RunSegmentStartedAt), 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. func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task, error) { m.taskStateMu.Lock() @@ -1180,6 +1184,7 @@ func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task return nil, nil } task.updateLifecycle(func(state *taskLifecycleState) { + state.ShareGlobalAssets = updated.ShareGlobalAssets state.Name = updated.Name state.Remark = updated.Remark state.PinnedAt = unixOrZero(updated.PinnedAt) diff --git a/server/server.go b/server/server.go index 114f0e9..7a81131 100644 --- a/server/server.go +++ b/server/server.go @@ -1710,9 +1710,10 @@ type createTaskReq struct { TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时 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 轮)。 - CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。 - Remark string `json:"remark,omitempty"` // 任务备注(最多2000字);空=无 - WorkerCount int `json:"worker_count,omitempty"` // 本任务 Worker 数;0/省略=用全局默认;上限50 + ShareGlobalAssets bool `json:"share_global_assets"` + CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。 + Remark string `json:"remark,omitempty"` // 任务备注(最多2000字);空=无 + WorkerCount int `json:"worker_count,omitempty"` // 本任务 Worker 数;0/省略=用全局默认;上限50 // InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。 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, SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs, TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds, - CoverageEnabled: req.CoverageEnabled, - Remark: remark, - WorkerCount: req.WorkerCount, - InterceptRules: interceptRules, + CoverageEnabled: req.CoverageEnabled, + ShareGlobalAssets: req.ShareGlobalAssets, + Remark: remark, + WorkerCount: req.WorkerCount, + InterceptRules: interceptRules, }) if err != nil { if errors.Is(err, db.ErrTaskCategoryInvalid) || errors.Is(err, db.ErrTaskCategoryNotFound) { diff --git a/server/task_metadata.go b/server/task_metadata.go index 89bc370..8d44caa 100644 --- a/server/task_metadata.go +++ b/server/task_metadata.go @@ -15,8 +15,7 @@ const ( maxTaskMetadataRequestSize = 16 << 10 ) -// updateTaskMetadata changes list-only task metadata. It intentionally does not -// touch lifecycle state, scheduling, or the task's immutable description/goal. +// updateTaskMetadata updates editable metadata and the live asset-sharing policy. func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) { taskID := r.PathValue("id") 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) var request struct { - Name *string `json:"name"` - Remark *string `json:"remark"` - Pinned *bool `json:"pinned"` + Name *string `json:"name"` + Remark *string `json:"remark"` + Pinned *bool `json:"pinned"` + ShareGlobalAssets *bool `json:"share_global_assets"` } if err := decode(r, &request); err != nil { var tooLarge *http.MaxBytesError @@ -38,8 +38,8 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) { } return } - if request.Name == nil && request.Pinned == nil && request.Remark == nil { - writeErr(w, http.StatusBadRequest, "至少需要提供 name、pinned 或 remark") + if request.Name == nil && request.Pinned == nil && request.Remark == nil && request.ShareGlobalAssets == nil { + writeErr(w, http.StatusBadRequest, "至少需要提供 name、pinned、remark 或 share_global_assets") return } if request.Name != nil { @@ -63,7 +63,7 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) { } 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 { writeErr(w, http.StatusInternalServerError, err.Error()) return diff --git a/server/task_metadata_test.go b/server/task_metadata_test.go index 2ef209a..baab52e 100644 --- a/server/task_metadata_test.go +++ b/server/task_metadata_test.go @@ -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) { m, err := NewManager(t.TempDir(), "") if err != nil { diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 152eb6e..a197003 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -24,6 +24,8 @@ import { ChevronRightIcon, EyeIcon, FolderInputIcon, + GlobeIcon, + GlobeOffIcon, GripVerticalIcon, Loader2Icon, PaperclipIcon, @@ -795,6 +797,30 @@ export default function TasksPage() { [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( async (task: Task) => { try { @@ -1284,6 +1310,7 @@ export default function TasksPage() { onRename={renameTask} onRemark={updateTaskRemark} onTogglePinned={toggleTaskPinned} + onToggleGlobalAssets={toggleTaskGlobalAssets} onArchive={queueTaskArchive} selected={selectedIds.has(task.id)} onSelectedChange={toggleSelected} @@ -1547,6 +1574,7 @@ const TaskRow = React.memo(function TaskRow({ onRename, onRemark, onTogglePinned, + onToggleGlobalAssets, onArchive, selected, onSelectedChange, @@ -1559,6 +1587,7 @@ const TaskRow = React.memo(function TaskRow({ onRename: (task: Task, name: string) => Promise; onRemark: (task: Task, remark: string) => Promise; onTogglePinned: (task: Task) => Promise; + onToggleGlobalAssets: (task: Task) => Promise; onArchive: (task: Task) => Promise; selected: boolean; onSelectedChange: (id: string, checked: boolean) => void; @@ -1756,19 +1785,24 @@ const TaskRow = React.memo(function TaskRow({
- + + + + + @@ -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 ( + + + {disabled ? ( + + {children} + + ) : ( + children + )} + + +

{label}

+

{description}

+
+
+ ); +} + function TaskNameEditor({ task, onRename, @@ -1865,20 +1933,24 @@ function TaskNameEditor({ 未命名 )} - + +
); } @@ -1900,24 +1972,93 @@ function TaskPinAction({ const pinned = taskIsPinned(task); return ( - + + + ); +} + +function taskGlobalAssetsIcon(saving: boolean, enabled: boolean) { + if (saving) return ; + if (enabled) return ; + return ; +} + +function TaskGlobalAssetsAction({ + task, + onToggle, +}: { + task: Task; + onToggle: (task: Task) => Promise; +}) { + const [saving, setSaving] = React.useState(false); + const enabled = task.share_global_assets === true; + const label = `共享全局资产:${enabled ? "已开启,点击关闭" : "未开启,点击开启"}`; + return ( + + + + + +

+ 共享全局资产 · {enabled ? "已开启" : "已关闭"} +

+

+ 开启后可查询全局资产库;关闭后仅使用本任务已关联的资产及直接关联任务的资产。 +

+

+ {enabled ? "点击关闭共享" : "点击开启共享"} +

+
+
); } @@ -1940,25 +2081,34 @@ function TaskControlButton({ const [pending, setPending] = React.useState(false); const action = taskControlAction(task.status); const label = action === "resume" ? "继续任务" : "暂停任务"; + let description = "暂停当前任务的执行,之后可继续。"; + if (action === "resume") + description = "继续已暂停的任务;等待中的任务将重新进入调度。"; + if (!action) description = "当前任务状态不支持暂停或继续。"; return ( - + + ); } @@ -2040,20 +2190,27 @@ function TaskArchiveAction({ ); if (reason) { return ( - - - {trigger} - - {reason} - + + {trigger} + ); } return ( - onArchive(task)} - /> + + {trigger}} + onConfirm={() => onArchive(task)} + /> + +

归档任务

+

将任务及相关记录压缩保存,之后可从“已归档”中还原。点击后需确认。

+
+
); } @@ -2545,42 +2702,51 @@ function TaskArchivesPanel({ onChanged }: { onChanged: () => void }) {
{ARCHIVE_FAILED_STATES.has(archive.state) && ( - + + + )} {canRestore && ( - + + + )} {canDelete && ( - deleteMany([archive])} - trigger={ - - } - /> + + deleteMany([archive])} + trigger={ + + + + } + /> + +

永久删除归档

+

删除此归档及其保存的数据,删除后无法还原。点击后需确认。

+
+
)}
@@ -2795,11 +2961,13 @@ function DeleteTaskDialog({ return ( - - - + + + + + 确认删除任务 #{task.id}? @@ -3415,19 +3583,24 @@ function DraggableCategoryTask({ {moving ? ( ) : ( - + + )} @@ -3877,6 +4050,7 @@ function CreateTaskSheet({ const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时 const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致) const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行 + const [shareGlobalAssets, setShareGlobalAssets] = React.useState(false); const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响) const [workerCount, setWorkerCount] = React.useState(""); // 本任务 Worker 数;空/0=用全局默认 const [interceptRules, setInterceptRules] = React.useState< @@ -3972,6 +4146,7 @@ function CreateTaskSheet({ seedFirstIntent, planHeartbeatSeconds: heartbeatSec, coverageEnabled, + shareGlobalAssets, workerCount: workerCountNum, schedule: scheduleEnabled ? { ...schedule, task_ids: [] } : undefined, interceptRules: interceptRules @@ -3992,6 +4167,7 @@ function CreateTaskSheet({ setHeartbeatMin("10"); setSeedFirstIntent(false); setCoverageEnabled(true); + setShareGlobalAssets(false); setWorkerCount(""); setInterceptRules([]); setScheduleEnabled(false); @@ -4286,6 +4462,19 @@ function CreateTaskSheet({ onChange={(e) => setGoal(e.target.value)} /> + +
+ setShareGlobalAssets(checked === true)} + /> + +
+ + 默认关闭,仅使用本任务及明确关联任务的资产。开启后可查询全局资产库,测试仍须遵守授权范围。 + +
关联任务 {emptyLabel} )} - + + + + + + 编辑{fieldName}:输入完成后按 Enter 或点击其他位置保存,按 Esc 取消。 + + ); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2282fb2..b009a7f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -346,6 +346,7 @@ export const api = { timeoutSeconds?: number; seedFirstIntent?: boolean; planHeartbeatSeconds?: number; + shareGlobalAssets?: boolean; coverageEnabled?: boolean; workerCount?: number; interceptRules?: AssetInterceptRuleInput[]; @@ -363,6 +364,7 @@ export const api = { timeout_seconds: input.timeoutSeconds ?? 0, seed_first_intent: input.seedFirstIntent ?? false, plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min) + share_global_assets: input.shareGlobalAssets ?? false, coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能 worker_count: input.workerCount ?? 0, // 0 = 用全局默认 workers intercept_rules: input.interceptRules ?? [], // 任务级资产拦截规则 @@ -380,7 +382,7 @@ export const api = { runScheduleNow: (id: number) => post<{ ok: boolean; started: number; errors?: string[] }>(`/schedules/${id}/run-now`, {}), 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(`/tasks/${id}`, input), renameTask: (id: string, name: string) => patch(`/tasks/${id}`, { name }), pinTask: (id: string, pinned: boolean) => patch(`/tasks/${id}`, { pinned }), diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index f23b147..b1575e0 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -253,6 +253,7 @@ function mockTaskArchiveBlocker(taskID: string): string | undefined { function publicMockTask(task: Task): Task { const item = structuredClone(task); + item.share_global_assets = task.share_global_assets ?? false; const blocker = mockTaskArchiveBlocker(task.id); if (blocker) item.archive_blocked_by_task_id = blocker; 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", source_task_ids: sourceTaskIDs, company_ids: companyIDs, + share_global_assets: b.share_global_assets === true, }; const numericTaskID = nextMockTaskAssetID++; 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("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("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 remarkPresent = has("remark") && b.remark !== 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 nextRemark = task.remark; let nextPinned = task.pinned; @@ -1480,6 +1484,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re nextPinned = pinned; 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.remark = nextRemark; task.pinned = nextPinned; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 5fd92ef..89167a3 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -62,6 +62,7 @@ export interface Task { 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 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=不计算/不展示覆盖度 }