diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index d0120b65d..f6d379048 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -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): 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): 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, diff --git a/src/resources/extensions/gsd/tests/gsd-db.test.ts b/src/resources/extensions/gsd/tests/gsd-db.test.ts index 557b5830c..097ea7151 100644 --- a/src/resources/extensions/gsd/tests/gsd-db.test.ts +++ b/src/resources/extensions/gsd/tests/gsd-db.test.ts @@ -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();