fix(gsd): heal legacy task arrays and evidence rows (#4027)

This commit is contained in:
mastertyko 2026-04-13 14:07:26 +02:00 committed by GitHub
parent 1d8e7c95ff
commit b13c980ecc
2 changed files with 126 additions and 5 deletions

View file

@ -163,6 +163,29 @@ function openRawDb(path: string): unknown {
const SCHEMA_VERSION = 14;
function indexExists(db: DbAdapter, name: string): boolean {
return !!db.prepare(
"SELECT 1 as present FROM sqlite_master WHERE type = 'index' AND name = ?",
).get(name);
}
function dedupeVerificationEvidenceRows(db: DbAdapter): void {
db.exec(`
DELETE FROM verification_evidence
WHERE rowid NOT IN (
SELECT MIN(rowid)
FROM verification_evidence
GROUP BY task_id, slice_id, milestone_id, command, verdict
)
`);
}
function ensureVerificationEvidenceDedupIndex(db: DbAdapter): void {
if (indexExists(db, "idx_verification_evidence_dedup")) return;
dedupeVerificationEvidenceRows(db);
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_verification_evidence_dedup ON verification_evidence(task_id, slice_id, milestone_id, command, verdict)");
}
function initSchema(db: DbAdapter, fileBacked: boolean): void {
if (fileBacked) db.exec("PRAGMA journal_mode=WAL");
if (fileBacked) db.exec("PRAGMA busy_timeout = 5000");
@ -410,7 +433,7 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void {
db.exec("CREATE INDEX IF NOT EXISTS idx_milestones_status ON milestones(status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_quality_gates_pending ON quality_gates(milestone_id, slice_id, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_verification_evidence_task ON verification_evidence(milestone_id, slice_id, task_id)");
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_verification_evidence_dedup ON verification_evidence(task_id, slice_id, milestone_id, command, verdict)");
ensureVerificationEvidenceDedupIndex(db);
// v14 index — slice dependency lookups
db.exec("CREATE INDEX IF NOT EXISTS idx_slice_deps_target ON slice_dependencies(milestone_id, depends_on_slice_id)");
@ -743,7 +766,7 @@ function migrateSchema(db: DbAdapter): void {
db.exec("CREATE INDEX IF NOT EXISTS idx_milestones_status ON milestones(status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_quality_gates_pending ON quality_gates(milestone_id, slice_id, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_verification_evidence_task ON verification_evidence(milestone_id, slice_id, task_id)");
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_verification_evidence_dedup ON verification_evidence(task_id, slice_id, milestone_id, command, verdict)");
ensureVerificationEvidenceDedupIndex(db);
db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({
":version": 13,
":applied_at": new Date().toISOString(),
@ -1542,6 +1565,30 @@ export interface TaskRow {
}
function rowToTask(row: Record<string, unknown>): TaskRow {
const parseTaskArray = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === "string");
}
if (typeof value !== "string") return [];
const trimmed = value.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.filter((entry): entry is string => typeof entry === "string");
}
if (typeof parsed === "string" && parsed.trim()) {
return [parsed.trim()];
}
} catch {
// Older/corrupt DB rows may contain raw comma-separated paths instead of JSON arrays.
}
return trimmed.split(",").map((entry) => entry.trim()).filter(Boolean);
};
return {
milestone_id: row["milestone_id"] as string,
slice_id: row["slice_id"] as string,
@ -1561,10 +1608,10 @@ function rowToTask(row: Record<string, unknown>): TaskRow {
full_summary_md: row["full_summary_md"] as string,
description: (row["description"] as string) ?? "",
estimate: (row["estimate"] as string) ?? "",
files: JSON.parse((row["files"] as string) || "[]"),
files: parseTaskArray(row["files"]),
verify: (row["verify"] as string) ?? "",
inputs: JSON.parse((row["inputs"] as string) || "[]"),
expected_output: JSON.parse((row["expected_output"] as string) || "[]"),
inputs: parseTaskArray(row["inputs"]),
expected_output: parseTaskArray(row["expected_output"]),
observability_impact: (row["observability_impact"] as string) ?? "",
full_plan_md: (row["full_plan_md"] as string) ?? "",
sequence: (row["sequence"] as number) ?? 0,

View file

@ -15,6 +15,7 @@ import {
getRequirementById,
getActiveDecisions,
getActiveRequirements,
getTask,
transaction,
_getAdapter,
_resetProvider,
@ -359,6 +360,79 @@ describe('gsd-db', () => {
closeDatabase();
});
test('gsd-db: recreates missing verification evidence dedup index after removing duplicate rows', () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
let adapter = _getAdapter()!;
adapter.prepare("INSERT INTO milestones (id, created_at) VALUES (?, '')").run('M001');
adapter.prepare("INSERT INTO slices (milestone_id, id, created_at) VALUES (?, ?, '')").run('M001', 'S01');
adapter.prepare("INSERT INTO tasks (milestone_id, slice_id, id) VALUES (?, ?, ?)").run('M001', 'S01', 'T01');
adapter.exec('DROP INDEX IF EXISTS idx_verification_evidence_dedup');
const insertEvidence = adapter.prepare(
`INSERT INTO verification_evidence (
task_id, slice_id, milestone_id, command, exit_code, verdict, duration_ms, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
);
insertEvidence.run('T01', 'S01', 'M001', 'npm test', 1, 'fail', 125, '2026-04-12T00:00:00.000Z');
insertEvidence.run('T01', 'S01', 'M001', 'npm test', 1, 'fail', 125, '2026-04-12T00:00:01.000Z');
insertEvidence.run('T01', 'S01', 'M001', 'npm run lint', 0, 'pass', 90, '2026-04-12T00:00:02.000Z');
closeDatabase();
assert.equal(openDatabase(dbPath), true, 'openDatabase should repair legacy duplicate evidence rows');
adapter = _getAdapter()!;
const countRow = adapter.prepare(
`SELECT count(*) as cnt
FROM verification_evidence
WHERE task_id = ? AND slice_id = ? AND milestone_id = ? AND command = ? AND verdict = ?`,
).get('T01', 'S01', 'M001', 'npm test', 'fail');
assert.equal(countRow?.['cnt'], 1, 'duplicate verification evidence rows should be deduplicated before index creation');
const indexRow = adapter.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_verification_evidence_dedup'",
).get();
assert.equal(indexRow?.['name'], 'idx_verification_evidence_dedup', 'dedup index should be recreated on reopen');
cleanup(dbPath);
});
test('gsd-db: rowToTask tolerates legacy comma-separated task arrays', () => {
openDatabase(':memory:');
const adapter = _getAdapter()!;
adapter.prepare("INSERT INTO milestones (id, created_at) VALUES (?, '')").run('M001');
adapter.prepare("INSERT INTO slices (milestone_id, id, created_at) VALUES (?, ?, '')").run('M001', 'S01');
adapter.prepare(
`INSERT INTO tasks (
milestone_id, slice_id, id, key_files, key_decisions, files, inputs, expected_output
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
'M001',
'S01',
'T01',
'[]',
'[]',
'tests/test_verify.py, config.yaml, configs/roster_2026-05-11.yaml',
'tests/test_verify.py',
'reports/summary.md, artifacts/output.json',
);
const task = getTask('M001', 'S01', 'T01');
assert.ok(task, 'task should load successfully from DB');
assert.deepEqual(task?.files, [
'tests/test_verify.py',
'config.yaml',
'configs/roster_2026-05-11.yaml',
]);
assert.deepEqual(task?.inputs, ['tests/test_verify.py']);
assert.deepEqual(task?.expected_output, ['reports/summary.md', 'artifacts/output.json']);
closeDatabase();
});
test('gsd-db: query wrappers return null/empty when DB unavailable', () => {
// Ensure DB is closed
closeDatabase();