Second slice of "Make UOK the SF Control Plane". Wires the DB-level
capability for schema-v2 gate metadata so future callers can flip
quality_gates rows from "legacy" to "ok"/"stale"/"incomplete" by
passing a canonical uokContext. No production caller passes ctx yet —
slice 3 wires producers (headless triage --apply, phases-pre-dispatch,
phases-unit).
Schema migration v66 (SCHEMA_VERSION bumped 65 → 66):
- quality_gates gains 5 nullable columns: surface, run_control,
permission_profile, trace_id, parent_trace.
- Idempotent ALTERs via PRAGMA table_info probes — fresh-DB CREATE
path already includes the columns; migration only ALTERs older DBs.
- Existing rows keep NULL across the new columns, so classifyCoverage
in headless-uok-status reads them as "legacy" — no day-one warning
flood.
New adapter src/resources/extensions/sf/uok/run-context.js:
- buildUokRunContext(opts) validates and normalizes the canonical
camelCase shape: surface, runControl, permissionProfile, traceId
(required), plus parentTrace, unitType, unitId, milestoneId,
sliceId, taskId (optional). Frozen on success, null on any invalid
or missing required field.
- VALID_SURFACES / VALID_RUN_CONTROLS / VALID_PERMISSION_PROFILES
enums reject typos at build time so we don't get silent schema-v2
rows with garbage in the enum columns.
- uokRunContextToGateColumns(ctx) translates camelCase → snake_case
column shape used by sf-db-gates writers.
Writer chain (sf-db-gates.js):
- insertGateRow now imports uokRunContextToGateColumns and translates
g.uokContext (canonical camelCase) to the SQL column shape. Callers
pass canonical ctx, the DB writer owns translation. NULL on legacy
callers, NULL on malformed ctx.
- saveGateResult mirrors the same translation; uses COALESCE(:col,
col) so a missing ctx on a follow-up update preserves the row's
existing schema-v2 metadata instead of nulling it.
Reader chain (headless-uok-status.ts):
- getGateMeta SELECTs surface, run_control, permission_profile,
trace_id alongside scope and evaluated_at. ORDER BY uses
"evaluated_at IS NULL, evaluated_at DESC" for cross-SQLite safety
(NULLS LAST is not portable).
- classifyCoverage signature changed from (entry, metadataPresent:
bool) to (entry, meta: GateMetadataRow). Returns "incomplete" when
surface is set but runControl/permissionProfile/traceId missing —
surfaces buggy writers instead of silently classifying as "ok".
Tests:
- uok-run-context.test.mjs (12 tests): adapter validation, enum
rejection, optional-field handling, frozen output, column
translation.
- uok-quality-gates-writer.test.mjs (5 tests): real DB round-trip
proving insertGateRow + saveGateResult populate schema-v2 columns
from canonical camelCase ctx, leave NULL on legacy/malformed,
and preserve existing metadata via COALESCE on no-ctx updates.
- headless-uok-status.test.mjs adjusted: classifier now takes
GateMetadataRow; added test for "incomplete" classification.
- sf-db-migration.test.mjs bumped expected version 65 → 66 and
asserts the 5 new quality_gates columns exist.
Full SF suite: 1678/1678 ✓ (+17 from slice 2 + +9 from slice 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
462 lines
14 KiB
JavaScript
462 lines
14 KiB
JavaScript
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 {
|
|
_getAdapter,
|
|
getDbPath,
|
|
rowToGate,
|
|
transaction,
|
|
} from "./sf-db-core.js";
|
|
|
|
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,
|
|
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,
|
|
":gid": g.gateId,
|
|
":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,
|
|
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({
|
|
":mid": g.milestoneId,
|
|
":sid": g.sliceId,
|
|
":gid": g.gateId,
|
|
":tid": g.taskId ?? "",
|
|
":verdict": g.verdict,
|
|
":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"
|
|
? "pass"
|
|
: g.verdict === "omitted"
|
|
? "manual-attention"
|
|
: "fail";
|
|
insertGateRun({
|
|
traceId: `quality-gate:${g.milestoneId}:${g.sliceId}`,
|
|
turnId: `gate:${g.gateId}:${g.taskId ?? "slice"}`,
|
|
gateId: g.gateId,
|
|
gateType: "quality-gate",
|
|
milestoneId: g.milestoneId,
|
|
sliceId: g.sliceId,
|
|
taskId: g.taskId ?? undefined,
|
|
outcome,
|
|
failureClass:
|
|
outcome === "fail"
|
|
? "verification"
|
|
: outcome === "manual-attention"
|
|
? "manual-attention"
|
|
: "none",
|
|
rationale: g.rationale,
|
|
findings: g.findings,
|
|
attempt: 1,
|
|
maxAttempts: 1,
|
|
retryable: false,
|
|
evaluatedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
export function getPendingGates(milestoneId, sliceId, scope) {
|
|
const currentDb = _getAdapter();
|
|
if (!currentDb) return [];
|
|
const sql = scope
|
|
? `SELECT * FROM quality_gates WHERE milestone_id = :mid AND slice_id = :sid AND scope = :scope AND status = 'pending'`
|
|
: `SELECT * FROM quality_gates WHERE milestone_id = :mid AND slice_id = :sid AND status = 'pending'`;
|
|
const params = {
|
|
":mid": milestoneId,
|
|
":sid": sliceId,
|
|
};
|
|
if (scope) params[":scope"] = scope;
|
|
return currentDb.prepare(sql).all(params).map(rowToGate);
|
|
}
|
|
|
|
export function getGateResults(milestoneId, sliceId, scope) {
|
|
const currentDb = _getAdapter();
|
|
if (!currentDb) return [];
|
|
const sql = scope
|
|
? `SELECT * FROM quality_gates WHERE milestone_id = :mid AND slice_id = :sid AND scope = :scope`
|
|
: `SELECT * FROM quality_gates WHERE milestone_id = :mid AND slice_id = :sid`;
|
|
const params = {
|
|
":mid": milestoneId,
|
|
":sid": sliceId,
|
|
};
|
|
if (scope) params[":scope"] = scope;
|
|
return currentDb.prepare(sql).all(params).map(rowToGate);
|
|
}
|
|
|
|
export function markAllGatesOmitted(milestoneId, sliceId) {
|
|
const currentDb = _getAdapter();
|
|
if (!currentDb) return;
|
|
currentDb
|
|
.prepare(`UPDATE quality_gates SET status = 'omitted', verdict = 'omitted', evaluated_at = :now
|
|
WHERE milestone_id = :mid AND slice_id = :sid AND status = 'pending'`)
|
|
.run({
|
|
":mid": milestoneId,
|
|
":sid": sliceId,
|
|
":now": new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
export function getPendingSliceGateCount(milestoneId, sliceId) {
|
|
const currentDb = _getAdapter();
|
|
if (!currentDb) return 0;
|
|
const row = currentDb
|
|
.prepare(`SELECT COUNT(*) as cnt FROM quality_gates
|
|
WHERE milestone_id = :mid AND slice_id = :sid AND scope = 'slice' AND status = 'pending'`)
|
|
.get({ ":mid": milestoneId, ":sid": sliceId });
|
|
return row ? row["cnt"] : 0;
|
|
}
|
|
|
|
export function getPendingGatesForTurn(milestoneId, sliceId, turn, taskId) {
|
|
const currentDb = _getAdapter();
|
|
if (!currentDb) return [];
|
|
const ids = getGateIdsForTurn(turn);
|
|
if (ids.size === 0) return [];
|
|
const idList = [...ids];
|
|
const placeholders = idList.map((_, i) => `:gid${i}`).join(",");
|
|
const params = {
|
|
":mid": milestoneId,
|
|
":sid": sliceId,
|
|
};
|
|
idList.forEach((id, i) => {
|
|
params[`:gid${i}`] = id;
|
|
});
|
|
let sql = `SELECT * FROM quality_gates
|
|
WHERE milestone_id = :mid AND slice_id = :sid
|
|
AND status = 'pending'
|
|
AND gate_id IN (${placeholders})`;
|
|
if (taskId !== undefined) {
|
|
sql += ` AND task_id = :tid`;
|
|
params[":tid"] = taskId;
|
|
}
|
|
return currentDb.prepare(sql).all(params).map(rowToGate);
|
|
}
|
|
|
|
export function getPendingGateCountForTurn(milestoneId, sliceId, turn) {
|
|
return getPendingGatesForTurn(milestoneId, sliceId, turn).length;
|
|
}
|
|
|
|
export function insertGateRun(_entry) {
|
|
// no-op: gate runs now written to JSONL trace files
|
|
}
|
|
|
|
export function upsertTurnGitTransaction(_entry) {
|
|
// no-op: turn git transactions now written to JSONL audit events
|
|
}
|
|
|
|
export function getGateRunStats(gateId, windowHours = 24) {
|
|
try {
|
|
const currentPath = getDbPath();
|
|
const basePath =
|
|
currentPath && currentPath !== ":memory:"
|
|
? dirname(dirname(currentPath))
|
|
: process.cwd();
|
|
const events = readTraceEvents(basePath, "gate_run", windowHours).filter(
|
|
(e) => e.gateId === gateId,
|
|
);
|
|
if (events.length > 0) {
|
|
const stats = {
|
|
total: events.length,
|
|
pass: 0,
|
|
fail: 0,
|
|
retry: 0,
|
|
manualAttention: 0,
|
|
lastEvaluatedAt: null,
|
|
};
|
|
for (const e of events) {
|
|
if (e.outcome === "pass") stats.pass++;
|
|
else if (e.outcome === "fail") stats.fail++;
|
|
else if (e.outcome === "retry") stats.retry++;
|
|
else if (e.outcome === "manual-attention") stats.manualAttention++;
|
|
if (
|
|
!stats.lastEvaluatedAt ||
|
|
(e.evaluatedAt ?? e.ts) > stats.lastEvaluatedAt
|
|
)
|
|
stats.lastEvaluatedAt = e.evaluatedAt ?? e.ts;
|
|
}
|
|
return stats;
|
|
}
|
|
// Fall back to quality_gates DB when no trace events found (e.g. after trace rotation)
|
|
const db = _getAdapter();
|
|
if (db) {
|
|
const cutoff = new Date(
|
|
Date.now() - windowHours * 3600 * 1000,
|
|
).toISOString();
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT verdict, evaluated_at FROM quality_gates
|
|
WHERE gate_id = ? AND evaluated_at >= ? AND verdict != '' AND verdict != 'omitted'
|
|
ORDER BY evaluated_at DESC`,
|
|
)
|
|
.all(gateId, cutoff);
|
|
if (rows.length > 0) {
|
|
const stats = {
|
|
total: rows.length,
|
|
pass: 0,
|
|
fail: 0,
|
|
retry: 0,
|
|
manualAttention: 0,
|
|
lastEvaluatedAt: rows[0].evaluated_at,
|
|
};
|
|
for (const r of rows) {
|
|
if (r.verdict === "pass") stats.pass++;
|
|
else if (r.verdict === "fail" || r.verdict === "flag") stats.fail++;
|
|
else if (r.verdict === "manual-attention") stats.manualAttention++;
|
|
}
|
|
return stats;
|
|
}
|
|
}
|
|
return {
|
|
total: 0,
|
|
pass: 0,
|
|
fail: 0,
|
|
retry: 0,
|
|
manualAttention: 0,
|
|
lastEvaluatedAt: null,
|
|
};
|
|
} catch {
|
|
return {
|
|
total: 0,
|
|
pass: 0,
|
|
fail: 0,
|
|
retry: 0,
|
|
manualAttention: 0,
|
|
lastEvaluatedAt: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
export function getGateCircuitBreaker(gateId) {
|
|
const currentDb = _getAdapter();
|
|
if (!currentDb) {
|
|
return {
|
|
gateId,
|
|
state: "closed",
|
|
failureStreak: 0,
|
|
lastFailureAt: null,
|
|
openedAt: null,
|
|
halfOpenAttempts: 0,
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
try {
|
|
const row = currentDb
|
|
.prepare(
|
|
`SELECT gate_id, state, failure_streak, last_failure_at, opened_at, half_open_attempts, updated_at
|
|
FROM gate_circuit_breakers
|
|
WHERE gate_id = :gate_id`,
|
|
)
|
|
.get({ ":gate_id": gateId });
|
|
if (!row) {
|
|
return {
|
|
gateId,
|
|
state: "closed",
|
|
failureStreak: 0,
|
|
lastFailureAt: null,
|
|
openedAt: null,
|
|
halfOpenAttempts: 0,
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
return {
|
|
gateId: row.gate_id,
|
|
state: row.state,
|
|
failureStreak: row.failure_streak ?? 0,
|
|
lastFailureAt: row.last_failure_at ?? null,
|
|
openedAt: row.opened_at ?? null,
|
|
halfOpenAttempts: row.half_open_attempts ?? 0,
|
|
updatedAt: row.updated_at ?? null,
|
|
};
|
|
} catch {
|
|
return {
|
|
gateId,
|
|
state: "closed",
|
|
failureStreak: 0,
|
|
lastFailureAt: null,
|
|
openedAt: null,
|
|
halfOpenAttempts: 0,
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
export function updateGateCircuitBreaker(gateId, updates) {
|
|
const currentDb = _getAdapter();
|
|
if (!currentDb) return;
|
|
currentDb
|
|
.prepare(
|
|
`INSERT INTO gate_circuit_breakers (
|
|
gate_id, state, failure_streak, last_failure_at, opened_at, half_open_attempts, updated_at
|
|
) VALUES (
|
|
:gate_id, :state, :failure_streak, :last_failure_at, :opened_at, :half_open_attempts, :updated_at
|
|
)
|
|
ON CONFLICT(gate_id) DO UPDATE SET
|
|
state = excluded.state,
|
|
failure_streak = excluded.failure_streak,
|
|
last_failure_at = COALESCE(excluded.last_failure_at, gate_circuit_breakers.last_failure_at),
|
|
opened_at = COALESCE(excluded.opened_at, gate_circuit_breakers.opened_at),
|
|
half_open_attempts = excluded.half_open_attempts,
|
|
updated_at = excluded.updated_at`,
|
|
)
|
|
.run({
|
|
":gate_id": gateId,
|
|
":state": updates.state ?? "closed",
|
|
":failure_streak": updates.failureStreak ?? 0,
|
|
":last_failure_at": updates.lastFailureAt ?? null,
|
|
":opened_at": updates.openedAt ?? null,
|
|
":half_open_attempts": updates.halfOpenAttempts ?? 0,
|
|
":updated_at": new Date().toISOString(),
|
|
});
|
|
return { total: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 };
|
|
}
|
|
|
|
export function getGateLatencyStats(gateId, windowHours = 24) {
|
|
try {
|
|
const currentPath = getDbPath();
|
|
const basePath =
|
|
currentPath && currentPath !== ":memory:"
|
|
? dirname(dirname(currentPath))
|
|
: process.cwd();
|
|
const durations = readTraceEvents(basePath, "gate_run", windowHours)
|
|
.filter((e) => e.gateId === gateId && typeof e.durationMs === "number")
|
|
.map((e) => e.durationMs)
|
|
.sort((a, b) => a - b);
|
|
if (durations.length === 0)
|
|
return {
|
|
p50: null,
|
|
p95: null,
|
|
count: 0,
|
|
total: 0,
|
|
avgMs: 0,
|
|
p50Ms: 0,
|
|
p95Ms: 0,
|
|
maxMs: 0,
|
|
};
|
|
const p50Ms = durations[Math.floor(durations.length * 0.5)] ?? 0;
|
|
const p95Ms = durations[Math.floor(durations.length * 0.95)] ?? 0;
|
|
const maxMs = durations[durations.length - 1] ?? 0;
|
|
const avgMs = Math.round(
|
|
durations.reduce((s, v) => s + v, 0) / durations.length,
|
|
);
|
|
return {
|
|
p50: p50Ms,
|
|
p95: p95Ms,
|
|
count: durations.length,
|
|
total: durations.length,
|
|
avgMs,
|
|
p50Ms,
|
|
p95Ms,
|
|
maxMs,
|
|
};
|
|
} catch {
|
|
return {
|
|
p50: null,
|
|
p95: null,
|
|
count: 0,
|
|
total: 0,
|
|
avgMs: 0,
|
|
p50Ms: 0,
|
|
p95Ms: 0,
|
|
maxMs: 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
export function getDistinctGateIds() {
|
|
try {
|
|
const currentPath = getDbPath();
|
|
const basePath =
|
|
currentPath && currentPath !== ":memory:"
|
|
? dirname(dirname(currentPath))
|
|
: process.cwd();
|
|
const events = readTraceEvents(basePath, "gate_run", 24 * 30); // 30 days
|
|
const traceIds = [...new Set(events.map((e) => e.gateId).filter(Boolean))];
|
|
if (traceIds.length > 0) return traceIds;
|
|
// Fall back to quality_gates DB when no trace events found
|
|
const db = _getAdapter();
|
|
if (db) {
|
|
const rows = db
|
|
.prepare(
|
|
"SELECT DISTINCT gate_id FROM quality_gates WHERE gate_id != '' ORDER BY gate_id",
|
|
)
|
|
.all();
|
|
return rows.map((r) => r.gate_id);
|
|
}
|
|
return [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function upsertQualityGate(g) {
|
|
const currentDb = _getAdapter();
|
|
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
|
|
currentDb
|
|
.prepare(`INSERT OR REPLACE INTO quality_gates
|
|
(milestone_id, slice_id, gate_id, scope, task_id, status, verdict, rationale, findings, evaluated_at)
|
|
VALUES (:mid, :sid, :gid, :scope, :tid, :status, :verdict, :rationale, :findings, :evaluated_at)`)
|
|
.run({
|
|
":mid": g.milestoneId,
|
|
":sid": g.sliceId,
|
|
":gid": g.gateId,
|
|
":scope": g.scope,
|
|
":tid": g.taskId,
|
|
":status": g.status,
|
|
":verdict": g.verdict,
|
|
":rationale": g.rationale,
|
|
":findings": g.findings,
|
|
":evaluated_at": g.evaluatedAt,
|
|
});
|
|
}
|