fix(cli): accept renumbered migration journal order (#11684)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-19 09:53:15 -05:00 committed by GitHub
parent 536d5880c5
commit 51a843e135
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 65 additions and 17 deletions

View File

@ -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 });

View File

@ -1416,20 +1416,14 @@ export function resolveWorktreeSeedMigrationRevision(
migrationState: Awaited<ReturnType<typeof inspectMigrations>>,
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.");
}