Files
ARTEX/db/coverage_ledger.go
T
BaaRai 101c92d516 Coverage ledger: review fixes (one-line capped fields, framed gap lines, per-run end_reason)
Normalise and length-check ledger fields in db.RecordCoverage, frame the planner gap lines in the data envelope, cap the wrap-up (25) and report (200) gap lists, cap current rows at 2000 per task. Replace the activity budget marker with tasks.end_reason, written with the guarded terminal status and cleared on revive. Generic 500 message in taskCoverage; early return on an empty ledger in the planner block.
2026-11-12 20:48:34 +01:00

336 lines
13 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"
"sort"
"strings"
"time"
"unicode"
"unicode/utf8"
)
// Coverage ledger (modeled on Strix strix/report/coverage.py, skill_coverage_gaps).
// Two layers: agent_reported = the entries workers write with record_coverage;
// machine_observed = execution facts (asset coverage from TaskCoverageWithSources,
// findings). The ledger adds the risk-class dimension and the negative results; it
// never recomputes tested assets. A risk class with no entry is unexamined, never
// clean, and a no_issue_found is never derived from the absence of a finding.
// Ledger outcomes.
const (
OutcomeReported = "reported"
OutcomeNoIssueFound = "no_issue_found"
OutcomeRuledOut = "ruled_out"
OutcomeNotApplicable = "not_applicable"
OutcomeNeedsFollowUp = "needs_follow_up"
)
// Gap kinds.
const (
GapNeedsFollowUp = "needs_follow_up"
GapUnrecordedRiskClass = "unrecorded_risk_class"
GapMissingEvidence = "missing_evidence"
GapMissingReason = "missing_reason"
)
// EndReasonBudget is the tasks.end_reason of a run settled by the run-budget
// wrap-up (server/engine_timeout.go); completeness reads it back. The column is
// per run: a revive to running clears it (ApplyTaskAdmission, SetStatus).
const EndReasonBudget = "budget"
// Ledger field limits (characters, after normalisation). Over a limit the write
// is refused, never truncated.
const (
maxCoverageSurface = 200
maxCoverageRiskArea = 64
maxCoverageEvidence = 500
maxCoverageReason = 500
// MaxCoverageRowsPerTask caps the current rows of one task: a new key beyond
// it is refused, an update of an existing key is always allowed. The history
// is append-only and not capped.
MaxCoverageRowsPerTask = 2000
)
// ValidCoverageOutcome reports whether o is one of the five ledger outcomes.
func ValidCoverageOutcome(o string) bool {
switch o {
case OutcomeReported, OutcomeNoIssueFound, OutcomeRuledOut, OutcomeNotApplicable, OutcomeNeedsFollowUp:
return true
}
return false
}
// CoverageEntry is one agent_reported row: surface + risk_area + outcome + evidence + reason.
type CoverageEntry struct {
Surface string `json:"surface"`
RiskArea string `json:"risk_area"`
Outcome string `json:"outcome"`
Evidence string `json:"evidence"`
Reason string `json:"reason"`
Worker string `json:"worker,omitempty"`
IntentID int64 `json:"intent_id,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// CoverageGap is one open item: a follow-up, an unrecorded mandatory class, or an
// entry missing its required evidence/reason.
type CoverageGap struct {
Kind string `json:"kind"`
Surface string `json:"surface,omitempty"`
RiskArea string `json:"risk_area"`
}
func (g CoverageGap) String() string {
if g.Surface == "" {
return g.Kind + " " + g.RiskArea
}
return g.Kind + " " + g.Surface + " " + g.RiskArea
}
// CoverageCompleteness says whether the run ended as a complete scan. Caveat is
// always set: an incomplete run is never reported silently.
type CoverageCompleteness struct {
Complete bool `json:"complete"`
State string `json:"state"` // complete | in_progress | paused | timeout | budget | aborted
Caveat string `json:"caveat"`
}
// CoverageLedger is the ledger document of one task.
type CoverageLedger struct {
Entries []CoverageEntry `json:"entries"`
Gaps []CoverageGap `json:"gaps"`
Unexamined []string `json:"unexamined"` // mandatory classes with no entry
FindingsByClass map[string]int `json:"findings_by_class"` // machine_observed
Completeness *CoverageCompleteness `json:"completeness"`
}
func normRiskArea(s string) string { return strings.ToLower(NormalizeCoverageField(s)) }
// NormalizeCoverageField folds an agent-reported ledger field to one line: every
// control character (C0, DEL, C1) and the Unicode line/paragraph separators become
// a space, runs of whitespace collapse to one space, and the ends are trimmed. The
// fields reach the planner prompt and the report, so they must not carry newlines.
func NormalizeCoverageField(s string) string {
s = strings.Map(func(r rune) rune {
if unicode.IsControl(r) || r == '
' || r == '
' {
return ' '
}
return r
}, s)
return strings.Join(strings.Fields(s), " ")
}
// normalizeCoverageEntry normalises the agent-reported fields of e and checks
// them: required fields, the outcome and the length limits.
func normalizeCoverageEntry(e CoverageEntry) (CoverageEntry, error) {
e.Surface, e.RiskArea = NormalizeCoverageField(e.Surface), normRiskArea(e.RiskArea)
e.Evidence, e.Reason = NormalizeCoverageField(e.Evidence), NormalizeCoverageField(e.Reason)
if e.Surface == "" || e.RiskArea == "" {
return e, fmt.Errorf("task, surface and risk_area are required")
}
if !ValidCoverageOutcome(e.Outcome) {
return e, fmt.Errorf("outcome must be one of reported, no_issue_found, ruled_out, not_applicable, needs_follow_up")
}
for _, f := range []struct {
name, v string
max int
}{{"surface", e.Surface, maxCoverageSurface}, {"risk_area", e.RiskArea, maxCoverageRiskArea},
{"evidence", e.Evidence, maxCoverageEvidence}, {"reason", e.Reason, maxCoverageReason}} {
if utf8.RuneCountInString(f.v) > f.max {
return e, fmt.Errorf("%s too long (max %d)", f.name, f.max)
}
}
return e, nil
}
// RecordCoverage upserts the current row of (task, surface, risk_area) and appends
// the write to the history, in one transaction. The fields are normalised and
// checked here (normalizeCoverageEntry), whatever the caller.
func (s *AssetStore) RecordCoverage(taskID int64, e CoverageEntry) error {
if taskID <= 0 {
return fmt.Errorf("task, surface and risk_area are required")
}
e, err := normalizeCoverageEntry(e)
if err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
// Serialise the writers of one task so the row cap holds under concurrency.
if _, err := tx.Exec(`SELECT 1 FROM tasks WHERE id=$1 FOR UPDATE`, taskID); err != nil {
return err
}
var exists bool
var current int
if err := tx.QueryRow(`SELECT EXISTS (SELECT 1 FROM coverage_ledger WHERE task_id=$1 AND surface=$2 AND risk_area=$3),
(SELECT COUNT(*) FROM coverage_ledger WHERE task_id=$1)`, taskID, e.Surface, e.RiskArea).Scan(&exists, &current); err != nil {
return err
}
if !exists && current >= MaxCoverageRowsPerTask {
return fmt.Errorf("coverage ledger full for this task (max %d entries): update an existing surface and risk_area instead", MaxCoverageRowsPerTask)
}
args := []any{taskID, e.Surface, e.RiskArea, e.Outcome, e.Evidence, e.Reason, e.Worker, nullID(e.IntentID)}
if _, err := tx.Exec(`INSERT INTO coverage_ledger_history(task_id, surface, risk_area, outcome, evidence, reason, worker, intent_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, args...); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO coverage_ledger(task_id, surface, risk_area, outcome, evidence, reason, worker, intent_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (task_id, surface, risk_area) DO UPDATE SET outcome=EXCLUDED.outcome, evidence=EXCLUDED.evidence,
reason=EXCLUDED.reason, worker=EXCLUDED.worker, intent_id=EXCLUDED.intent_id, updated_at=now()`, args...); err != nil {
return err
}
return tx.Commit()
}
func nullID(id int64) any {
if id <= 0 {
return nil
}
return id
}
// CoverageEntryCount returns the number of current ledger rows of a task.
func (s *AssetStore) CoverageEntryCount(taskID int64) (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM coverage_ledger WHERE task_id=$1`, taskID).Scan(&n)
return n, err
}
// CoverageLedger loads the ledger document of a task: its entries, the mandatory
// classes (goal vulnclass), the findings per class and the task's end state.
func (s *AssetStore) CoverageLedger(taskID int64) (*CoverageLedger, error) {
var entries []CoverageEntry
rows, err := s.db.Query(`SELECT surface, risk_area, outcome, evidence, reason, worker, COALESCE(intent_id,0), updated_at
FROM coverage_ledger WHERE task_id=$1 ORDER BY risk_area, surface`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var e CoverageEntry
if err := rows.Scan(&e.Surface, &e.RiskArea, &e.Outcome, &e.Evidence, &e.Reason, &e.Worker, &e.IntentID, &e.UpdatedAt); err != nil {
return nil, err
}
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
var classes []string
if err := s.scanStrings(&classes, `SELECT DISTINCT lower(btrim(n.payload->>'vulnclass')) FROM exploration_nodes n
JOIN tasks t ON t.exploration_id=n.exploration_id
WHERE t.id=$1 AND n.kind='goal' AND COALESCE(btrim(n.payload->>'vulnclass'),'')<>'' ORDER BY 1`, taskID); err != nil {
return nil, err
}
byClass := map[string]int{}
frows, err := s.db.Query(`SELECT lower(btrim(vulnclass)), COUNT(*) FROM findings WHERE task_id=$1 AND btrim(vulnclass)<>'' GROUP BY 1`, taskID)
if err != nil {
return nil, err
}
defer frows.Close()
for frows.Next() {
var c string
var n int
if err := frows.Scan(&c, &n); err != nil {
return nil, err
}
byClass[c] = n
}
if err := frows.Err(); err != nil {
return nil, err
}
var status string
var paused, budget, pastDeadline bool
// end_reason is per run (cleared when a revive moves the task back to running),
// so a budget wrap-up of an earlier run never marks a later one.
if err := s.db.QueryRow(`SELECT t.status, COALESCE(t.paused,false), t.end_reason=$2,
COALESCE(t.deadline_at IS NOT NULL AND t.completed_at IS NOT NULL AND t.completed_at >= t.deadline_at, false)
FROM tasks t WHERE t.id=$1`, taskID, EndReasonBudget).Scan(&status, &paused, &budget, &pastDeadline); err != nil {
return nil, err
}
l := BuildCoverageLedger(entries, classes, CoverageCompletenessFor(status, paused, budget, pastDeadline))
l.FindingsByClass = byClass
return l, nil
}
func (s *AssetStore) scanStrings(out *[]string, q string, args ...any) error {
rows, err := s.db.Query(q, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return err
}
*out = append(*out, v)
}
return rows.Err()
}
// BuildCoverageLedger derives the gaps from the entries and the mandatory classes.
// Gap sources: (a) needs_follow_up entries; (b) a mandatory class with no entry at
// all (unrecorded_risk_class, also listed as unexamined); (c) an entry missing its
// required evidence (no_issue_found) or reason (ruled_out, not_applicable). Nothing
// here reads findings: an absent finding never closes a class.
func BuildCoverageLedger(entries []CoverageEntry, mandatory []string, c CoverageCompleteness) *CoverageLedger {
l := &CoverageLedger{Entries: entries, Gaps: []CoverageGap{}, Unexamined: []string{}, FindingsByClass: map[string]int{}, Completeness: &c}
if l.Entries == nil {
l.Entries = []CoverageEntry{}
}
seen := map[string]bool{}
for _, e := range entries {
seen[normRiskArea(e.RiskArea)] = true
switch {
case e.Outcome == OutcomeNeedsFollowUp:
l.Gaps = append(l.Gaps, CoverageGap{GapNeedsFollowUp, e.Surface, e.RiskArea})
case e.Outcome == OutcomeNoIssueFound && strings.TrimSpace(e.Evidence) == "":
l.Gaps = append(l.Gaps, CoverageGap{GapMissingEvidence, e.Surface, e.RiskArea})
case (e.Outcome == OutcomeRuledOut || e.Outcome == OutcomeNotApplicable) && strings.TrimSpace(e.Reason) == "":
l.Gaps = append(l.Gaps, CoverageGap{GapMissingReason, e.Surface, e.RiskArea})
}
}
classes := map[string]bool{}
for _, m := range mandatory {
if m = normRiskArea(m); m != "" && !seen[m] && !classes[m] {
classes[m] = true
l.Unexamined = append(l.Unexamined, m)
}
}
sort.Strings(l.Unexamined)
unrecorded := make([]CoverageGap, 0, len(l.Unexamined))
for _, m := range l.Unexamined {
unrecorded = append(unrecorded, CoverageGap{Kind: GapUnrecordedRiskClass, RiskArea: m})
}
l.Gaps = append(unrecorded, l.Gaps...) // the central invariant first
return l
}
// CoverageCompletenessFor maps a task's end state to completeness. Only a task that
// reached done on its own is complete; timeout, budget wrap-up (even when the final
// round met the goals), pause, abort (failed) and a still-running task are not.
func CoverageCompletenessFor(status string, paused, budget, pastDeadline bool) CoverageCompleteness {
state := "complete"
switch {
case budget:
state = "budget"
case status == "timeout" || (status == "done" && pastDeadline):
state = "timeout"
case status == "failed":
state = "aborted"
case paused || status == "paused":
state = "paused"
case status != "done":
state = "in_progress"
}
if state == "complete" {
return CoverageCompleteness{true, state, "Run ended normally. Classes listed as unexamined were not tested and are not clean."}
}
return CoverageCompleteness{false, state, "Incomplete scan (" + state + "): the absence of a finding does not mean the target is clean; unexamined classes and open gaps were not covered."}
}