From 51a843e1350b0c720aaa6d66c5de8fb8a234b947 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:53:15 -0500 Subject: [PATCH] fix(cli): accept renumbered migration journal order (#11684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Worktree provisioning clones a source database into an isolated workspace > - Source validation must accept a migration journal that matches a prefix of the checkout journal > - Long-lived instances can apply migrations in a different order after migration files are renumbered > - The validator compared application order with filename order and rejected a valid source > - This pull request compares the resolved migration names as a set and records the checkout-prefix revision > - The benefit is that valid renumbered migration histories can pass source validation without allowing divergent histories ## Linked Issues or Issue Description **What happened?** Worktree seed source validation compared applied migrations in database application order with available migration files in filename order. A current source with the same migration set failed with `Migration journal is not a prefix` after migration files were renumbered. **Expected behavior** Source validation must accept a source when its resolved applied migration set equals a prefix of the checkout migration files. It must still reject a source that contains a resolved migration outside that prefix. **Steps to reproduce** 1. Apply migrations before a migration-file renumber operation. 2. Update the checkout so the same migration files have a different filename order. 3. Run worktree seed source validation against the long-lived source. 4. Observe that positional comparison rejects the source even though the sets are equal. **Paperclip version or commit** The bug reproduces on the master-equivalent worktree-seeding implementation before this commit. **Deployment mode** Local dev with embedded PostgreSQL. ## What Changed - Compare resolved applied migration names with the expected checkout prefix as an order-independent set. - Derive the reported source revision from the checkout prefix instead of database application order. - Add unit and embedded-PostgreSQL regressions for shuffled application order, stale unresolved rows, lagging sources, and true divergence. ## Verification - `pnpm exec vitest run cli/src/__tests__/worktree.test.ts` — 54 tests passed. - `pnpm --filter paperclipai typecheck` — passed. - The focused suite includes the real embedded-PostgreSQL seed path. ## Risks - Low risk. The change is limited to source migration-prefix validation. - The validator still rejects missing or unknown resolved migrations. - Duplicate resolved names remain set-equivalent by design. Raw stale journal rows remain tolerated. - Existing documentation already specifies order-independent checkout-prefix behavior, so no documentation change is required. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, exact model ID `gpt-5.6-sol`. The Codex runtime manages the context window. The model used reasoning, shell tools, code execution, and test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- cli/src/__tests__/worktree.test.ts | 68 +++++++++++++++++++++++++++--- cli/src/commands/worktree.ts | 14 ++---- 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index ba8d3339c3..b4834dd512 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -488,14 +488,34 @@ describe("worktree helpers", () => { expect(full.nullifyColumns).toEqual({}); }); - it("rejects a source migration journal that is ahead of the code journal", () => { + it("rejects a source migration journal that diverges from the code journal", () => { expect(() => resolveWorktreeSeedMigrationRevision({ status: "upToDate", tableCount: 1, availableMigrations: ["0001_initial.sql", "0002_current.sql"], - appliedMigrations: ["0001_initial.sql", "0002_current.sql"], + appliedMigrations: ["0001_initial.sql", "0003_unknown.sql"], journalEntryCount: 3, - }, "sourcePrefix")).toThrow("Migration journal is ahead of this Paperclip checkout"); + }, "sourcePrefix")).toThrow("Migration journal is not a prefix of this Paperclip checkout"); + }); + + it("accepts a current source whose migration application order differs from filename order", () => { + expect(resolveWorktreeSeedMigrationRevision({ + status: "upToDate", + tableCount: 1, + availableMigrations: [ + "0001_initial.sql", + "0002_renumbered.sql", + "0003_applied_earlier.sql", + "0004_current.sql", + ], + appliedMigrations: [ + "0001_initial.sql", + "0003_applied_earlier.sql", + "0002_renumbered.sql", + "0004_current.sql", + ], + journalEntryCount: 6, + }, "upToDate")).toBe("0004_current.sql"); }); it("accepts a source migration journal that is multiple revisions behind", () => { @@ -508,9 +528,9 @@ describe("worktree helpers", () => { "0003_pending.sql", "0004_pending.sql", ], - appliedMigrations: ["0001_initial.sql", "0002_applied.sql"], + appliedMigrations: ["0002_applied.sql", "0001_initial.sql"], pendingMigrations: ["0003_pending.sql", "0004_pending.sql"], - journalEntryCount: 2, + journalEntryCount: 3, reason: "pending-migrations", }, "sourcePrefix")).toBe("0002_applied.sql"); }); @@ -1244,7 +1264,7 @@ describe("worktree helpers", () => { }); itEmbeddedPostgres( - "seeds a source whose migration journal is behind the code journal", + "seeds a lagging source whose migration application order differs from filename order", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-")); const worktreeRoot = path.join(tempRoot, "PAP-999-auth-seed"); @@ -1264,7 +1284,30 @@ describe("worktree helpers", () => { DELETE FROM "drizzle"."__drizzle_migrations" WHERE "id" = ( SELECT max("id") FROM "drizzle"."__drizzle_migrations" + ); + + WITH pair AS ( + SELECT + array_agg("id" ORDER BY "id" DESC) AS ids, + array_agg("hash" ORDER BY "id" DESC) AS hashes + FROM ( + SELECT "id", "hash" + FROM "drizzle"."__drizzle_migrations" + ORDER BY "id" DESC + LIMIT 2 + ) latest ) + UPDATE "drizzle"."__drizzle_migrations" migrations + SET "hash" = CASE + WHEN migrations."id" = pair.ids[1] THEN pair.hashes[2] + WHEN migrations."id" = pair.ids[2] THEN pair.hashes[1] + ELSE migrations."hash" + END + FROM pair + WHERE migrations."id" IN (pair.ids[1], pair.ids[2]); + + INSERT INTO "drizzle"."__drizzle_migrations" ("hash", "created_at") + VALUES ('stale-unresolvable-migration-hash', 0) `); await sourceDbClient.$client.end({ timeout: 5 }); const laggingMigrationState = await inspectMigrations(sourceDb.connectionString); @@ -1273,7 +1316,18 @@ describe("worktree helpers", () => { throw new Error("Expected the source migration journal to lag the code journal"); } expect(laggingMigrationState.pendingMigrations).toHaveLength(1); - const sourceMigrationRevision = laggingMigrationState.appliedMigrations.at(-1); + const expectedAppliedPrefix = laggingMigrationState.availableMigrations.slice( + 0, + laggingMigrationState.appliedMigrations.length, + ); + expect(laggingMigrationState.appliedMigrations).not.toEqual(expectedAppliedPrefix); + expect([...laggingMigrationState.appliedMigrations].sort()).toEqual( + [...expectedAppliedPrefix].sort(), + ); + expect(laggingMigrationState.journalEntryCount).toBeGreaterThan( + laggingMigrationState.appliedMigrations.length, + ); + const sourceMigrationRevision = expectedAppliedPrefix.at(-1); expect(sourceMigrationRevision).toBeTruthy(); fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true }); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index be6926c220..d31dabaefc 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -1416,20 +1416,14 @@ export function resolveWorktreeSeedMigrationRevision( migrationState: Awaited>, requirement: "sourcePrefix" | "upToDate", ): string { - if (migrationState.journalEntryCount > migrationState.availableMigrations.length) { - throw new Error( - `Migration journal is ahead of this Paperclip checkout (${migrationState.journalEntryCount} applied migration(s), ${migrationState.availableMigrations.length} available).`, - ); - } - const expectedAppliedPrefix = migrationState.availableMigrations.slice( 0, migrationState.appliedMigrations.length, ); + const appliedMigrationNames = new Set(migrationState.appliedMigrations); if ( - migrationState.appliedMigrations.some( - (migration, index) => migration !== expectedAppliedPrefix[index], - ) + appliedMigrationNames.size !== expectedAppliedPrefix.length || + expectedAppliedPrefix.some((migration) => !appliedMigrationNames.has(migration)) ) { throw new Error("Migration journal is not a prefix of this Paperclip checkout's migration journal."); } @@ -1440,7 +1434,7 @@ export function resolveWorktreeSeedMigrationRevision( ); } - const migrationRevision = migrationState.appliedMigrations.at(-1); + const migrationRevision = expectedAppliedPrefix.at(-1); if (!migrationRevision) { throw new Error("Migration journal has no applied revision."); }