diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index f85c5d809..d16bf8631 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -114,7 +114,7 @@ function openRawDb(path) { loadProvider(); return new DatabaseSync(path); } -const SCHEMA_VERSION = 45; +const SCHEMA_VERSION = 46; function indexExists(db, name) { return !!db .prepare( @@ -2541,6 +2541,55 @@ function migrateSchema(db) { ":applied_at": new Date().toISOString(), }); } + if (currentVersion < 46) { + // validation_runs: mirrors droid's validation-contract.md + validation-state.json + // pattern. Each run stores the contract spec inline and its execution state. + db.exec(` + CREATE TABLE IF NOT EXISTS validation_runs ( + run_id TEXT PRIMARY KEY, + milestone_id TEXT NOT NULL, + slice_id TEXT, + task_id TEXT, + contract TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + verdict TEXT NOT NULL DEFAULT '', + rationale TEXT NOT NULL DEFAULT '', + findings TEXT NOT NULL DEFAULT '', + started_at TEXT, + completed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + superseded_by TEXT, + FOREIGN KEY (milestone_id) REFERENCES milestones(id) + ) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_validation_runs_scope + ON validation_runs(milestone_id, slice_id, task_id) + `); + db.exec(` + CREATE VIEW IF NOT EXISTS latest_validation_state AS + SELECT vr.* + FROM validation_runs vr + INNER JOIN ( + SELECT milestone_id, + COALESCE(slice_id, '') AS slice_id, + COALESCE(task_id, '') AS task_id, + MAX(created_at) AS max_created + FROM validation_runs + GROUP BY milestone_id, slice_id, task_id + ) latest + ON vr.milestone_id = latest.milestone_id + AND COALESCE(vr.slice_id, '') = latest.slice_id + AND COALESCE(vr.task_id, '') = latest.task_id + AND vr.created_at = latest.max_created + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 46, + ":applied_at": new Date().toISOString(), + }); + } db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); @@ -7215,3 +7264,116 @@ export function getTaskSpec(milestoneId, sliceId, taskId) { ) .get(milestoneId, sliceId, taskId); } + +// ─── Validation Runs ─────────────────────────────────────────────────────────── + +/** + * Start a validation run for a milestone, slice, or task. + * Mirrors droid's validation-state.json creation from validation-contract.md. + * + * Purpose: Track explicit validation contracts and their execution state in the + * DB so any surface (CLI, TUI, headless) can answer "what are we validating and + * where are we" with a single query. + * + * Consumer: autonomous-solver, plan-slice, quality gates, eval runners. + */ +export function startValidationRun({ milestoneId, sliceId, taskId, contract }) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + const runId = crypto.randomUUID(); + currentDb + .prepare( + `INSERT INTO validation_runs + (run_id, milestone_id, slice_id, task_id, contract, status, started_at) + VALUES (:run_id, :milestone_id, :slice_id, :task_id, :contract, 'running', datetime('now'))`, + ) + .run({ + ":run_id": runId, + ":milestone_id": milestoneId, + ":slice_id": sliceId ?? null, + ":task_id": taskId ?? null, + ":contract": contract ?? "", + }); + return runId; +} + +/** + * Complete a validation run with verdict and findings. + * Mirrors droid's update of validation-state.json after run finishes. + * + * Consumer: autonomous-solver after eval execution, quality gate evaluators. + */ +export function completeValidationRun({ + runId, + verdict, + rationale = "", + findings = "", +}) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + currentDb + .prepare( + `UPDATE validation_runs SET + status = :status, + verdict = :verdict, + rationale = :rationale, + findings = :findings, + completed_at = datetime('now') + WHERE run_id = :run_id`, + ) + .run({ + ":run_id": runId, + ":status": verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "error", + ":verdict": verdict ?? "", + ":rationale": rationale ?? "", + ":findings": findings ?? "", + }); +} + +/** + * Get the latest validation state for a scope (milestone, slice, or task). + * Returns the most recent run — mirrors droid's validation-state.json read. + * + * Consumer: any surface that needs "are we passing?" for a milestone/slice/task. + */ +export function getLatestValidationState(milestoneId, sliceId, taskId) { + if (!currentDb) return null; + const rows = currentDb + .prepare( + `SELECT * FROM validation_runs + WHERE milestone_id = :milestone_id + AND (:slice_id IS NULL OR slice_id = :slice_id) + AND (:task_id IS NULL OR task_id = :task_id) + ORDER BY created_at DESC + LIMIT 1`, + ) + .all({ + ":milestone_id": milestoneId, + ":slice_id": sliceId ?? null, + ":task_id": taskId ?? null, + }); + return rows[0] ?? null; +} + +/** + * Get validation run history for a scope. + * Mirrors droid's historical validation-state.json files. + * + * Consumer: forensics, eval review, audit trail queries. + */ +export function getValidationHistory(milestoneId, sliceId, taskId, limit = 20) { + if (!currentDb) return []; + return currentDb + .prepare( + `SELECT * FROM validation_runs + WHERE milestone_id = :milestone_id + AND (:slice_id IS NULL OR slice_id = :slice_id) + AND (:task_id IS NULL OR task_id = :task_id) + ORDER BY created_at DESC + LIMIT :limit`, + ) + .all({ + ":milestone_id": milestoneId, + ":slice_id": sliceId ?? null, + ":task_id": taskId ?? null, + ":limit": limit, + }); +}