按 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]>
273 lines
8.6 KiB
Go
273 lines
8.6 KiB
Go
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
|
||
}
|