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:
Autumn-27andClaude Opus 5 committed 2026-08-08 09:09:38 -04:00
1 parent 58e057a3f2
commit aeadd0d48b
8 files changed
+499 -4

No files matched your search

@@ -52,6 +52,13 @@ export function OverviewTab({ taskId }: { taskId: string }) {
const [stats, setStats] = React.useState<Stats | null>(null);
const [intents, setIntents] = React.useState<TaskNode[]>([]);
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(() => {
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 (
<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 */}
<Card>
<CardHeader>
+9
View File
@@ -115,6 +115,15 @@ export const api = {
setActive: (id: string) => post<{ active: string }>("/active", { id }),
// ---- stats ----
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 ----
// Server-side paginated: pass limit/offset, get back the page + full match total.