fix(gsd): heal legacy task arrays and evidence rows (#4027)
This commit is contained in:
parent
1d8e7c95ff
commit
b13c980ecc
2 changed files with 126 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue