Files
ARTEX/db/task_scope.go
T
Autumn-27andClaude Opus 5 aeadd0d48b 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]>
2026-08-08 09:09:38 -04:00

273 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}