diff --git a/src/headless-uok-status.ts b/src/headless-uok-status.ts index 2fba2435c..ff0425e4a 100644 --- a/src/headless-uok-status.ts +++ b/src/headless-uok-status.ts @@ -93,32 +93,57 @@ export interface UokStatusResult { } /** - * A row is "legacy" when it lacks the schema-v2 metadata fields that the - * writer adapter (slice 2) will start producing. Today the quality_gates - * table has no surface/runControl/permissionProfile columns, so every row - * is legacy by definition. This guard lives here so the moment those - * columns appear, the classification flips automatically without a - * separate code edit. + * A row is "legacy" when it lacks the schema-v2 metadata that the writer + * adapter (slice 2 of the UOK control-plane plan) populates. Surface is + * the canonical indicator: NULL → legacy, set → schema-v2 row that + * should be classified ok/stale/incomplete based on the other fields. + * + * Schema v66 added the columns (surface, run_control, permission_profile, + * trace_id, parent_trace); pre-v66 rows have NULL for all of them. + * + * "incomplete" fires when surface is set but one of run_control, + * permission_profile, or trace_id is missing — caller didn't populate the + * full required set, and the classifier surfaces that so operators can + * find the buggy writer. */ -function hasSchemaV2Metadata(_row: unknown): boolean { - // Placeholder for slice 2. Will read row.surface / row.run_control / - // row.permission_profile when those columns ship. - return false; +interface GateMetadataRow { + surface: string | null; + runControl: string | null; + permissionProfile: string | null; + traceId: string | null; +} + +function hasSchemaV2Metadata(meta: GateMetadataRow): boolean { + return typeof meta.surface === "string" && meta.surface.length > 0; +} + +function isSchemaV2Complete(meta: GateMetadataRow): boolean { + return ( + typeof meta.surface === "string" && + meta.surface.length > 0 && + typeof meta.runControl === "string" && + meta.runControl.length > 0 && + typeof meta.permissionProfile === "string" && + meta.permissionProfile.length > 0 && + typeof meta.traceId === "string" && + meta.traceId.length > 0 + ); } const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; function classifyCoverage( entry: Omit, - metadataPresent: boolean, + meta: GateMetadataRow, ): GateCoverageStatus { - if (!metadataPresent) return "legacy"; + if (!hasSchemaV2Metadata(meta)) return "legacy"; + if (!isSchemaV2Complete(meta)) return "incomplete"; if (entry.total === 0) { // Has metadata but no runs in window. If we ever saw a run, it's // stale; otherwise it's never run (caller will mark "missing" when // a configured-gate registry confirms it was expected). For slice - // 1, no registry exists, so the safer default is "stale". - return entry.lastEvaluatedAt ? "stale" : "stale"; + // 1+2, no registry exists, so the safer default is "stale". + return "stale"; } const last = entry.lastEvaluatedAt ? Date.parse(entry.lastEvaluatedAt) @@ -203,25 +228,52 @@ export async function handleUokStatus( const gateIds: string[] = gatesDbModule.getDistinctGateIds(); - // Fetch scope and last-evaluated from quality_gates DB for each gate + // Fetch scope, last-evaluated, and schema-v2 metadata from + // quality_gates DB for each gate. Picks the most-recent row's + // metadata (MAX(evaluated_at)) so the classifier sees current + // schema-v2 status rather than oldest. Returns null fields when + // no row exists or the columns haven't been migrated yet. const sfDbModule = (await jiti.import(sfExtensionPath("sf-db"), {})) as any; - const getGateMeta = ( - id: string, - ): { scope: string; lastEvaluatedAt: string | null } => { + interface GateMetaQuery { + scope: string; + lastEvaluatedAt: string | null; + surface: string | null; + runControl: string | null; + permissionProfile: string | null; + traceId: string | null; + } + const getGateMeta = (id: string): GateMetaQuery => { + const empty: GateMetaQuery = { + scope: "unknown", + lastEvaluatedAt: null, + surface: null, + runControl: null, + permissionProfile: null, + traceId: null, + }; try { const db = sfDbModule._getAdapter?.() ?? null; - if (!db) return { scope: "unknown", lastEvaluatedAt: null }; + if (!db) return empty; const row = db .prepare( - "SELECT scope, MAX(evaluated_at) AS last_eval FROM quality_gates WHERE gate_id = ? LIMIT 1", + `SELECT scope, evaluated_at, surface, run_control, + permission_profile, trace_id + FROM quality_gates + WHERE gate_id = ? + ORDER BY evaluated_at IS NULL, evaluated_at DESC + LIMIT 1`, ) .get(id); return { scope: row?.scope ?? "unknown", - lastEvaluatedAt: row?.last_eval ?? null, + lastEvaluatedAt: row?.evaluated_at ?? null, + surface: row?.surface ?? null, + runControl: row?.run_control ?? null, + permissionProfile: row?.permission_profile ?? null, + traceId: row?.trace_id ?? null, }; } catch { - return { scope: "unknown", lastEvaluatedAt: null }; + return empty; } }; @@ -241,10 +293,7 @@ export async function handleUokStatus( circuitBreaker: cb?.state ?? "closed", failureStreak: cb?.failureStreak ?? 0, }; - const coverageStatus = classifyCoverage( - base, - hasSchemaV2Metadata(meta), - ); + const coverageStatus = classifyCoverage(base, meta); return { ...base, coverageStatus } satisfies GateHealthEntry; }); } catch (err) { diff --git a/src/resources/extensions/sf/sf-db/sf-db-gates.js b/src/resources/extensions/sf/sf-db/sf-db-gates.js index f6a055b00..a7f3b0bc9 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-gates.js +++ b/src/resources/extensions/sf/sf-db/sf-db-gates.js @@ -1,6 +1,7 @@ import { dirname } from "node:path"; import { SF_STALE_STATE, SFError } from "../errors.js"; import { getGateIdsForTurn } from "../gate-registry.js"; +import { uokRunContextToGateColumns } from "../uok/run-context.js"; import { readTraceEvents } from "../uok/trace-writer.js"; import { logWarning } from "../workflow-logger.js"; import { @@ -13,9 +14,20 @@ import { export function insertGateRow(g) { const currentDb = _getAdapter(); if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + // Callers pass canonical camelCase uokContext (the shape returned by + // buildUokRunContext). This module owns the snake_case translation so + // callers don't need to know the column names. uokRunContextToGateColumns + // returns null when the context is invalid/incomplete, which leaves the + // columns NULL — same shape as pre-v66 (legacy) rows, so the classifier + // will mark the row "legacy" or "incomplete" rather than silently passing. + const uokCols = uokRunContextToGateColumns(g.uokContext) ?? null; currentDb - .prepare(`INSERT OR IGNORE INTO quality_gates (milestone_id, slice_id, gate_id, scope, task_id, status) - VALUES (:mid, :sid, :gid, :scope, :tid, :status)`) + .prepare(`INSERT OR IGNORE INTO quality_gates ( + milestone_id, slice_id, gate_id, scope, task_id, status, + surface, run_control, permission_profile, trace_id, parent_trace + ) + VALUES (:mid, :sid, :gid, :scope, :tid, :status, + :surface, :run_control, :permission_profile, :trace_id, :parent_trace)`) .run({ ":mid": g.milestoneId, ":sid": g.sliceId, @@ -23,16 +35,32 @@ export function insertGateRow(g) { ":scope": g.scope, ":tid": g.taskId ?? "", ":status": g.status ?? "pending", + ":surface": uokCols?.surface ?? null, + ":run_control": uokCols?.run_control ?? null, + ":permission_profile": uokCols?.permission_profile ?? null, + ":trace_id": uokCols?.trace_id ?? null, + ":parent_trace": uokCols?.parent_trace ?? null, }); } export function saveGateResult(g) { const currentDb = _getAdapter(); if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + // Schema-v2 metadata is updated only when the caller supplies a valid + // canonical context. Existing rows keep their metadata (or stay legacy/ + // null) when no context is provided. COALESCE semantics on each column + // mean "only overwrite when non-null" — so a legacy row stays legacy + // unless this update brings a full schema-v2 ctx. + const uokCols = uokRunContextToGateColumns(g.uokContext) ?? null; currentDb .prepare(`UPDATE quality_gates SET status = 'complete', verdict = :verdict, rationale = :rationale, - findings = :findings, evaluated_at = :evaluated_at + findings = :findings, evaluated_at = :evaluated_at, + surface = COALESCE(:surface, surface), + run_control = COALESCE(:run_control, run_control), + permission_profile = COALESCE(:permission_profile, permission_profile), + trace_id = COALESCE(:trace_id, trace_id), + parent_trace = COALESCE(:parent_trace, parent_trace) WHERE milestone_id = :mid AND slice_id = :sid AND gate_id = :gid AND task_id = :tid`) .run({ @@ -44,6 +72,11 @@ export function saveGateResult(g) { ":rationale": g.rationale, ":findings": g.findings, ":evaluated_at": new Date().toISOString(), + ":surface": uokCols?.surface ?? null, + ":run_control": uokCols?.run_control ?? null, + ":permission_profile": uokCols?.permission_profile ?? null, + ":trace_id": uokCols?.trace_id ?? null, + ":parent_trace": uokCols?.parent_trace ?? null, }); const outcome = g.verdict === "pass" diff --git a/src/resources/extensions/sf/sf-db/sf-db-schema.js b/src/resources/extensions/sf/sf-db/sf-db-schema.js index 42a376bc0..acdd4ce1d 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-schema.js +++ b/src/resources/extensions/sf/sf-db/sf-db-schema.js @@ -15,7 +15,7 @@ function defaultQueryTimeout(operation, fallbackValue) { } } -const SCHEMA_VERSION = 65; +const SCHEMA_VERSION = 66; function indexExists(db, name) { return !!db .prepare( @@ -1072,6 +1072,14 @@ export function initSchema(db, fileBacked, options = {}) { rationale TEXT NOT NULL DEFAULT '', findings TEXT NOT NULL DEFAULT '', evaluated_at TEXT DEFAULT NULL, + -- Schema v2 metadata (v66): populated by the UOK adapter; NULL on + -- pre-v2 rows. headless-uok-status classifyCoverage reads surface + -- as the schema-v2 indicator. + surface TEXT, + run_control TEXT, + permission_profile TEXT, + trace_id TEXT, + parent_trace TEXT, PRIMARY KEY (milestone_id, slice_id, gate_id, task_id), FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) ) @@ -3399,6 +3407,58 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { if (ok) appliedVersion = 65; } + if (appliedVersion < 66) { + const ok = runMigrationStep("v66", () => { + // Schema v66: UOK schema-v2 metadata on quality_gates. + // + // "Make UOK the SF Control Plane" plan, slice 2. Adds the columns + // that classifyCoverage in headless-uok-status.ts checks for to + // distinguish "ok"/"stale"/"incomplete" from "legacy": + // + // - surface headless | autonomous | interactive + // - run_control bare | supervised | autonomous + // - permission_profile high | medium | low + // - trace_id flow id from the journal + // - parent_trace parent flow id (nested traces) + // + // All columns are nullable so existing rows stay valid; the + // classifier reads them as "legacy" when surface IS NULL. + // + // Idempotent ALTERs: probe via PRAGMA table_info because the + // fresh-DB CREATE path may have already added them. + const cols = new Set( + db + .prepare("PRAGMA table_info(quality_gates)") + .all() + .map((r) => r.name), + ); + if (!cols.has("surface")) { + db.exec("ALTER TABLE quality_gates ADD COLUMN surface TEXT"); + } + if (!cols.has("run_control")) { + db.exec("ALTER TABLE quality_gates ADD COLUMN run_control TEXT"); + } + if (!cols.has("permission_profile")) { + db.exec( + "ALTER TABLE quality_gates ADD COLUMN permission_profile TEXT", + ); + } + if (!cols.has("trace_id")) { + db.exec("ALTER TABLE quality_gates ADD COLUMN trace_id TEXT"); + } + if (!cols.has("parent_trace")) { + db.exec("ALTER TABLE quality_gates ADD COLUMN parent_trace TEXT"); + } + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 66, + ":applied_at": new Date().toISOString(), + }); + }); + if (ok) appliedVersion = 66; + } + // Post-migration assertion: ensure critical tables created by historical // migrations are actually present. If a prior migration claimed success but // the table is missing (e.g., due to a rolled-back transaction that failed diff --git a/src/resources/extensions/sf/tests/headless-uok-status.test.mjs b/src/resources/extensions/sf/tests/headless-uok-status.test.mjs index 3075d56a8..899e83ff4 100644 --- a/src/resources/extensions/sf/tests/headless-uok-status.test.mjs +++ b/src/resources/extensions/sf/tests/headless-uok-status.test.mjs @@ -57,10 +57,27 @@ function formatTable(gates) { // Mirror of headless-uok-status.ts:classifyCoverage. Kept inline so the // test file stays a pure unit test against the same logic shape (the // .ts source isn't directly importable from this .mjs in the SF -// extension test tree). The icon mapping above mirrors coverageIcon. +// extension test tree). Slice 2 changed the second arg from a bool +// to the full meta row so we can distinguish "incomplete" from "ok". const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; -function classifyCoverage(entry, metadataPresent, now = Date.now()) { - if (!metadataPresent) return "legacy"; +function hasSchemaV2Metadata(meta) { + return typeof meta?.surface === "string" && meta.surface.length > 0; +} +function isSchemaV2Complete(meta) { + return ( + typeof meta?.surface === "string" && + meta.surface.length > 0 && + typeof meta?.runControl === "string" && + meta.runControl.length > 0 && + typeof meta?.permissionProfile === "string" && + meta.permissionProfile.length > 0 && + typeof meta?.traceId === "string" && + meta.traceId.length > 0 + ); +} +function classifyCoverage(entry, meta, now = Date.now()) { + if (!hasSchemaV2Metadata(meta)) return "legacy"; + if (!isSchemaV2Complete(meta)) return "incomplete"; if (entry.total === 0) return "stale"; const last = entry.lastEvaluatedAt ? Date.parse(entry.lastEvaluatedAt) @@ -68,6 +85,18 @@ function classifyCoverage(entry, metadataPresent, now = Date.now()) { if (last !== null && now - last > STALE_THRESHOLD_MS) return "stale"; return "ok"; } +const LEGACY_META = { + surface: null, + runControl: null, + permissionProfile: null, + traceId: null, +}; +const COMPLETE_META = { + surface: "headless", + runControl: "supervised", + permissionProfile: "high", + traceId: "flow-123", +}; function makeGate(overrides = {}) { return { @@ -207,47 +236,59 @@ describe("formatTable", () => { }); }); -describe("classifyCoverage (slice 1: legacy / ok / stale)", () => { +describe("classifyCoverage (slice 1+2: legacy / ok / stale / incomplete)", () => { // "now" pinned so stale-threshold math is reproducible across runs. const now = Date.parse("2026-05-14T12:00:00.000Z"); - it("returns_legacy_when_metadata_is_absent", () => { + it("returns_legacy_when_surface_is_null", () => { // All rows in the current quality_gates table predate schema v2. - // Without surface/runControl/permissionProfile columns there is - // no schema-v2 metadata to detect, so every existing row must - // classify as legacy and not warn the operator. + // Without surface set there is no schema-v2 metadata to detect, + // so every existing row must classify as legacy. const status = classifyCoverage( makeGate({ total: 10, lastEvaluatedAt: "2026-05-14T11:00:00.000Z" }), - false, + LEGACY_META, now, ); assert.equal(status, "legacy"); }); - it("returns_legacy_even_when_rows_are_recent_and_metadata_missing", () => { + it("returns_legacy_even_when_rows_are_recent_and_surface_missing", () => { // "Legacy" wins over freshness — we never page operators about // rows that predate the schema-v2 writer. const status = classifyCoverage( makeGate({ total: 5, lastEvaluatedAt: "2026-05-14T11:59:00.000Z" }), - false, + LEGACY_META, now, ); assert.equal(status, "legacy"); }); - it("returns_ok_when_metadata_present_and_recent_runs_exist", () => { + it("returns_ok_when_metadata_is_complete_and_runs_are_recent", () => { const status = classifyCoverage( makeGate({ total: 10, lastEvaluatedAt: "2026-05-14T11:00:00.000Z" }), - true, + COMPLETE_META, now, ); assert.equal(status, "ok"); }); - it("returns_stale_when_metadata_present_but_no_runs_in_window", () => { + it("returns_incomplete_when_surface_is_set_but_other_required_fields_missing", () => { + // Writer started populating surface but forgot runControl — the + // classifier surfaces this so the operator can find the buggy + // writer instead of seeing a silently "ok" row. + const partial = { ...COMPLETE_META, runControl: null }; + const status = classifyCoverage( + makeGate({ total: 10, lastEvaluatedAt: "2026-05-14T11:00:00.000Z" }), + partial, + now, + ); + assert.equal(status, "incomplete"); + }); + + it("returns_stale_when_metadata_complete_but_no_runs_in_window", () => { const status = classifyCoverage( makeGate({ total: 0, lastEvaluatedAt: "2026-05-12T00:00:00.000Z" }), - true, + COMPLETE_META, now, ); assert.equal(status, "stale"); @@ -257,7 +298,7 @@ describe("classifyCoverage (slice 1: legacy / ok / stale)", () => { // Has runs but the most recent one is > 24h old. const status = classifyCoverage( makeGate({ total: 3, lastEvaluatedAt: "2026-05-12T11:59:00.000Z" }), - true, + COMPLETE_META, now, ); assert.equal(status, "stale"); diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs index 88faa1eb2..3d684a8c9 100644 --- a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs +++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs @@ -273,7 +273,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", const version = db .prepare("SELECT MAX(version) AS version FROM schema_version") .get(); - assert.equal(version.version, 65); + assert.equal(version.version, 66); // v61: intent_chapters table exists const chaptersTable = db .prepare( @@ -326,6 +326,22 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", colNames.includes("effort_estimate"), "effort_estimate column should exist after v65 migration", ); + // v66: quality_gates gained UOK schema-v2 metadata columns (UOK + // control-plane plan, slice 2). + const qgColumns = db.prepare("PRAGMA table_info(quality_gates)").all(); + const qgColNames = qgColumns.map((c) => c.name); + for (const col of [ + "surface", + "run_control", + "permission_profile", + "trace_id", + "parent_trace", + ]) { + assert.ok( + qgColNames.includes(col), + `${col} column should exist after v66 migration`, + ); + } const taskSpec = db .prepare( "SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'", @@ -367,11 +383,11 @@ test("openDatabase_v52_db_heals_routing_history_and_auto_start_path_works", () = initRoutingHistory(dbPath); }, "initRoutingHistory should not throw on a v52 DB"); - // Schema should have migrated to v65 (current head) + // Schema should have migrated to v66 (current head) const version = db .prepare("SELECT MAX(version) AS version FROM schema_version") .get(); - assert.equal(version.version, 65); + assert.equal(version.version, 66); }); test("openDatabase_when_fresh_db_supports_schedule_entries", () => { diff --git a/src/resources/extensions/sf/tests/uok-quality-gates-writer.test.mjs b/src/resources/extensions/sf/tests/uok-quality-gates-writer.test.mjs new file mode 100644 index 000000000..b696cbe63 --- /dev/null +++ b/src/resources/extensions/sf/tests/uok-quality-gates-writer.test.mjs @@ -0,0 +1,222 @@ +/** + * uok-quality-gates-writer.test.mjs — verify insertGateRow / saveGateResult + * round-trip canonical uokContext into the quality_gates table after the + * schema v66 migration (UOK control-plane plan, slice 2). + * + * Asserts the writer-side of the slice: a caller that supplies a valid + * uokContext from buildUokRunContext gets schema-v2 columns populated + * on the DB row, which is the prerequisite for the status uok classifier + * to flip its verdict from "legacy" to "ok"/"stale"/"incomplete". + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + closeDatabase, + getDatabase, + insertGateRow, + openDatabase, + saveGateResult, +} from "../sf-db.js"; +import { buildUokRunContext } from "../uok/run-context.js"; + +let projectDir; +let dbPath; + +function seedMilestoneSlice(db, milestoneId, sliceId) { + // quality_gates has a FOREIGN KEY into slices(milestone_id, id), so the + // parent milestone+slice rows have to exist before we insert a gate. + // All other columns have defaults; minimum required is the PK shape. + db.prepare( + `INSERT INTO milestones (id, title, created_at) + VALUES (?, ?, ?)`, + ).run(milestoneId, "Test milestone", "2026-05-14T12:00:00Z"); + db.prepare( + `INSERT INTO slices (milestone_id, id, title, created_at) + VALUES (?, ?, ?, ?)`, + ).run(milestoneId, sliceId, "Test slice", "2026-05-14T12:00:00Z"); +} + +beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "sf-uok-writer-test-")); + mkdirSync(join(projectDir, ".sf"), { recursive: true }); + writeFileSync( + join(projectDir, "package.json"), + JSON.stringify({ name: "test-project" }), + ); + dbPath = join(projectDir, ".sf", "sf.db"); + openDatabase(dbPath); +}); + +afterEach(() => { + closeDatabase(); + rmSync(projectDir, { recursive: true, force: true }); +}); + +describe("insertGateRow writes schema-v2 columns from uokContext", () => { + test("populates surface / run_control / permission_profile / trace_id / parent_trace", () => { + const db = getDatabase(); + seedMilestoneSlice(db, "M001", "S01"); + const ctx = buildUokRunContext({ + surface: "headless", + runControl: "supervised", + permissionProfile: "high", + traceId: "flow-abc", + parentTrace: "flow-root", + }); + insertGateRow({ + milestoneId: "M001", + sliceId: "S01", + gateId: "test-gate", + scope: "slice", + taskId: "T01", + status: "pending", + uokContext: ctx, + }); + const row = db + .prepare( + `SELECT surface, run_control, permission_profile, + trace_id, parent_trace + FROM quality_gates WHERE gate_id = 'test-gate'`, + ) + .get(); + expect(row).toBeTruthy(); + expect(row.surface).toBe("headless"); + expect(row.run_control).toBe("supervised"); + expect(row.permission_profile).toBe("high"); + expect(row.trace_id).toBe("flow-abc"); + expect(row.parent_trace).toBe("flow-root"); + }); + + test("leaves columns NULL when uokContext is absent (legacy path)", () => { + const db = getDatabase(); + seedMilestoneSlice(db, "M002", "S01"); + insertGateRow({ + milestoneId: "M002", + sliceId: "S01", + gateId: "legacy-gate", + scope: "slice", + taskId: "T01", + status: "pending", + }); + const row = db + .prepare( + `SELECT surface, run_control, permission_profile, + trace_id, parent_trace + FROM quality_gates WHERE gate_id = 'legacy-gate'`, + ) + .get(); + expect(row.surface).toBe(null); + expect(row.run_control).toBe(null); + expect(row.permission_profile).toBe(null); + expect(row.trace_id).toBe(null); + expect(row.parent_trace).toBe(null); + }); + + test("leaves columns NULL when uokContext is malformed (invalid surface)", () => { + const db = getDatabase(); + seedMilestoneSlice(db, "M003", "S01"); + insertGateRow({ + milestoneId: "M003", + sliceId: "S01", + gateId: "bad-ctx-gate", + scope: "slice", + taskId: "T01", + status: "pending", + // Bypass buildUokRunContext on purpose to test the adapter's + // last-line-of-defense rejection path. + uokContext: { surface: "rogue", runControl: "supervised" }, + }); + const row = db + .prepare( + `SELECT surface, run_control FROM quality_gates + WHERE gate_id = 'bad-ctx-gate'`, + ) + .get(); + expect(row.surface).toBe(null); + expect(row.run_control).toBe(null); + }); +}); + +describe("saveGateResult merges schema-v2 columns via COALESCE", () => { + test("upgrades a legacy row to schema-v2 when uokContext supplied", () => { + const db = getDatabase(); + seedMilestoneSlice(db, "M004", "S01"); + insertGateRow({ + milestoneId: "M004", + sliceId: "S01", + gateId: "upgrade-gate", + scope: "slice", + taskId: "T01", + status: "pending", + }); + const ctx = buildUokRunContext({ + surface: "autonomous", + runControl: "autonomous", + permissionProfile: "high", + traceId: "flow-upgrade", + }); + saveGateResult({ + milestoneId: "M004", + sliceId: "S01", + gateId: "upgrade-gate", + taskId: "T01", + verdict: "pass", + rationale: "ok", + findings: "", + uokContext: ctx, + }); + const row = db + .prepare( + `SELECT surface, run_control, permission_profile, trace_id, verdict + FROM quality_gates WHERE gate_id = 'upgrade-gate'`, + ) + .get(); + expect(row.verdict).toBe("pass"); + expect(row.surface).toBe("autonomous"); + expect(row.run_control).toBe("autonomous"); + expect(row.permission_profile).toBe("high"); + expect(row.trace_id).toBe("flow-upgrade"); + }); + + test("does NOT overwrite existing schema-v2 metadata when ctx is absent", () => { + const db = getDatabase(); + seedMilestoneSlice(db, "M005", "S01"); + insertGateRow({ + milestoneId: "M005", + sliceId: "S01", + gateId: "preserve-gate", + scope: "slice", + taskId: "T01", + status: "pending", + uokContext: buildUokRunContext({ + surface: "headless", + runControl: "supervised", + permissionProfile: "medium", + traceId: "flow-first", + }), + }); + // Second write has no ctx — COALESCE should keep the original + // values, not blank them. + saveGateResult({ + milestoneId: "M005", + sliceId: "S01", + gateId: "preserve-gate", + taskId: "T01", + verdict: "pass", + rationale: "", + findings: "", + }); + const row = db + .prepare( + `SELECT surface, run_control, permission_profile, trace_id + FROM quality_gates WHERE gate_id = 'preserve-gate'`, + ) + .get(); + expect(row.surface).toBe("headless"); + expect(row.run_control).toBe("supervised"); + expect(row.permission_profile).toBe("medium"); + expect(row.trace_id).toBe("flow-first"); + }); +}); diff --git a/src/resources/extensions/sf/tests/uok-run-context.test.mjs b/src/resources/extensions/sf/tests/uok-run-context.test.mjs new file mode 100644 index 000000000..6d28586f0 --- /dev/null +++ b/src/resources/extensions/sf/tests/uok-run-context.test.mjs @@ -0,0 +1,152 @@ +/** + * uok-run-context.test.mjs — verify the schema-v2 run-context adapter + * (slice 2 of "Make UOK the SF Control Plane"). + */ +import { describe, expect, test } from "vitest"; +import { + buildUokRunContext, + OPTIONAL_UOK_RUN_CONTEXT_KEYS, + REQUIRED_UOK_RUN_CONTEXT_KEYS, + uokRunContextToGateColumns, + VALID_PERMISSION_PROFILES, + VALID_RUN_CONTROLS, + VALID_SURFACES, +} from "../uok/run-context.js"; + +const VALID_BASE = { + surface: "headless", + runControl: "supervised", + permissionProfile: "high", + traceId: "flow-123", +}; + +describe("buildUokRunContext", () => { + test("returns_a_frozen_normalized_context_when_all_required_fields_are_present", () => { + const ctx = buildUokRunContext({ + ...VALID_BASE, + parentTrace: "flow-100", + unitType: "execute-task", + unitId: "M001/S01/T02", + milestoneId: "M001", + sliceId: "S01", + taskId: "T02", + }); + expect(ctx).toBeTruthy(); + expect(Object.isFrozen(ctx)).toBe(true); + expect(ctx.surface).toBe("headless"); + expect(ctx.runControl).toBe("supervised"); + expect(ctx.permissionProfile).toBe("high"); + expect(ctx.traceId).toBe("flow-123"); + expect(ctx.parentTrace).toBe("flow-100"); + expect(ctx.unitType).toBe("execute-task"); + }); + + test("returns_null_when_any_required_key_is_missing", () => { + for (const key of REQUIRED_UOK_RUN_CONTEXT_KEYS) { + const opts = { ...VALID_BASE }; + delete opts[key]; + expect( + buildUokRunContext(opts), + `missing ${key} should return null`, + ).toBe(null); + } + }); + + test("returns_null_on_invalid_surface_runControl_or_permissionProfile", () => { + expect(buildUokRunContext({ ...VALID_BASE, surface: "rogue" })).toBe(null); + expect(buildUokRunContext({ ...VALID_BASE, runControl: "rogue" })).toBe( + null, + ); + expect( + buildUokRunContext({ ...VALID_BASE, permissionProfile: "rogue" }), + ).toBe(null); + }); + + test("treats_empty_string_required_fields_as_missing", () => { + expect(buildUokRunContext({ ...VALID_BASE, surface: " " })).toBe(null); + expect(buildUokRunContext({ ...VALID_BASE, traceId: "" })).toBe(null); + }); + + test("omits_optional_keys_when_empty_or_absent", () => { + const ctx = buildUokRunContext({ + ...VALID_BASE, + parentTrace: "", + unitType: undefined, + }); + expect(ctx).toBeTruthy(); + expect(ctx.parentTrace).toBeUndefined(); + expect(ctx.unitType).toBeUndefined(); + }); + + test("rejects_non_object_input", () => { + expect(buildUokRunContext(null)).toBe(null); + expect(buildUokRunContext("not an object")).toBe(null); + expect(buildUokRunContext(undefined)).toBe(null); + }); + + test("only_recognized_optional_keys_are_kept", () => { + const ctx = buildUokRunContext({ + ...VALID_BASE, + randomField: "ignored", + anotherJunk: "also ignored", + }); + expect(ctx).toBeTruthy(); + for (const key of Object.keys(ctx)) { + expect( + [ + ...REQUIRED_UOK_RUN_CONTEXT_KEYS, + ...OPTIONAL_UOK_RUN_CONTEXT_KEYS, + ].includes(key), + `unexpected key in normalized ctx: ${key}`, + ).toBe(true); + } + }); + + test("all_documented_enum_values_are_accepted", () => { + for (const surface of VALID_SURFACES) { + expect(buildUokRunContext({ ...VALID_BASE, surface })?.surface).toBe( + surface, + ); + } + for (const runControl of VALID_RUN_CONTROLS) { + expect( + buildUokRunContext({ ...VALID_BASE, runControl })?.runControl, + ).toBe(runControl); + } + for (const permissionProfile of VALID_PERMISSION_PROFILES) { + expect( + buildUokRunContext({ ...VALID_BASE, permissionProfile }) + ?.permissionProfile, + ).toBe(permissionProfile); + } + }); +}); + +describe("uokRunContextToGateColumns", () => { + test("translates_camelCase_to_snake_case_column_shape", () => { + const ctx = buildUokRunContext({ + ...VALID_BASE, + parentTrace: "flow-100", + }); + const cols = uokRunContextToGateColumns(ctx); + expect(cols).toEqual({ + surface: "headless", + run_control: "supervised", + permission_profile: "high", + trace_id: "flow-123", + parent_trace: "flow-100", + }); + }); + + test("nulls_parent_trace_when_omitted", () => { + const ctx = buildUokRunContext(VALID_BASE); + const cols = uokRunContextToGateColumns(ctx); + expect(cols.parent_trace).toBe(null); + }); + + test("returns_null_for_invalid_ctx", () => { + expect(uokRunContextToGateColumns(null)).toBe(null); + expect(uokRunContextToGateColumns({})).toBe(null); + expect(uokRunContextToGateColumns({ surface: "headless" })).toBe(null); + }); +}); diff --git a/src/resources/extensions/sf/uok/run-context.js b/src/resources/extensions/sf/uok/run-context.js new file mode 100644 index 000000000..3a3915a78 --- /dev/null +++ b/src/resources/extensions/sf/uok/run-context.js @@ -0,0 +1,164 @@ +/** + * uok/run-context.js — UOK schema-v2 run-context adapter. + * + * Purpose: normalize existing runtime/headless metadata into the shape + * the UOK control plane expects (schema-v2 fields surface, runControl, + * permissionProfile, traceId, parentTrace, plus the already-existing + * unitType/unitId/milestoneId/sliceId). The adapter is intentionally + * thin — it does NOT define a new parallel run model, it just collects + * fields the caller already has and emits one canonical shape. + * + * Slice 2 of "Make UOK the SF Control Plane". The classifier in + * headless-uok-status.ts treats `surface` as the schema-v2 indicator; + * rows with surface != null get classified as ok/stale/incomplete + * based on the other fields, rows with surface == null stay legacy. + * + * Consumer: uok/gate-runner.js (gate-run writes), sf-db-gates.js + * (quality_gates writes), future autonomous/headless adapters. + */ + +/** + * Required keys for a schema-v2 UOK run-context. The classifier flags + * a row "incomplete" when any of these is missing on an otherwise + * schema-v2 row. + */ +export const REQUIRED_UOK_RUN_CONTEXT_KEYS = Object.freeze([ + "surface", + "runControl", + "permissionProfile", + "traceId", +]); + +/** + * Optional keys — present on schema-v2 rows when the caller can + * supply them, but their absence does not flip the classification. + */ +export const OPTIONAL_UOK_RUN_CONTEXT_KEYS = Object.freeze([ + "parentTrace", + "unitType", + "unitId", + "milestoneId", + "sliceId", + "taskId", +]); + +/** + * Allowed `surface` values. Reject anything else at build time so we + * don't end up with `surface = "headless-something"` typos that the + * classifier silently treats as schema-v2. + */ +export const VALID_SURFACES = Object.freeze([ + "headless", + "autonomous", + "interactive", + "hook", +]); + +/** + * Allowed `runControl` values. + * + * - bare One-shot, no orchestrator gates + * - supervised Orchestrator-supervised dispatch with operator gates + * - autonomous Full autonomous loop + * - interactive Operator-driven session + */ +export const VALID_RUN_CONTROLS = Object.freeze([ + "bare", + "supervised", + "autonomous", + "interactive", +]); + +/** + * Allowed `permissionProfile` values. + * + * - high Production / write-allowed + * - medium Dev / write-with-confirmation + * - low Read-only / sandboxed + */ +export const VALID_PERMISSION_PROFILES = Object.freeze([ + "high", + "medium", + "low", +]); + +/** + * Build a schema-v2 UOK run-context from the operator-supplied inputs. + * + * Validates enum fields, normalizes empty strings to undefined, and + * returns a frozen plain object with only the keys the classifier + * understands. Returns null when required fields are missing — callers + * that build a partial ctx should NOT pass that ctx to a gate write, + * otherwise the row lands as `incomplete` in the next status uok read. + * + * Consumer: headless commands, autonomous-loop phases, anywhere a UOK + * gate is about to be written. + * + * @param {object} opts + * @param {string} opts.surface One of VALID_SURFACES. + * @param {string} opts.runControl One of VALID_RUN_CONTROLS. + * @param {string} opts.permissionProfile One of VALID_PERMISSION_PROFILES. + * @param {string} opts.traceId Flow id from the journal. + * @param {string} [opts.parentTrace] Parent flow id (nested). + * @param {string} [opts.unitType] Unit type if dispatching one. + * @param {string} [opts.unitId] Unit id. + * @param {string} [opts.milestoneId] Milestone id. + * @param {string} [opts.sliceId] Slice id. + * @param {string} [opts.taskId] Task id. + * + * @returns {object | null} The normalized context, or null when a + * required field is missing or an enum is invalid. + */ +export function buildUokRunContext(opts) { + if (!opts || typeof opts !== "object") return null; + const required = { + surface: normalizeString(opts.surface), + runControl: normalizeString(opts.runControl), + permissionProfile: normalizeString(opts.permissionProfile), + traceId: normalizeString(opts.traceId), + }; + for (const key of REQUIRED_UOK_RUN_CONTEXT_KEYS) { + if (!required[key]) return null; + } + if (!VALID_SURFACES.includes(required.surface)) return null; + if (!VALID_RUN_CONTROLS.includes(required.runControl)) return null; + if (!VALID_PERMISSION_PROFILES.includes(required.permissionProfile)) { + return null; + } + const ctx = { ...required }; + for (const key of OPTIONAL_UOK_RUN_CONTEXT_KEYS) { + const value = normalizeString(opts[key]); + if (value) ctx[key] = value; + } + return Object.freeze(ctx); +} + +function normalizeString(value) { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + +/** + * Translate a schema-v2 UOK run-context into the column-name shape the + * SQLite writer expects. Snake_case keys, only the columns that exist + * on the quality_gates table (after v66 migration). + * + * Returns null when the context isn't a valid schema-v2 ctx (caller + * should fall through to legacy write, leaving columns NULL). + * + * Consumer: sf-db-gates.js insertGateRow / saveGateResult. + */ +export function uokRunContextToGateColumns(ctx) { + if (!ctx || typeof ctx !== "object") return null; + for (const key of REQUIRED_UOK_RUN_CONTEXT_KEYS) { + if (typeof ctx[key] !== "string" || ctx[key].length === 0) return null; + } + return { + surface: ctx.surface, + run_control: ctx.runControl, + permission_profile: ctx.permissionProfile, + trace_id: ctx.traceId, + parent_trace: ctx.parentTrace ?? null, + }; +}