From aeadd0d48b59a9eb06e5656fd733cab6995ca222 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:09:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(coverage):=20=E8=B5=84=E4=BA=A7=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=A6=86=E7=9B=96=E5=BA=A6=EF=BC=88task=5Fscope=20?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=20+=20fact=20=E6=B4=BE=E7=94=9F=E5=B7=B2?= =?UTF-8?q?=E6=B5=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 docs/任务覆盖度设计-最终版.md 实现(轻量版,粗估供 agent 参考): - 新表 task_scope(覆盖度分母 + per-task 授权边界):CREATE TABLE IF NOT EXISTS 进 schema.sql,随 db.go 每次启动 Exec,旧库自动生效。 - db/task_scope.go:AddAutoScope(insertAssets 按资产类型保守入范围)、AddAgentScope (add_task_scope 工具:company/root_domain/subdomain/ip/cidr)、ListTaskScope、 TaskCoverage(范围内资产中被 fact 节点锚定过的占比 + backlog 取样)。 - insertAssets 顶层循环按 worker 显式插入的资产类型自动入范围(source=auto); side-effect 派生的资产不触发(钩子在 handler 顶层、派生在 db 层内部),范围不盲目扩大。 - add_task_scope 工具绑 planner,随 BuiltinToolSeeds 播种(新库/旧库皆 seed)。 - graph_overview 注入 coverage(denominator/tested/backlog_sample + 粗估提示)供 planner 参考。 - HTTP:GET /api/tasks/{id}/coverage、/scope;任务总览页覆盖度卡(含 backlog)。 - 修 toolcatalog_test(record_fact 绑定断言,早前 mainagent 加 recordFact 后未同步)。 验证:go build / vet / agent test / 前端 tsc 均通过。注:DB 层 SQL 尚未经 live Postgres 运行验证。 Co-Authored-By: Claude Opus 5 (1M context) --- agent/toolcatalog_test.go | 17 +- agent/tools.go | 23 +- agent/tools_insert.go | 66 +++++ db/schema.sql | 24 ++ db/task_scope.go | 272 ++++++++++++++++++ server/server.go | 44 +++ .../tasks/detail/_tabs/overview-tab.tsx | 48 ++++ web/src/lib/api.ts | 9 + 8 files changed, 499 insertions(+), 4 deletions(-) create mode 100644 db/task_scope.go diff --git a/agent/toolcatalog_test.go b/agent/toolcatalog_test.go index 8db56ce..1cfc3f7 100644 --- a/agent/toolcatalog_test.go +++ b/agent/toolcatalog_test.go @@ -22,9 +22,20 @@ func TestBuiltinToolSeeds(t *testing.T) { } byKey[s.Key] = s } - // record_fact is worker-only. - if rf, ok := byKey["record_fact"]; !ok || len(rf.Agents) != 1 || rf.Agents[0] != "worker" { - t.Errorf("record_fact agents = %v, want [worker]", rf.Agents) + // record_fact is bound to worker (also mainagent, which can log confirmed facts). + rf, ok := byKey["record_fact"] + hasWorker := false + for _, a := range rf.Agents { + if a == "worker" { + hasWorker = true + } + } + if !ok || !hasWorker { + t.Errorf("record_fact agents = %v, want to include worker", rf.Agents) + } + // add_task_scope is bound to the planner (deliberate scope widening). + if ts, ok := byKey["add_task_scope"]; !ok || len(ts.Agents) == 0 { + t.Errorf("add_task_scope not seeded / has no agent binding: %v", ts.Agents) } // SDK generic tools (incl. sleep, now part of DefaultTools) are deliberately NOT // seeded — every agent owns them; they flow through ToolResolve untouched. diff --git a/agent/tools.go b/agent/tools.go index 52c9fbe..877032a 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -7,9 +7,9 @@ import ( "strconv" "strings" + "github.com/Autumn-27/artex/db" acperm "github.com/Autumn-27/norma/permission" actool "github.com/Autumn-27/norma/tool" - "github.com/Autumn-27/artex/db" ) // compactIntents distills intents to {id, summary, state, asset_ids, parents, @@ -324,6 +324,25 @@ func (t *ToolSet) graphOverviewData() map[string]any { if description, goal, err := t.ts.Root(); err == nil { out["task"] = map[string]any{"description": description, "goal": goal} } + // coverage:粗略的资产测试覆盖度参考——范围(task_scope)内的资产里,被 fact 碰过的 + // 占比;backlog_sample 是范围内还没被测的资产(供优先补测)。仅任务上下文有。 + if t.as != nil && t.ts != nil && t.taskID > 0 { + if cov, err := t.as.TaskCoverage(t.taskID, t.ts.ID(), 20); err == nil { + m := map[string]any{ + "denominator": cov.Denominator, + "tested": cov.Tested, + "backlog_sample": cov.Backlog, + "note": "粗略估计、仅供参考:容器型资产/大量枚举会让它偏低,勿据此认为已测完;优先补测 backlog。", + } + if cov.ScopeRows == 0 { + m["pct"] = nil + m["status"] = "范围未锚定" + } else { + m["pct"] = cov.Pct + } + out["coverage"] = m + } + } return out } } @@ -1073,5 +1092,7 @@ func (t *ToolSet) PlannerTools() []actool.CoreTool { t.addFinding(), // list_companies:查看企业列表 + scope + 资产数(拿 company_id / 理解归属范围)。 t.listCompanies(), + // add_task_scope:主动把整根域/整公司/某子域/IP 纳入本任务测试范围(覆盖度分母)。 + t.addTaskScope(), } } diff --git a/agent/tools_insert.go b/agent/tools_insert.go index c4cab12..7adb5b0 100644 --- a/agent/tools_insert.go +++ b/agent/tools_insert.go @@ -3,6 +3,7 @@ package agent import ( "context" "encoding/json" + "strconv" "strings" "github.com/Autumn-27/artex/db" @@ -271,6 +272,13 @@ func (t *ToolSet) insertAssets() actool.CoreTool { results = append(results, result{Index: i, ID: id, Type: typ}) t.writes.Assets++ t.anchorOwner(id) + // 自动入测试范围(source='auto'):只对 worker 顶层显式插入的这一项,按其 + // 类型加保守范围;side-effect 派生的资产不经此处,故范围不盲目扩大。taskID=0 时无操作。 + svcIP := item.ServiceIP + if svcIP == "" { + svcIP = item.IP + } + _ = t.as.AddAutoScope(taskID, typ, item.Domain, item.URL, svcIP) } return jsonResult(map[string]any{ @@ -332,6 +340,64 @@ func (t *ToolSet) addCompanyScope() actool.CoreTool { ) } +// addTaskScope lets the plan agent add test scope to THE CURRENT TASK — the coverage +// denominator and the task's authorization edge. Worker discoveries are auto-scoped +// (precise host) by insertAssets; this tool is for DELIBERATELY WIDENING: pull a whole +// root domain or whole company into scope, or add a specific subdomain / ip. +func (t *ToolSet) addTaskScope() actool.CoreTool { + return writeTool( + "add_task_scope", + "把测试范围加入【本任务】——这是资产测试覆盖度的分母,也是本任务的授权边界。\n"+ + "kind 支持:company(整个公司名下资产) / root_domain(整个根域,含所有子域) / subdomain(单个精确子域) / ip / cidr。\n"+ + "说明:worker 逐个碰到的主机会被系统【自动】加进范围(精确子域);本工具用于【主动扩大】——把整个根域/整个公司纳入,或补充指定某子域/IP。\n"+ + "value:company 传公司名或 id(公司须已存在);root_domain/subdomain 传域名;ip/cidr 传 IP 或网段。\n"+ + "务必给 reason 说明依据(可审计)。多条用 entries 数组。", + obj(map[string]any{ + "entries": map[string]any{"type": "array", "description": "批量:[{kind, value}]。kind∈company/root_domain/subdomain/ip/cidr;value=公司名或id / 域名 / IP / CIDR。", "items": map[string]any{"type": "object"}}, + "kind": str("[单条] company / root_domain / subdomain / ip / cidr"), + "value": str("[单条] 公司名或id / 域名 / IP / CIDR"), + "reason": str("加入依据(用于审计),务必填写"), + }), + func(_ context.Context, in json.RawMessage) (actool.Result, error) { + if t.as == nil { + return actool.Errorf("add_task_scope 未启用: AssetStore 未初始化"), nil + } + if t.taskID <= 0 { + return actool.Errorf("add_task_scope 需要任务上下文(当前无 task)"), nil + } + type scopeEntry struct { + Kind string `json:"kind"` + Value string `json:"value"` + } + var a struct { + Entries []scopeEntry `json:"entries"` + scopeEntry // 单条模式 + Reason string `json:"reason"` + } + _ = json.Unmarshal(in, &a) + items := a.Entries + if len(items) == 0 { + items = []scopeEntry{a.scopeEntry} + } + var added []map[string]any + errs := map[string]string{} + for i, e := range items { + ts, err := t.as.AddAgentScope(t.taskID, strings.TrimSpace(e.Kind), e.Value, a.Reason) + if err != nil { + errs[strconv.Itoa(i)] = err.Error() + continue + } + added = append(added, map[string]any{"kind": ts.Kind, "domain": ts.Domain, "net": ts.Net, "company_id": ts.CompanyID}) + } + out := map[string]any{"added": added} + if len(errs) > 0 { + out["errors"] = errs + } + return jsonResult(out) + }, + ) +} + // listAssets lets an agent query the asset table. func (t *ToolSet) listAssets() actool.CoreTool { return readTool( diff --git a/db/schema.sql b/db/schema.sql index 578315f..2f70e13 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -248,6 +248,30 @@ DROP TRIGGER IF EXISTS trg_tasks_upd ON tasks; CREATE TRIGGER trg_tasks_upd BEFORE UPDATE ON tasks FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +-- 任务测试范围(资产覆盖度的分母 + 授权边界)。 +-- 自动填(source='auto'):insertAssets 顶层按 worker 显式插入的资产类型加保守范围 +-- (root_domain→root_domain,subdomain/service/endpoint→subdomain(host),ip→ip); +-- side-effect 派生的资产不入范围(钩子在 handler 顶层,派生在 db 层内部)。 +-- agent 填(source='agent'):add_task_scope 加 company/root_domain/subdomain/ip。 +-- 覆盖度 = 匹配 active 行的 assets(分母)中,被 fact 节点锚定过的占比(分子)。 +CREATE TABLE IF NOT EXISTS task_scope ( + id BIGSERIAL PRIMARY KEY, + task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('company','root_domain','subdomain','ip','cidr')), + company_id BIGINT REFERENCES companies(id) ON DELETE CASCADE, -- kind='company' + domain TEXT, -- root_domain / subdomain + net CIDR, -- ip / cidr + source TEXT NOT NULL DEFAULT 'auto' CHECK (source IN ('auto','agent','manual')), + reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +-- 去重:同一 task 的同一条范围只存一次(自动填批量插入靠它幂等)。 +CREATE UNIQUE INDEX IF NOT EXISTS uq_task_scope ON task_scope( + task_id, kind, COALESCE(domain,''), COALESCE(net::text,''), COALESCE(company_id,0)); +CREATE INDEX IF NOT EXISTS idx_ts_domain ON task_scope(domain) WHERE kind IN ('root_domain','subdomain'); +CREATE INDEX IF NOT EXISTS idx_ts_net ON task_scope USING GIST(net inet_ops) WHERE kind IN ('ip','cidr'); +CREATE INDEX IF NOT EXISTS idx_ts_company ON task_scope(company_id) WHERE kind = 'company'; + -- ===================================================================== -- E. Agents / 提示词模板 / 变量目录 -- ===================================================================== diff --git a/db/task_scope.go b/db/task_scope.go new file mode 100644 index 0000000..9f9c5c5 --- /dev/null +++ b/db/task_scope.go @@ -0,0 +1,272 @@ +package db + +import ( + "fmt" + "net" + "strconv" + "strings" +) + +// TaskScope is one row of a task's test scope — the coverage denominator and the +// per-task authorization edge. Rows come either from insertAssets (source='auto', +// conservative, one per explicitly-inserted asset) or from the add_task_scope tool +// (source='agent', for company / whole-root-domain / subdomain / ip). +type TaskScope struct { + ID int64 `json:"id"` + TaskID int64 `json:"task_id"` + Kind string `json:"kind"` // company|root_domain|subdomain|ip|cidr + CompanyID *int64 `json:"company_id,omitempty"` + Domain string `json:"domain,omitempty"` + Net string `json:"net,omitempty"` + Source string `json:"source"` + Reason string `json:"reason,omitempty"` +} + +// ipToHostCIDR turns a bare IP into its single-host CIDR (/32 or /128). "" if invalid. +func ipToHostCIDR(ip string) string { + ip = strings.TrimSpace(ip) + p := net.ParseIP(ip) + if p == nil { + return "" + } + if p.To4() != nil { + return ip + "/32" + } + return ip + "/128" +} + +// upsertTaskScope inserts one scope row idempotently (uq_task_scope). A duplicate is +// silently ignored. taskID<=0 or empty kind → no-op. +func (s *AssetStore) upsertTaskScope(ts TaskScope) error { + if ts.TaskID <= 0 || ts.Kind == "" { + return nil + } + var domainVal, netVal, companyVal any + if ts.Domain != "" { + domainVal = ts.Domain + } + if ts.Net != "" { + netVal = ts.Net + } + if ts.CompanyID != nil && *ts.CompanyID > 0 { + companyVal = *ts.CompanyID + } + src := ts.Source + if src == "" { + src = "auto" + } + _, err := s.db.Exec(` +INSERT INTO task_scope(task_id, kind, company_id, domain, net, source, reason) +VALUES ($1,$2,$3,$4,$5::cidr,$6,NULLIF($7,'')) +ON CONFLICT DO NOTHING`, ts.TaskID, ts.Kind, companyVal, domainVal, netVal, src, ts.Reason) + return err +} + +// AddAutoScope records the conservative task scope implied by ONE explicitly-inserted +// asset item (source='auto'). MUST be called only from insertAssets' top-level loop — +// never from a db-layer side effect (linkHostAssets), so派生资产不会盲目扩大范围。 +// Rule: scope granularity follows the asset's own type. taskID<=0 → no-op. +func (s *AssetStore) AddAutoScope(taskID int64, assetType, domain, rawURL, ip string) error { + if taskID <= 0 { + return nil + } + switch assetType { + case "root_domain": + if d := DomainKey(domain); d != "" { + return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "root_domain", Domain: d}) + } + case "subdomain": + if d := DomainKey(domain); d != "" { + return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "subdomain", Domain: d}) + } + case "service", "endpoint": + host := domain + if host == "" && rawURL != "" { + host, _, _ = parseURL(normalizeURL(rawURL)) + } + host = DomainKey(host) + if host != "" && net.ParseIP(host) == nil { + return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "subdomain", Domain: host}) + } + // IP-literal host or no host → fall back to ip scope if we have one. + if c := ipToHostCIDR(ip); c != "" { + return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "ip", Net: c}) + } + case "ip": + if c := ipToHostCIDR(ip); c != "" { + return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "ip", Net: c}) + } + } + return nil +} + +// AddAgentScope parses an agent-provided (kind,value) and records it (source='agent'). +// company: value = company name or id (must already exist). root_domain/subdomain: +// value = a domain. ip/cidr: value = an IP or CIDR (bare IP → /32,/128). +func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason string) (TaskScope, error) { + ts := TaskScope{TaskID: taskID, Kind: kind, Source: "agent", Reason: reason} + if taskID <= 0 { + return ts, fmt.Errorf("需要 task_id") + } + value = strings.TrimSpace(value) + if value == "" { + return ts, fmt.Errorf("value 不能为空") + } + switch kind { + case "company": + if s.company == nil { + return ts, fmt.Errorf("company store 未启用") + } + var comp *Company + var err error + if id, e := strconv.ParseInt(value, 10, 64); e == nil { + comp, err = s.company.GetCompany(id) + } else { + comp, err = s.company.GetCompanyByName(value) + } + if err != nil { + return ts, err + } + if comp == nil { + return ts, fmt.Errorf("company 不存在: %s(先用 list_companies 确认,或建好企业)", value) + } + ts.CompanyID = &comp.ID + case "root_domain": + d := DomainKey(value) + root, _ := RootDomain(d) + if root == "" { + root = d + } + if root == "" { + return ts, fmt.Errorf("无效根域: %s", value) + } + ts.Domain = root + case "subdomain": + d := DomainKey(value) + if d == "" { + return ts, fmt.Errorf("无效子域: %s", value) + } + ts.Domain = d + case "ip", "cidr": + v := value + if !strings.Contains(v, "/") { + v = ipToHostCIDR(v) + ts.Kind = "ip" + } else { + ts.Kind = "cidr" + } + if v == "" { + return ts, fmt.Errorf("无效 ip/cidr: %s", value) + } + if _, _, err := net.ParseCIDR(v); err != nil { + return ts, fmt.Errorf("无效 ip/cidr: %s", value) + } + ts.Net = v + default: + return ts, fmt.Errorf("不支持的 kind: %s(company/root_domain/subdomain/ip/cidr)", kind) + } + if err := s.upsertTaskScope(ts); err != nil { + return ts, err + } + return ts, nil +} + +// ListTaskScope returns all scope rows for a task. +func (s *AssetStore) ListTaskScope(taskID int64) ([]TaskScope, error) { + rows, err := s.db.Query(` +SELECT id, kind, COALESCE(company_id,0), COALESCE(domain,''), COALESCE(net::text,''), source, COALESCE(reason,'') +FROM task_scope WHERE task_id=$1 ORDER BY id`, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []TaskScope{} + for rows.Next() { + var t TaskScope + var cid int64 + if err := rows.Scan(&t.ID, &t.Kind, &cid, &t.Domain, &t.Net, &t.Source, &t.Reason); err != nil { + return nil, err + } + t.TaskID = taskID + if cid > 0 { + t.CompanyID = &cid + } + out = append(out, t) + } + return out, rows.Err() +} + +// CoverageAsset is one in-scope asset (used for the untested backlog sample). +type CoverageAsset struct { + ID int64 `json:"id"` + Type string `json:"type"` + Label string `json:"label"` +} + +// Coverage is a task's rough asset test coverage — a reference figure for the agent, +// 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 { + ScopeRows int `json:"scope_rows"` // 0 → 范围未锚定 + Denominator int `json:"denominator"` // 范围内资产数 + Tested int `json:"tested"` // 已测(约) + Pct *float64 `json:"pct"` // 覆盖度;分母 0 时 null + Backlog []CoverageAsset `json:"backlog_sample"` +} + +// task_scope→assets match predicate, reused by the count and backlog queries. $1=taskID. +const covTargetCTE = ` +target AS ( + SELECT DISTINCT a.id, a.type, + COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label + FROM assets a + JOIN task_scope ts ON ts.task_id = $1 AND ( + (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 a.ip IS NOT NULL AND a.ip !~ '[^0-9a-fA-F:.]' AND ts.net >>= a.ip::inet) + ) +), +tested AS ( + SELECT DISTINCT ea.asset_id + FROM exploration_anchors ea + JOIN exploration_nodes en ON en.id = ea.node_id + WHERE en.exploration_id = $2 AND en.kind = 'fact' +)` + +// TaskCoverage computes rough coverage for a task. taskID indexes task_scope + assets; +// expID indexes the fact anchors. backlogSample caps the untested-asset sample (≤). +func (s *AssetStore) TaskCoverage(taskID, expID int64, backlogSample int) (*Coverage, error) { + if backlogSample <= 0 { + backlogSample = 20 + } + cov := &Coverage{Backlog: []CoverageAsset{}} + _ = s.db.QueryRow(`SELECT count(*) FROM task_scope WHERE task_id=$1`, taskID).Scan(&cov.ScopeRows) + + if err := s.db.QueryRow(`WITH `+covTargetCTE+` +SELECT (SELECT count(*) FROM target), + (SELECT count(*) FROM target t WHERE t.id IN (SELECT asset_id FROM tested))`, + taskID, expID).Scan(&cov.Denominator, &cov.Tested); err != nil { + return nil, err + } + if cov.Denominator > 0 { + p := float64(cov.Tested) / float64(cov.Denominator) + cov.Pct = &p + } + + rows, err := s.db.Query(`WITH `+covTargetCTE+` +SELECT t.id, t.type, t.label FROM target t +WHERE t.id NOT IN (SELECT asset_id FROM tested) +ORDER BY t.id LIMIT $3`, taskID, expID, backlogSample) + if err != nil { + return cov, nil // counts are still useful even if the sample query fails + } + defer rows.Close() + for rows.Next() { + var a CoverageAsset + if err := rows.Scan(&a.ID, &a.Type, &a.Label); err == nil { + cov.Backlog = append(cov.Backlog, a) + } + } + return cov, nil +} diff --git a/server/server.go b/server/server.go index edd8a7f..93a9370 100644 --- a/server/server.go +++ b/server/server.go @@ -482,6 +482,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/tasks", s.listTasks) mux.HandleFunc("POST /api/tasks", s.createTask) mux.HandleFunc("GET /api/tasks/{id}", s.getTask) + mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage) + mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList) mux.HandleFunc("POST /api/tasks/{id}/control", s.control) mux.HandleFunc("POST /api/active", s.setActive) @@ -1084,6 +1086,48 @@ func (s *Server) getTask(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, t) } +// taskCoverage returns a task's rough asset test coverage (denominator/tested/backlog). +func (s *Server) taskCoverage(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + as := s.m.Assets() + if as == nil { + writeErr(w, 503, "asset store 未启用") + return + } + taskID, _ := strconv.ParseInt(t.ID, 10, 64) + cov, err := as.TaskCoverage(taskID, t.ExpID, atoiDefault(r.URL.Query().Get("backlog"), 20)) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, cov) +} + +// taskScopeList returns a task's scope rows (coverage denominator sources). +func (s *Server) taskScopeList(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + as := s.m.Assets() + if as == nil { + writeErr(w, 503, "asset store 未启用") + return + } + taskID, _ := strconv.ParseInt(t.ID, 10, 64) + rows, err := as.ListTaskScope(taskID) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"scope": rows}) +} + func (s *Server) frontier(w http.ResponseWriter, r *http.Request) { t := s.m.ResolveTask(r.URL.Query().Get("task")) if t == nil { 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 8426fc3..725f38a 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 @@ -52,6 +52,13 @@ export function OverviewTab({ taskId }: { taskId: string }) { const [stats, setStats] = React.useState(null); const [intents, setIntents] = React.useState([]); const [findings, setFindings] = React.useState([]); + const [coverage, setCoverage] = React.useState<{ + scope_rows: number; + denominator: number; + tested: number; + pct: number | null; + backlog_sample: { id: number; type: string; label: string }[]; + } | null>(null); React.useEffect(() => { let cancelled = false; @@ -70,6 +77,14 @@ export function OverviewTab({ taskId }: { taskId: string }) { setStats(statsResp); setIntents(intentsResp); setFindings(findingsResp); + // coverage is independent + may 503 when no asset store — fetch separately so + // its failure never blocks the others. + api + .taskCoverage(taskId) + .then((c) => { + if (!cancelled) setCoverage(c); + }) + .catch(() => {}); } catch { // transient errors are ignored; the next poll will retry } @@ -93,6 +108,39 @@ export function OverviewTab({ taskId }: { taskId: string }) { return (
+ {coverage && coverage.scope_rows > 0 && ( + + + + 资产测试覆盖度 + (粗估,仅供参考) + + + +
+ + {coverage.pct != null ? Math.round(coverage.pct * 100) + "%" : "—"} + + + 已测 {coverage.tested} / 范围内 {coverage.denominator} + +
+ {coverage.pct != null && } + {coverage.backlog_sample.length > 0 && ( +
+
未测(优先补测):
+
+ {coverage.backlog_sample.slice(0, 12).map((a) => ( + + {a.label || a.type} + + ))} +
+
+ )} +
+
+ )} {/* Heartbeat */} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index cb27de5..e144d30 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -115,6 +115,15 @@ export const api = { setActive: (id: string) => post<{ active: string }>("/active", { id }), // ---- stats ---- stats: (task?: string) => get(`/stats${tq(task)}`), + // 资产测试覆盖度(粗估,供参考):范围内资产里被 fact 碰过的占比 + 未测 backlog 取样。 + taskCoverage: (id: string) => + get<{ + scope_rows: number; + denominator: number; + tested: number; + pct: number | null; + backlog_sample: { id: number; type: string; label: string }[]; + }>(`/tasks/${id}/coverage`), // ---- assets ---- // Server-side paginated: pass limit/offset, get back the page + full match total.