feat(db): DB-only UAT verdicts — backfill on open, write on ASSESSMENT save, no file fallbacks
- sf-db.js: add backfillUatVerdicts(basePath) that scans ASSESSMENT/UAT_RESULT files for slices with no uat_verdict in DB and populates them on open - dynamic-tools.js: call backfillUatVerdicts after openDatabase succeeds so all 3 repos with existing verdict files are covered on next launch - workflow-tool-executors.js: call setSliceUatVerdict when saving ASSESSMENT at slice scope so future verdicts are written directly to DB - workflow-helpers.js: remove all file fallbacks from checkNeedsRunUat; verdict check is DB-only (backfill guarantees DB is populated on open) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
6c113be473
commit
e58e138457
4 changed files with 123 additions and 22 deletions
|
|
@ -81,7 +81,10 @@ export async function ensureDbOpen(basePath = process.cwd()) {
|
|||
// Open existing DB file (may be at project root for worktrees)
|
||||
if (existsSync(dbPath)) {
|
||||
const opened = db.openDatabase(dbPath);
|
||||
if (opened) setLogBasePath(projectRoot);
|
||||
if (opened) {
|
||||
setLogBasePath(projectRoot);
|
||||
try { db.backfillUatVerdicts(projectRoot); } catch { /* non-fatal */ }
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
// No DB file — create + migrate from Markdown if .sf/ has content
|
||||
|
|
@ -102,6 +105,7 @@ export async function ensureDbOpen(basePath = process.cwd()) {
|
|||
`ensureDbOpen auto-migration failed: ${err.message}`,
|
||||
);
|
||||
}
|
||||
try { db.backfillUatVerdicts(projectRoot); } catch { /* non-fatal */ }
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2114,6 +2114,12 @@ function migrateSchema(db) {
|
|||
"observability_impact",
|
||||
`ALTER TABLE slices ADD COLUMN observability_impact TEXT NOT NULL DEFAULT ''`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
"slices",
|
||||
"uat_verdict",
|
||||
`ALTER TABLE slices ADD COLUMN uat_verdict TEXT DEFAULT NULL`,
|
||||
);
|
||||
ensureColumn(
|
||||
db,
|
||||
"tasks",
|
||||
|
|
@ -4550,6 +4556,95 @@ export function updateSliceStatus(milestoneId, sliceId, status, completedAt) {
|
|||
":id": sliceId,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Store the UAT verdict for a slice. Called when an ASSESSMENT or UAT_RESULT
|
||||
* file is written so the DB is the canonical source for verdict checks.
|
||||
*/
|
||||
export function setSliceUatVerdict(milestoneId, sliceId, verdict) {
|
||||
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
|
||||
currentDb
|
||||
.prepare(
|
||||
`UPDATE slices SET uat_verdict = :verdict WHERE milestone_id = :mid AND id = :sid`,
|
||||
)
|
||||
.run({ ":mid": milestoneId, ":sid": sliceId, ":verdict": verdict });
|
||||
}
|
||||
/**
|
||||
* Returns the stored UAT verdict for a slice, or null if not yet recorded.
|
||||
*/
|
||||
export function getSliceUatVerdict(milestoneId, sliceId) {
|
||||
if (!currentDb) return null;
|
||||
const row = currentDb
|
||||
.prepare(
|
||||
`SELECT uat_verdict FROM slices WHERE milestone_id = :mid AND id = :sid`,
|
||||
)
|
||||
.get({ ":mid": milestoneId, ":sid": sliceId });
|
||||
return row?.uat_verdict ?? null;
|
||||
}
|
||||
/**
|
||||
* Scan existing ASSESSMENT/UAT_RESULT files on disk and populate uat_verdict
|
||||
* for slices that have no verdict recorded in the DB yet.
|
||||
*
|
||||
* Purpose: one-time migration path so that repos with pre-existing verdict
|
||||
* files work without file fallbacks in checkNeedsRunUat — the DB becomes the
|
||||
* sole source of truth immediately after open.
|
||||
*
|
||||
* Consumer: ensureDbOpen (dynamic-tools.js) after openDatabase succeeds.
|
||||
*/
|
||||
export function backfillUatVerdicts(basePath) {
|
||||
if (!currentDb) return;
|
||||
// Find all slices that have no verdict yet
|
||||
const rows = currentDb
|
||||
.prepare(
|
||||
`SELECT milestone_id, id FROM slices WHERE uat_verdict IS NULL`,
|
||||
)
|
||||
.all();
|
||||
if (!rows.length) return;
|
||||
// Extract verdict from content — inline to avoid cross-module import at db layer
|
||||
function parseVerdictFromContent(content) {
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (fmMatch) {
|
||||
const m = fmMatch[1].match(/verdict:\s*([\w-]+)/i);
|
||||
if (m) {
|
||||
let v = m[1].toLowerCase();
|
||||
if (v === "passed") v = "pass";
|
||||
return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const bodyMatch = content.match(/\*\*Verdict:?\*\*\s*(?:✅\s*)?(\w[\w-]*)/i);
|
||||
if (bodyMatch) {
|
||||
let v = bodyMatch[1].toLowerCase();
|
||||
if (v === "passed") v = "pass";
|
||||
return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const stmt = currentDb.prepare(
|
||||
`UPDATE slices SET uat_verdict = :verdict WHERE milestone_id = :mid AND id = :sid`,
|
||||
);
|
||||
for (const row of rows) {
|
||||
const mid = row["milestone_id"];
|
||||
const sid = row["id"];
|
||||
const sliceDir = join(basePath, ".sf", "milestones", mid, "slices", sid);
|
||||
const candidates = [
|
||||
join(sliceDir, `${sid}-ASSESSMENT.md`),
|
||||
join(sliceDir, `${sid}-UAT_RESULT.md`),
|
||||
];
|
||||
for (const candidatePath of candidates) {
|
||||
if (!existsSync(candidatePath)) continue;
|
||||
try {
|
||||
const content = readFileSync(candidatePath, "utf8");
|
||||
const verdict = parseVerdictFromContent(content);
|
||||
if (verdict) {
|
||||
stmt.run({ ":mid": mid, ":sid": sid, ":verdict": verdict });
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export function setTaskSummaryMd(milestoneId, sliceId, taskId, md) {
|
||||
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
|
||||
currentDb
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ import {
|
|||
getSliceTaskCounts,
|
||||
readTransaction,
|
||||
saveGateResult,
|
||||
setSliceUatVerdict,
|
||||
} from "../sf-db.js";
|
||||
import { invalidateStateCache } from "../state.js";
|
||||
import { logError, logWarning } from "../workflow-logger.js";
|
||||
import { extractVerdict } from "../verdict-parser.js";
|
||||
import { handleCompleteMilestone } from "./complete-milestone.js";
|
||||
import { handleCompleteSlice } from "./complete-slice.js";
|
||||
import { handleCompleteTask } from "./complete-task.js";
|
||||
|
|
@ -128,6 +130,22 @@ export async function executeSummarySave(params, basePath = process.cwd()) {
|
|||
},
|
||||
basePath,
|
||||
);
|
||||
// Persist UAT verdict to DB when an ASSESSMENT is saved at slice scope.
|
||||
// This makes checkNeedsRunUat DB-only — no file fallback needed.
|
||||
if (
|
||||
params.artifact_type === "ASSESSMENT" &&
|
||||
params.slice_id &&
|
||||
!params.task_id
|
||||
) {
|
||||
try {
|
||||
const verdict = extractVerdict(params.content);
|
||||
if (verdict) {
|
||||
setSliceUatVerdict(params.milestone_id, params.slice_id, verdict);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — verdict check will still fall through to backfill
|
||||
}
|
||||
}
|
||||
// Terminal transition for research units: After successful RESEARCH artifact save,
|
||||
// research units must terminate or become unable to call planning/milestone-generation tools.
|
||||
// This prevents the dr-repo M008/S01 issue where research continued into planning.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { join } from "node:path";
|
|||
import { loadFile, parseContinue, parseSummary } from "./files.js";
|
||||
import { resolveSliceFile } from "./paths.js";
|
||||
import { isDbAvailable } from "./sf-db.js";
|
||||
import { hasVerdict } from "./verdict-parser.js";
|
||||
|
||||
/**
|
||||
* Escape regex special characters for safe use in RegExp.
|
||||
|
|
@ -95,14 +94,14 @@ export async function checkNeedsReassessment(base, mid, _state, _prefs) {
|
|||
* - All slices are done (milestone complete path)
|
||||
* - uat_dispatch preference is not enabled
|
||||
* - No UAT file exists for the slice
|
||||
* - UAT result file already exists (idempotent)
|
||||
* - uat_verdict already recorded in DB (backfilled on open from any existing ASSESSMENT/UAT_RESULT)
|
||||
*/
|
||||
export async function checkNeedsRunUat(base, mid, _state, prefs) {
|
||||
// Check if UAT dispatch is enabled
|
||||
if (!prefs?.uat_dispatch) return null;
|
||||
|
||||
try {
|
||||
const { getMilestoneSlices } = await import("./sf-db.js");
|
||||
const { getMilestoneSlices, getSliceUatVerdict } = await import("./sf-db.js");
|
||||
if (isDbAvailable()) {
|
||||
const slices = getMilestoneSlices(mid);
|
||||
if (slices.length > 0) {
|
||||
|
|
@ -112,25 +111,10 @@ export async function checkNeedsRunUat(base, mid, _state, prefs) {
|
|||
const lastCompleted = completedSlices[completedSlices.length - 1];
|
||||
const uatFile = resolveSliceFile(base, mid, lastCompleted.id, "UAT");
|
||||
if (!uatFile || !existsSync(uatFile)) return null;
|
||||
const resultFile = resolveSliceFile(
|
||||
base,
|
||||
mid,
|
||||
lastCompleted.id,
|
||||
"UAT_RESULT",
|
||||
);
|
||||
if (resultFile && existsSync(resultFile)) return null;
|
||||
// Also treat an ASSESSMENT file with a verdict as a completed UAT result.
|
||||
const assessmentFile = resolveSliceFile(
|
||||
base,
|
||||
mid,
|
||||
lastCompleted.id,
|
||||
"ASSESSMENT",
|
||||
);
|
||||
if (assessmentFile && existsSync(assessmentFile)) {
|
||||
const assessContent = await loadFile(assessmentFile);
|
||||
if (assessContent && hasVerdict(assessContent)) return null;
|
||||
}
|
||||
// DB-only: verdict backfilled on open from any existing ASSESSMENT/UAT_RESULT file
|
||||
if (getSliceUatVerdict(mid, lastCompleted.id)) return null;
|
||||
const uatContent = await loadFile(uatFile);
|
||||
const { hasVerdict } = await import("./verdict-parser.js");
|
||||
const uatType = hasVerdict(uatContent) ? "verdict" : "narrative";
|
||||
return { sliceId: lastCompleted.id, uatType };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue