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
|
||||
}
|
||||
// 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.
|
||||
|
||||
+22
-1
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user