feat(task): 资产覆盖度功能可按任务开关

创建任务时新增「资产覆盖度功能」开关(默认开)。关闭后:
- 不计算/展示测试覆盖度,总览卡片隐藏、覆盖度 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
This commit is contained in:
Autumn-27 committed 2026-08-22 10:03:18 -04:00
1 parent 5126ac4592
commit eb3f278711
18 files changed
+179 -43

No files matched your search

+3 -1
View File
@@ -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()
+3 -1
View File
@@ -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()
+58 -14
View File
@@ -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
+7 -4
View File
@@ -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{
+7 -2
View File
@@ -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)
}
}
}
}
+5
View File
@@ -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.
+15
View File
@@ -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 (
+15 -5
View File
@@ -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
+2
View File
@@ -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,
}
}
+3 -1
View File
@@ -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),
}
}
+8
View File
@@ -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)
}
@@ -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 }) {
<div className="flex items-center justify-between gap-3">
{total > 0 ? (
<span className="text-muted-foreground">
范围内 <span className="text-foreground font-semibold tabular-nums">{inScope}</span> · 已测{" "}
<span className="font-semibold tabular-nums text-emerald-600 dark:text-emerald-400">{tested}</span>
范围内 <span className="text-foreground font-semibold tabular-nums">{inScope}</span>
{coverageEnabled && (
<>
{" "}
· 已测{" "}
<span className="font-semibold tabular-nums text-emerald-600 dark:text-emerald-400">{tested}</span>
</>
)}
</span>
) : (
<span className="text-muted-foreground">{loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"}</span>
@@ -664,12 +675,16 @@ function GraphInner({ taskId }: { taskId: string }) {
})}
</div>
<div className="border-border/60 text-muted-foreground flex flex-wrap gap-x-3 gap-y-1.5 border-t pt-2">
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded-full bg-emerald-500" /> 已测(高亮)
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded-full bg-neutral-400" /> 未测
</span>
{coverageEnabled && (
<>
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded-full bg-emerald-500" /> 已测(高亮)
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded-full bg-neutral-400" /> 未测
</span>
</>
)}
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded-full border border-dashed border-neutral-400 bg-neutral-200" /> 范围外
</span>
@@ -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 (
<Card>
<CardContent className="p-0">
<div className="h-[72vh] w-full overflow-hidden rounded-xl">
<GraphInner taskId={taskId} />
<GraphInner taskId={taskId} coverageEnabled={coverageEnabled} />
</div>
</CardContent>
</Card>
@@ -89,6 +89,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
const [intents, setIntents] = React.useState<TaskNode[]>([]);
const [findings, setFindings] = React.useState<Finding[]>([]);
const [coverage, setCoverage] = React.useState<{
enabled: boolean;
scope_rows: number;
denominator: number;
tested: number;
@@ -263,7 +264,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
</div>
</CardContent>
</Card>
{coverage && coverage.scope_rows > 0 && (
{coverage && coverage.enabled && coverage.scope_rows > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -364,7 +364,7 @@ function TaskDetailInner() {
<AssetsTab taskId={id} />
</TabsContent>
<TabsContent value="coverage" className="mt-0">
<CoverageGraphTab taskId={id} />
<CoverageGraphTab taskId={id} coverageEnabled={task?.coverage_enabled !== false} />
</TabsContent>
<TabsContent value="intercept" className="mt-0">
<InterceptTab taskId={id} />
@@ -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/<draftId>/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 直接解决的场景推荐开启;关闭则走标准的先规划再执行。
</p>
</div>
<div className="grid gap-2">
<label htmlFor="coverage-enabled" className="flex items-center gap-2 text-sm">
<Checkbox
id="coverage-enabled"
checked={coverageEnabled}
onCheckedChange={(v) => setCoverageEnabled(!!v)}
/>
资产覆盖度功能
</label>
<p className="text-muted-foreground text-xs">
默认开启:计算并展示测试覆盖度、态势图显示测试进度、自动累积测试范围。关闭后不再计算/展示覆盖度,
态势图仅展示资产不显示进度,agent 也不再获得范围类工具。关闭不影响「关联企业资产范围」。
</p>
</div>
</CollapsibleContent>
</Collapsible>
</div>
+3
View File
@@ -188,6 +188,7 @@ export const api = {
timeoutSeconds?: number;
seedFirstIntent?: boolean;
planHeartbeatSeconds?: number;
coverageEnabled?: boolean;
}) =>
post<Task>("/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<TaskTemplate, "name" | "description" | "goal">) =>
@@ -240,6 +242,7 @@ export const api = {
// 资产测试覆盖度(粗估,供参考):范围内资产被 fact 碰过的占比 + 按类型的 总数/已测。
taskCoverage: (id: string) =>
get<{
enabled: boolean; // 资产覆盖度功能是否开启;false 时其余字段为零值
scope_rows: number;
denominator: number;
tested: number;
+1
View File
@@ -844,6 +844,7 @@ export const conversationMessages: Record<number, Activity[]> = {
// ── 资产测试覆盖度(/tasks/{id}/coverage)──
export const coverage = {
enabled: true,
scope_rows: 4,
denominator: 26,
tested: 11,
+1
View File
@@ -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 {