feat(coverage): 资产测试覆盖度(task_scope 范围 + fact 派生已测)
按 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) <[email protected]>
This commit is contained in:
1 parent
58e057a3f2
commit
aeadd0d48b
8 files changed
+499
-4
No files matched your search
@@ -22,9 +22,20 @@ func TestBuiltinToolSeeds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
byKey[s.Key] = s
|
byKey[s.Key] = s
|
||||||
}
|
}
|
||||||
// record_fact is worker-only.
|
// record_fact is bound to worker (also mainagent, which can log confirmed facts).
|
||||||
if rf, ok := byKey["record_fact"]; !ok || len(rf.Agents) != 1 || rf.Agents[0] != "worker" {
|
rf, ok := byKey["record_fact"]
|
||||||
t.Errorf("record_fact agents = %v, want [worker]", rf.Agents)
|
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
|
// SDK generic tools (incl. sleep, now part of DefaultTools) are deliberately NOT
|
||||||
// seeded — every agent owns them; they flow through ToolResolve untouched.
|
// seeded — every agent owns them; they flow through ToolResolve untouched.
|
||||||
|
|||||||
+22
-1
@@ -7,9 +7,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Autumn-27/artex/db"
|
||||||
acperm "github.com/Autumn-27/norma/permission"
|
acperm "github.com/Autumn-27/norma/permission"
|
||||||
actool "github.com/Autumn-27/norma/tool"
|
actool "github.com/Autumn-27/norma/tool"
|
||||||
"github.com/Autumn-27/artex/db"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// compactIntents distills intents to {id, summary, state, asset_ids, parents,
|
// 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 {
|
if description, goal, err := t.ts.Root(); err == nil {
|
||||||
out["task"] = map[string]any{"description": description, "goal": goal}
|
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
|
return out
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1073,5 +1092,7 @@ func (t *ToolSet) PlannerTools() []actool.CoreTool {
|
|||||||
t.addFinding(),
|
t.addFinding(),
|
||||||
// list_companies:查看企业列表 + scope + 资产数(拿 company_id / 理解归属范围)。
|
// list_companies:查看企业列表 + scope + 资产数(拿 company_id / 理解归属范围)。
|
||||||
t.listCompanies(),
|
t.listCompanies(),
|
||||||
|
// add_task_scope:主动把整根域/整公司/某子域/IP 纳入本任务测试范围(覆盖度分母)。
|
||||||
|
t.addTaskScope(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Autumn-27/artex/db"
|
"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})
|
results = append(results, result{Index: i, ID: id, Type: typ})
|
||||||
t.writes.Assets++
|
t.writes.Assets++
|
||||||
t.anchorOwner(id)
|
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{
|
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.
|
// listAssets lets an agent query the asset table.
|
||||||
func (t *ToolSet) listAssets() actool.CoreTool {
|
func (t *ToolSet) listAssets() actool.CoreTool {
|
||||||
return readTool(
|
return readTool(
|
||||||
|
|||||||
@@ -248,6 +248,30 @@ DROP TRIGGER IF EXISTS trg_tasks_upd ON tasks;
|
|||||||
CREATE TRIGGER trg_tasks_upd BEFORE UPDATE ON tasks
|
CREATE TRIGGER trg_tasks_upd BEFORE UPDATE ON tasks
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
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 / 提示词模板 / 变量目录
|
-- E. Agents / 提示词模板 / 变量目录
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -482,6 +482,8 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/tasks", s.listTasks)
|
mux.HandleFunc("GET /api/tasks", s.listTasks)
|
||||||
mux.HandleFunc("POST /api/tasks", s.createTask)
|
mux.HandleFunc("POST /api/tasks", s.createTask)
|
||||||
mux.HandleFunc("GET /api/tasks/{id}", s.getTask)
|
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/tasks/{id}/control", s.control)
|
||||||
mux.HandleFunc("POST /api/active", s.setActive)
|
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)
|
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) {
|
func (s *Server) frontier(w http.ResponseWriter, r *http.Request) {
|
||||||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||||||
if t == nil {
|
if t == nil {
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
const [stats, setStats] = React.useState<Stats | null>(null);
|
const [stats, setStats] = React.useState<Stats | null>(null);
|
||||||
const [intents, setIntents] = React.useState<TaskNode[]>([]);
|
const [intents, setIntents] = React.useState<TaskNode[]>([]);
|
||||||
const [findings, setFindings] = React.useState<Finding[]>([]);
|
const [findings, setFindings] = React.useState<Finding[]>([]);
|
||||||
|
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(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -70,6 +77,14 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
setStats(statsResp);
|
setStats(statsResp);
|
||||||
setIntents(intentsResp);
|
setIntents(intentsResp);
|
||||||
setFindings(findingsResp);
|
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 {
|
} catch {
|
||||||
// transient errors are ignored; the next poll will retry
|
// transient errors are ignored; the next poll will retry
|
||||||
}
|
}
|
||||||
@@ -93,6 +108,39 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{coverage && coverage.scope_rows > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<TargetIcon className="size-4 text-emerald-500" /> 资产测试覆盖度
|
||||||
|
<span className="text-muted-foreground text-xs font-normal">(粗估,仅供参考)</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<span className="text-2xl font-semibold tabular-nums">
|
||||||
|
{coverage.pct != null ? Math.round(coverage.pct * 100) + "%" : "—"}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
已测 {coverage.tested} / 范围内 {coverage.denominator}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{coverage.pct != null && <Progress value={Math.round(coverage.pct * 100)} />}
|
||||||
|
{coverage.backlog_sample.length > 0 && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="text-muted-foreground mb-1">未测(优先补测):</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{coverage.backlog_sample.slice(0, 12).map((a) => (
|
||||||
|
<span key={a.id} className="bg-muted rounded px-1.5 py-0.5 font-mono">
|
||||||
|
{a.label || a.type}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{/* Heartbeat */}
|
{/* Heartbeat */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -115,6 +115,15 @@ export const api = {
|
|||||||
setActive: (id: string) => post<{ active: string }>("/active", { id }),
|
setActive: (id: string) => post<{ active: string }>("/active", { id }),
|
||||||
// ---- stats ----
|
// ---- stats ----
|
||||||
stats: (task?: string) => get<Stats>(`/stats${tq(task)}`),
|
stats: (task?: string) => get<Stats>(`/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 ----
|
// ---- assets ----
|
||||||
// Server-side paginated: pass limit/offset, get back the page + full match total.
|
// Server-side paginated: pass limit/offset, get back the page + full match total.
|
||||||
|
|||||||
Reference in new issue
Block a user