From eb3f27871166dcd35e3ac92f304e7f47fd9418e2 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:03:18 -0400 Subject: [PATCH] =?UTF-8?q?feat(task):=20=E8=B5=84=E4=BA=A7=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=BA=A6=E5=8A=9F=E8=83=BD=E5=8F=AF=E6=8C=89=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 创建任务时新增「资产覆盖度功能」开关(默认开)。关闭后: - 不计算/展示测试覆盖度,总览卡片隐藏、覆盖度 API 返回 enabled:false - 态势图仅展示资产不显示测试进度(B1:抹平 tested、去已测统计与图例) - 不再自动累积 task_scope(source=auto) - planner/mainagent 的 add_task_scope、list_untested_assets 从工具列表剔除 - graph_overview 丢弃 coverage 度量块、保留 scope 字段(防上下文污染) company 关联(task_scope kind=company)与此开关无关,永不受影响: insertTaskCompanies 不 gate,TaskCompanyIDs 照常读回,scope 字段照常喂给 agent。 DB:tasks 加 coverage_enabled 列(schema.sql + ALTER ADD COLUMN IF NOT EXISTS 补旧库,默认 true 保持存量任务原行为)。 Claude-Session: https://claude.ai/code/session_01Jm87Gr5fq5aso6SfLyguhx --- agent/mainagent.go | 4 +- agent/planner.go | 4 +- agent/tools.go | 72 +++++++++++++++---- agent/tools_insert.go | 11 +-- agent/worker.go | 9 ++- db/schema.sql | 5 ++ db/task_scope.go | 15 ++++ db/tasks.go | 20 ++++-- server/dto.go | 2 + server/manager.go | 4 +- server/server.go | 8 +++ .../tasks/detail/_tabs/coverage-graph-tab.tsx | 41 +++++++---- .../tasks/detail/_tabs/overview-tab.tsx | 3 +- .../app/(main)/function/tasks/detail/page.tsx | 2 +- web/src/app/(main)/function/tasks/page.tsx | 17 +++++ web/src/lib/api.ts | 3 + web/src/lib/mock/data.ts | 1 + web/src/lib/types.ts | 1 + 18 files changed, 179 insertions(+), 43 deletions(-) diff --git a/agent/mainagent.go b/agent/mainagent.go index b65d8c0..6d4aaca 100644 --- a/agent/mainagent.go +++ b/agent/mainagent.go @@ -80,11 +80,13 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t tsx.SetAssetStore(as, as.Companies()) } tsx.SetTaskID(taskID) + tsx.SetCoverageEnabled(as == nil || as.CoverageEnabled(taskID)) tsx.SetNotify(notify) // add_hint wakes this task's planner (debounced) tsx.SetResumeTask(resume) // set_goals 新增目标 → 把已完成/暂停的任务拉回 running tsx.SetNotifyGoal(notifyGoal) // set_goals 新增目标 → 给 planner 记一条「人新增了目标:…」触发 // 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash) - base := append(tsx.MainAgentTools(), actool.DefaultTools()...) + // 资产覆盖度功能关闭时剔除 add_task_scope/list_untested_assets(不入 prompt)。 + base := append(tsx.DropCoverageTools(tsx.MainAgentTools()), actool.DefaultTools()...) ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts)}) tools, def, cleanup := AugmentTools(ctx, "mainagent", base) defer cleanup() diff --git a/agent/planner.go b/agent/planner.go index d460e6c..1d09501 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -266,13 +266,15 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts tsx.SetAssetStore(as, as.Companies()) } tsx.SetTaskID(taskID) + tsx.SetCoverageEnabled(as == nil || as.CoverageEnabled(taskID)) tsx.killWork = p.killWork // enable kill_work tool (nil = unavailable) tsx.steerWork = p.steerWork // enable steer_work tool (nil = unavailable) if origin, _ := ts.OriginFactID(); origin > 0 { tsx.SetOwnerNode(origin) // planner-side anchors default to the task root (origin fact) } // 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash) - base := append(tsx.PlannerTools(), actool.DefaultTools()...) + // 资产覆盖度功能关闭时剔除 add_task_scope/list_untested_assets(不入 prompt)。 + base := append(tsx.DropCoverageTools(tsx.PlannerTools()), actool.DefaultTools()...) ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts)}) tools, def, cleanup := AugmentTools(ctx, "planner", base) defer cleanup() diff --git a/agent/tools.go b/agent/tools.go index 05b1dcc..e16b4da 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -56,6 +56,12 @@ type ToolSet struct { ts *db.ExplorationStore worker string taskID int64 // PG tasks.id; 0 when unknown (tests / orchestrator cross-task reads) + // coverageDisabled mirrors tasks.coverage_enabled=false. Stored inverted so the + // zero value (all existing ToolSet constructions) means ENABLED — matching the + // DB default (true). When true: graphOverviewData drops the coverage block, the + // auto-scope hook (insertAssets) is skipped, and add_task_scope/list_untested_assets + // are filtered out of the agent's tool list. The scope field stays regardless. + coverageDisabled bool // ownerNode is the exploration node that writes attach to: assets this run // touches get anchored to it as lineage/provenance (NOT visibility — the asset // graph is global and shared). Worker = its claimed intent; planner = begin root. @@ -150,6 +156,36 @@ func NewToolSet(ts *db.ExplorationStore, worker string) *ToolSet { // dual-write to the standalone findings table (which survives task deletion). func (t *ToolSet) SetTaskID(id int64) { t.taskID = id } +// SetCoverageEnabled records whether this task has the asset-coverage feature on +// (default enabled). Passing false makes graphOverviewData omit the coverage block, +// the insertAssets auto-scope hook a no-op, and CoverageTools reports the two +// coverage-only tools so callers can drop them from the agent's tool list. +func (t *ToolSet) SetCoverageEnabled(enabled bool) { t.coverageDisabled = !enabled } + +// CoverageDisabled reports whether the coverage feature is off for this task. +func (t *ToolSet) CoverageDisabled() bool { return t.coverageDisabled } + +// coverageOnlyTools are the LLM tools that only make sense when asset coverage is +// on. When the feature is off they are filtered out of the agent's tool list so +// they neither pollute the prompt nor let the model build a disabled denominator. +var coverageOnlyTools = map[string]bool{"add_task_scope": true, "list_untested_assets": true} + +// DropCoverageTools returns tools with the coverage-only ones removed when this +// task has the feature disabled; otherwise it returns tools unchanged. +func (t *ToolSet) DropCoverageTools(tools []actool.CoreTool) []actool.CoreTool { + if !t.coverageDisabled { + return tools + } + out := tools[:0:0] + for _, tool := range tools { + if coverageOnlyTools[tool.Name()] { + continue + } + out = append(out, tool) + } + return out +} + // Cross-task reuse: exported accessors returning the per-task tool logic bound to // THIS ToolSet's store. Host-side orchestration tools build a ToolSet for an // arbitrary task, then Call these — so cross-task reads/hint reuse the exact @@ -362,22 +398,28 @@ func (t *ToolSet) graphOverviewData() map[string]any { out["related_tasks"] = t.relatedTaskOverviews() // coverage:粗略的资产测试覆盖度参考——范围(task_scope)内的资产里,被 fact 碰过的 // 占比 + by_type(按类型的 总数/已测)。要看未测的具体资产由 agent 按需调 list_untested_assets 自行判断。仅任务上下文有。 + // 资产覆盖度功能关闭时(coverageDisabled):只保留 scope/hosts(范围边界与目标主机的 + // 感知信息,company 关联经由 scope 在此浮现),丢弃 denominator/tested/pct/by_type/note + // 等覆盖度度量,避免污染上下文、也不诱导已隐藏的 add_task_scope/list_untested_assets。 if t.as != nil && t.ts != nil && t.taskID > 0 { - if cov, err := t.as.TaskCoverageWithSources(t.taskID); err == nil { - m := map[string]any{ - "denominator": cov.Denominator, - "tested": cov.Tested, - "by_type": cov.ByType, - "note": "coverage资产测试覆盖度(包括接口等各种相关资产),粗略估计、仅供参考:包含当前任务与直接关联任务的 scope、事实锚点;关联 scope 只读。容器型资产/大量枚举会让它偏低,勿据此认为已测完;可用 add_task_scope 增补本任务范围、list_untested_assets 看未测资产【通常不调用list_untested_assets,按照任务推进即可】;", - } - if cov.Denominator == 0 { - m["pct"] = nil - m["status"] = "范围未锚定" - } else { - m["pct"] = cov.Pct + { + m := map[string]any{} + if !t.coverageDisabled { + if cov, err := t.as.TaskCoverageWithSources(t.taskID); err == nil { + m["denominator"] = cov.Denominator + m["tested"] = cov.Tested + m["by_type"] = cov.ByType + m["note"] = "coverage资产测试覆盖度(包括接口等各种相关资产),粗略估计、仅供参考:包含当前任务与直接关联任务的 scope、事实锚点;关联 scope 只读。容器型资产/大量枚举会让它偏低,勿据此认为已测完;可用 add_task_scope 增补本任务范围、list_untested_assets 看未测资产【通常不调用list_untested_assets,按照任务推进即可】;" + if cov.Denominator == 0 { + m["pct"] = nil + m["status"] = "范围未锚定" + } else { + m["pct"] = cov.Pct + } + } } // scope:当前测试范围的根资产(task_scope 原始行),让 agent 知道这个任务到底 - // 圈定了哪些目标(不是全部测试资产,而是范围边界本身)。 + // 圈定了哪些目标(不是全部测试资产,而是范围边界本身)。覆盖度开关无关,始终提供。 if rows, err := t.as.ListTaskScopeWithSources(t.taskID); err == nil && len(rows) > 0 { scope := make([]map[string]any, 0, len(rows)) for _, r := range rows { @@ -445,7 +487,9 @@ func (t *ToolSet) graphOverviewData() map[string]any { m["hosts_truncated"] = true } } - out["coverage"] = m + if len(m) > 0 { + out["coverage"] = m + } } } return out diff --git a/agent/tools_insert.go b/agent/tools_insert.go index 6a318de..dff7878 100644 --- a/agent/tools_insert.go +++ b/agent/tools_insert.go @@ -274,11 +274,14 @@ func (t *ToolSet) insertAssets() actool.CoreTool { t.anchorOwner(id) // 自动入测试范围(source='auto'):只对 worker 顶层显式插入的这一项,按其 // 类型加保守范围;side-effect 派生的资产不经此处,故范围不盲目扩大。taskID=0 时无操作。 - svcIP := item.ServiceIP - if svcIP == "" { - svcIP = item.IP + // 资产覆盖度功能关闭时不再累积测试范围(分母)。 + if !t.coverageDisabled { + svcIP := item.ServiceIP + if svcIP == "" { + svcIP = item.IP + } + _ = t.as.AddAutoScope(taskID, typ, item.Domain, item.URL, svcIP) } - _ = t.as.AddAutoScope(taskID, typ, item.Domain, item.URL, svcIP) } return jsonResult(map[string]any{ diff --git a/agent/worker.go b/agent/worker.go index 258b05b..3d00371 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -254,6 +254,8 @@ func renderWorkerGraphOverview(data map[string]any) string { func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, intent *db.Node, hooks harness.HookRunner, emit func(db.Activity), enr EnrichTrigger, notifyFinding func(int64, string)) (harness.TerminalReason, WriteCounts, error) { tsx := NewToolSet(ts, name) tsx.SetTaskID(taskID) + coverageEnabled := as == nil || as.CoverageEnabled(taskID) + tsx.SetCoverageEnabled(coverageEnabled) if as != nil { tsx.SetAssetStore(as, as.Companies()) } @@ -351,8 +353,11 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db. // 意图明确针对的这些资产 → 自动纳入任务测试范围(与 insertAssets 同一套 // 保守粒度)。upsertTaskScope 的 ON CONFLICT DO NOTHING + uq_task_scope // 唯一索引保证不会重复添加;重跑/重试同样是幂等 no-op。 - for _, a := range assets { - _ = as.AddAutoScope(taskID, a.Type, a.Domain, a.URL, a.IP) + // 资产覆盖度功能关闭时不再累积测试范围(分母)。 + if coverageEnabled { + for _, a := range assets { + _ = as.AddAutoScope(taskID, a.Type, a.Domain, a.URL, a.IP) + } } } } diff --git a/db/schema.sql b/db/schema.sql index a076156..968bd74 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -341,6 +341,7 @@ CREATE TABLE IF NOT EXISTS tasks ( parent_ref TEXT, timeout_seconds INTEGER NOT NULL DEFAULT 0, plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300, + coverage_enabled BOOLEAN NOT NULL DEFAULT true, first_run_at TIMESTAMPTZ, deadline_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ, @@ -357,6 +358,10 @@ CREATE TRIGGER trg_tasks_upd BEFORE UPDATE ON tasks ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300; -- 并发上限挂起态;补旧库。true=因并发上限排队、等待空位自动启动。 ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false; +-- 资产覆盖度功能开关;补旧库。true(默认)=计算/展示测试覆盖度、自动累积测试范围、 +-- 给 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; -- queued_at makes admission FIFO reflect the actual enqueue order rather than the -- task creation order. queue_mode distinguishes first bootstrap from resuming an -- exploration that already owns goals/history. diff --git a/db/task_scope.go b/db/task_scope.go index d3cf02e..40e3a28 100644 --- a/db/task_scope.go +++ b/db/task_scope.go @@ -248,6 +248,7 @@ type CoverageByType struct { // NOT a precise metric. Denominator = assets matching any active task_scope row; // Tested = those anchored to at least one fact node in the exploration. type Coverage struct { + Enabled bool `json:"enabled"` // 资产覆盖度功能是否开启;false 时其余字段为零值 ScopeRows int `json:"scope_rows"` // 0 → 范围未锚定 Denominator int `json:"denominator"` // 范围内资产数 Tested int `json:"tested"` // 已测(约) @@ -255,6 +256,20 @@ type Coverage struct { ByType []CoverageByType `json:"by_type"` // 按资产类型的 总数/已测 } +// CoverageEnabled reports whether a task has the asset-coverage feature turned on +// (tasks.coverage_enabled). Missing row / error → true (fail open to the default), +// so unknown/legacy tasks keep the historical behavior. taskID<=0 → true. +func (s *AssetStore) CoverageEnabled(taskID int64) bool { + if taskID <= 0 { + return true + } + var enabled bool + if err := s.db.QueryRow(`SELECT COALESCE(coverage_enabled,true) FROM tasks WHERE id=$1`, taskID).Scan(&enabled); err != nil { + return true + } + return enabled +} + // task_scope→assets match predicate, reused by the count / by-type / untested queries. $1=taskID. const covTargetCTE = ` target AS ( diff --git a/db/tasks.go b/db/tasks.go index 3af6275..cddadd3 100644 --- a/db/tasks.go +++ b/db/tasks.go @@ -40,6 +40,11 @@ type Task struct { // planner 心跳触发间隔(秒):距上轮 plan 结束/任务开始满该值且期间无触发 → 触发一轮。 // 下限=默认=300(5min),低于一律抬到 300(在 CreateTask 归一)。见 docs/planner-trigger-impl-plan.md PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` + // CoverageEnabled 是「资产覆盖度功能」总开关(默认 true)。false 时:不计算/不展示测试 + // 覆盖度、不自动累积 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"` } // TaskDeleteResult reports optional related-data cleanup performed in the same @@ -137,6 +142,9 @@ type TaskCreateOptions struct { LLMProfileIDs []int64 TimeoutSeconds int PlanHeartbeatSeconds int + // CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建 + // 路径(编排 spawn、老 API)沿用原行为。仅 web 创建任务时可显式传 false 关闭。 + CoverageEnabled *bool } // CreateTaskWithOptions creates an exploration, task, direct source relations, @@ -178,6 +186,7 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload)); opts.TimeoutSeconds = 0 } opts.PlanHeartbeatSeconds = normalizeHeartbeat(opts.PlanHeartbeatSeconds) + coverageEnabled := opts.CoverageEnabled == nil || *opts.CoverageEnabled var active *int64 if len(opts.LLMProfileIDs) > 0 { id := opts.LLMProfileIDs[0] @@ -190,11 +199,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, } if err := tx.QueryRow(` -INSERT INTO tasks(description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds) -VALUES ($1,$2,$3,$4,$4,$5,$6) -RETURNING id, status, paused, created_at`, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil { +INSERT INTO tasks(description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled) +VALUES ($1,$2,$3,$4,$4,$5,$6,$7) +RETURNING id, status, paused, created_at`, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil { return nil, err } if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil { @@ -274,11 +284,11 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error { return nil } -const taskCols = `id, description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), first_run_at, deadline_at` +const taskCols = `id, description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at` func scanTask(sc interface{ Scan(...any) error }) (*Task, error) { var t Task - if err := sc.Scan(&t.ID, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.FirstRunAt, &t.DeadlineAt); err != nil { + if err := sc.Scan(&t.ID, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil { return nil, err } return &t, nil diff --git a/server/dto.go b/server/dto.go index 87b8ba1..f818a9c 100644 --- a/server/dto.go +++ b/server/dto.go @@ -52,6 +52,7 @@ type TaskDTO struct { LLMFailoverReason string `json:"llm_failover_reason,omitempty"` SourceTaskIDs []string `json:"source_task_ids"` CompanyIDs []int64 `json:"company_ids"` + CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定) } // TokenTotalDTO is a whole-task (all agents) token aggregate. @@ -98,6 +99,7 @@ func taskDTO(t *Task, status string) TaskDTO { LLMFailoverReason: llmState.FailoverReason, SourceTaskIDs: sourceIDs, CompanyIDs: lifecycle.CompanyIDs, + CoverageEnabled: t.CoverageEnabled, } } diff --git a/server/manager.go b/server/manager.go index f606652..4bac104 100644 --- a/server/manager.go +++ b/server/manager.go @@ -53,6 +53,7 @@ type Task struct { // 任务级超时(见 docs/任务级超时与收尾设计.md)。DeadlineAt/FirstRunAt 为 unix 秒,0=未设/未运行。 TimeoutSeconds int `json:"timeout_seconds"` PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒) + CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定,默认开) FirstRunAt int64 `json:"first_run_at,omitempty"` DeadlineAt int64 `json:"deadline_at,omitempty"` Store *pgdb.ExplorationStore `json:"-"` @@ -696,7 +697,8 @@ 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, - FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt), + CoverageEnabled: pt.CoverageEnabled, + FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt), Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1), } } diff --git a/server/server.go b/server/server.go index 272379d..a1ca28b 100644 --- a/server/server.go +++ b/server/server.go @@ -1307,6 +1307,7 @@ 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 关联不受影响。 } func (s *Server) createTask(w http.ResponseWriter, r *http.Request) { @@ -1356,6 +1357,7 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) { t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{ SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs, TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds, + CoverageEnabled: req.CoverageEnabled, }) if err != nil { if errors.Is(err, db.ErrTaskCompanyIDsInvalid) || errors.Is(err, db.ErrTaskCompanyNotFound) { @@ -1587,12 +1589,18 @@ func (s *Server) taskCoverage(w http.ResponseWriter, r *http.Request) { writeErr(w, 503, "asset store 未启用") return } + // 资产覆盖度功能关闭 → 短路返回 {enabled:false},前端据此隐藏覆盖度卡片/进度。 + if !t.CoverageEnabled { + writeJSON(w, 200, &db.Coverage{Enabled: false, ByType: []db.CoverageByType{}}) + return + } taskID, _ := strconv.ParseInt(t.ID, 10, 64) cov, err := as.TaskCoverageWithSources(taskID) if err != nil { writeErr(w, 500, err.Error()) return } + cov.Enabled = true writeJSON(w, 200, cov) } diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx index e5cfdda..d16d2e5 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx @@ -456,7 +456,7 @@ function FoldSheet({ } // --------------------------------------------------------------------------- -function GraphInner({ taskId }: { taskId: string }) { +function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; coverageEnabled?: boolean }) { const [data, setData] = React.useState<{ nodes: CoverageGraphNode[]; edges: CoverageGraphEdge[]; @@ -479,12 +479,17 @@ function GraphInner({ taskId }: { taskId: string }) { setLoading(true); api .taskCoverageGraph(taskId) - .then((g) => setData({ nodes: g.nodes ?? [], edges: g.edges ?? [] })) + .then((g) => { + // 资产覆盖度功能关闭(B1):仍出图(范围内资产/company 关联依旧可见),但抹平 + // tested 状态——不显示测试进度、不做已测高亮。 + const nodes = coverageEnabled ? (g.nodes ?? []) : (g.nodes ?? []).map((n) => ({ ...n, tested: false })); + setData({ nodes, edges: g.edges ?? [] }); + }) .catch(() => { /* 保留上一次数据 */ }) .finally(() => setLoading(false)); - }, [taskId]); + }, [taskId, coverageEnabled]); React.useEffect(() => { fetchGraph(); @@ -639,8 +644,14 @@ function GraphInner({ taskId }: { taskId: string }) {
{total > 0 ? ( - 范围内 {inScope} · 已测{" "} - {tested} + 范围内 {inScope} + {coverageEnabled && ( + <> + {" "} + · 已测{" "} + {tested} + + )} ) : ( {loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"} @@ -664,12 +675,16 @@ function GraphInner({ taskId }: { taskId: string }) { })}
- - 已测(高亮) - - - 未测 - + {coverageEnabled && ( + <> + + 已测(高亮) + + + 未测 + + + )} 范围外 @@ -693,12 +708,12 @@ function GraphInner({ taskId }: { taskId: string }) { ); } -export function CoverageGraphTab({ taskId }: { taskId: string }) { +export function CoverageGraphTab({ taskId, coverageEnabled = true }: { taskId: string; coverageEnabled?: boolean }) { return (
- +
diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx index fe9ed26..26f7efc 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx @@ -89,6 +89,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { const [intents, setIntents] = React.useState([]); const [findings, setFindings] = React.useState([]); const [coverage, setCoverage] = React.useState<{ + enabled: boolean; scope_rows: number; denominator: number; tested: number; @@ -263,7 +264,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
- {coverage && coverage.scope_rows > 0 && ( + {coverage && coverage.enabled && coverage.scope_rows > 0 && ( diff --git a/web/src/app/(main)/function/tasks/detail/page.tsx b/web/src/app/(main)/function/tasks/detail/page.tsx index c6acd59..b58f702 100644 --- a/web/src/app/(main)/function/tasks/detail/page.tsx +++ b/web/src/app/(main)/function/tasks/detail/page.tsx @@ -364,7 +364,7 @@ function TaskDetailInner() { - + diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 9b6ea0c..bb95cc2 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -1294,6 +1294,7 @@ function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () => const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时 const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致) const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行 + const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响) // 方式1 文件上传:建任务前把文件暂存到 drafts//uploads/,拿回绝对路径追加进描述。 const [uploading, setUploading] = React.useState(false); const [uploadCount, setUploadCount] = React.useState(0); @@ -1357,6 +1358,7 @@ function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () => timeoutSeconds: timeoutSec, seedFirstIntent, planHeartbeatSeconds: heartbeatSec, + coverageEnabled, }); toast.success("任务已创建"); setDescription(""); @@ -1368,6 +1370,7 @@ function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () => setTimeoutMin(""); setHeartbeatMin("10"); setSeedFirstIntent(false); + setCoverageEnabled(true); setUploadCount(0); draftIdRef.current = ""; setOpen(false); @@ -1548,6 +1551,20 @@ function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () => 接手判定/补充。CTF 等常一个 work 直接解决的场景推荐开启;关闭则走标准的先规划再执行。

+
+ +

+ 默认开启:计算并展示测试覆盖度、态势图显示测试进度、自动累积测试范围。关闭后不再计算/展示覆盖度, + 态势图仅展示资产不显示进度,agent 也不再获得范围类工具。关闭不影响「关联企业资产范围」。 +

+
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a7e3522..fdcdd7d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -188,6 +188,7 @@ export const api = { timeoutSeconds?: number; seedFirstIntent?: boolean; planHeartbeatSeconds?: number; + coverageEnabled?: boolean; }) => post("/tasks", { description: input.description, @@ -198,6 +199,7 @@ export const api = { timeout_seconds: input.timeoutSeconds ?? 0, seed_first_intent: input.seedFirstIntent ?? false, plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min) + coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能 }), taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)), createTaskTemplate: (input: Pick) => @@ -240,6 +242,7 @@ export const api = { // 资产测试覆盖度(粗估,供参考):范围内资产被 fact 碰过的占比 + 按类型的 总数/已测。 taskCoverage: (id: string) => get<{ + enabled: boolean; // 资产覆盖度功能是否开启;false 时其余字段为零值 scope_rows: number; denominator: number; tested: number; diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts index 1286d5a..d739029 100644 --- a/web/src/lib/mock/data.ts +++ b/web/src/lib/mock/data.ts @@ -844,6 +844,7 @@ export const conversationMessages: Record = { // ── 资产测试覆盖度(/tasks/{id}/coverage)── export const coverage = { + enabled: true, scope_rows: 4, denominator: 26, tested: 11, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index d8d7eb0..66e7bbb 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -31,6 +31,7 @@ export interface Task { llm_failover_reason?: string; source_task_ids?: string[]; // directly related tasks inherited as read-only context company_ids?: number[]; // associated company asset scopes available as task context + coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度 } export interface TaskTemplate {