From be4037be90a642c982a0567de8132c3bf2ba13af Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 25 Mar 2026 02:06:47 -0400 Subject: [PATCH] fix: reconcile disk milestones missing from DB in deriveStateFromDb (#2416) (#2422) After migration to DB-backed state, milestones on disk that were never imported into the DB became invisible. deriveStateFromDb now scans the milestones directory and injects synthetic entries for any disk-only milestones, then re-sorts to maintain canonical order. Fixes #2416 Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/state.ts | 22 ++++ .../derive-state-db-disk-reconcile.test.ts | 121 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/derive-state-db-disk-reconcile.test.ts diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index a3694c61d..32d2d50e0 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -277,6 +277,28 @@ export async function deriveStateFromDb(basePath: string): Promise { } if (synced) allMilestones = getAllMilestones(); + // Reconcile: discover milestones that exist on disk but are missing from + // the DB. This happens when milestones were created before the DB migration + // or were manually added to the filesystem. Without this, disk-only + // milestones are invisible after migration (#2416). + const dbMilestoneIds = new Set(allMilestones.map(m => m.id)); + const diskMilestoneIds = findMilestoneIds(basePath); + for (const diskId of diskMilestoneIds) { + if (!dbMilestoneIds.has(diskId)) { + // Synthesize a minimal MilestoneRow for the disk-only milestone. + // Title and status will be resolved from disk files in the loop below. + allMilestones.push({ + id: diskId, + title: diskId, + status: 'active', + depends_on: [] as string[], + created_at: new Date().toISOString(), + } as MilestoneRow); + } + } + // Re-sort so milestones are in canonical order after injection + allMilestones.sort((a, b) => milestoneIdSort(a.id, b.id)); + // Parallel worker isolation: when locked, filter to just the locked milestone const milestoneLock = process.env.GSD_MILESTONE_LOCK; const milestones = milestoneLock diff --git a/src/resources/extensions/gsd/tests/derive-state-db-disk-reconcile.test.ts b/src/resources/extensions/gsd/tests/derive-state-db-disk-reconcile.test.ts new file mode 100644 index 000000000..a30251b3b --- /dev/null +++ b/src/resources/extensions/gsd/tests/derive-state-db-disk-reconcile.test.ts @@ -0,0 +1,121 @@ +/** + * derive-state-db-disk-reconcile.test.ts — #2416 + * + * After migration to DB-backed state, milestones that exist on disk + * (in .gsd/milestones/) but were never imported into the DB become + * invisible to deriveStateFromDb(). This test verifies that + * deriveStateFromDb reconciles disk milestones with DB milestones. + */ + +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { deriveStateFromDb, invalidateStateCache } from "../state.ts"; +import { + openDatabase, + closeDatabase, + insertMilestone, + insertSlice, + insertTask, +} from "../gsd-db.ts"; +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), "gsd-disk-reconcile-")); + mkdirSync(join(base, ".gsd", "milestones"), { recursive: true }); + return base; +} + +function writeFile(base: string, relativePath: string, content: string): void { + const full = join(base, ".gsd", relativePath); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); +} + +function cleanup(base: string): void { + rmSync(base, { recursive: true, force: true }); +} + +const CONTEXT_CONTENT = `# M002: Disk-Only Milestone + +This milestone exists on disk but not in the DB. + +## Must-Haves +- Something important +`; + +const ROADMAP_CONTENT = `# M002: Disk-Only Milestone + +**Vision:** Test disk reconciliation. + +## Slices + +- [ ] **S01: First Slice** \`risk:low\` \`depends:[]\` + > Do something. +`; + +async function main(): Promise { + console.log("\n=== #2416: deriveStateFromDb reconciles disk milestones ==="); + + // Set up: M001 in DB, M002 on disk only + const base = createFixtureBase(); + const dbPath = join(base, ".gsd", "gsd.db"); + + try { + openDatabase(dbPath); + + // M001 is in the DB with a complete status + insertMilestone({ id: "M001", title: "M001: DB Milestone", status: "complete", depends_on: [] }); + insertSlice({ id: "S01", milestoneId: "M001", title: "S01: Done Slice", status: "complete", depends: [] }); + + // Write M001 summary on disk (marks it complete on filesystem too) + writeFile(base, "milestones/M001/SUMMARY.md", "# M001: DB Milestone\n\nDone."); + + // M002 exists ONLY on disk, not in DB + writeFile(base, "milestones/M002/CONTEXT.md", CONTEXT_CONTENT); + writeFile(base, "milestones/M002/ROADMAP.md", ROADMAP_CONTENT); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + // M002 should be visible in the registry + const m002Entry = state.registry.find((m) => m.id === "M002"); + assertTrue( + m002Entry !== undefined, + "M002 (disk-only milestone) should appear in state.registry (#2416)", + ); + + // M001 should still be in the registry + const m001Entry = state.registry.find((m) => m.id === "M001"); + assertTrue( + m001Entry !== undefined, + "M001 (DB milestone) should still appear in state.registry", + ); + + // The active milestone should be M002 (since M001 is complete) + assertTrue( + state.activeMilestone !== null, + "There should be an active milestone", + ); + if (state.activeMilestone) { + assertEq( + state.activeMilestone.id, + "M002", + "Active milestone should be M002 (disk-only, not complete) (#2416)", + ); + } + } finally { + closeDatabase(); + cleanup(base); + } + + report(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});