diff --git a/src/secrets/migrate.test.ts b/src/secrets/migrate.test.ts index d55c44fcf..d4803b324 100644 --- a/src/secrets/migrate.test.ts +++ b/src/secrets/migrate.test.ts @@ -201,4 +201,16 @@ describe("secrets migrate", () => { const rolledBackEnv = await fs.readFile(envPath, "utf8"); expect(rolledBackEnv).toContain("OPENAI_API_KEY=sk-openai-plaintext"); }); + + it("uses a unique backup id when multiple writes happen in the same second", async () => { + const now = new Date("2026-02-22T00:00:00.000Z"); + const first = await runSecretsMigration({ env, write: true, now }); + await rollbackSecretsMigration({ env, backupId: first.backupId! }); + + const second = await runSecretsMigration({ env, write: true, now }); + + expect(first.backupId).toBeTruthy(); + expect(second.backupId).toBeTruthy(); + expect(second.backupId).not.toBe(first.backupId); + }); }); diff --git a/src/secrets/migrate.ts b/src/secrets/migrate.ts index 5d2bdc3c9..0c7fdc75a 100644 --- a/src/secrets/migrate.ts +++ b/src/secrets/migrate.ts @@ -129,6 +129,21 @@ function formatBackupId(now: Date): string { return `${year}${month}${day}T${hour}${minute}${second}Z`; } +function resolveUniqueBackupId(stateDir: string, now: Date): string { + const backupRoot = resolveBackupRoot(stateDir); + const base = formatBackupId(now); + let candidate = base; + let attempt = 0; + + while (fs.existsSync(path.join(backupRoot, candidate))) { + attempt += 1; + const suffix = `${String(attempt).padStart(2, "0")}-${crypto.randomBytes(2).toString("hex")}`; + candidate = `${base}-${suffix}`; + } + + return candidate; +} + function parseEnvValue(raw: string): string { const trimmed = raw.trim(); if ( @@ -778,7 +793,7 @@ export async function runSecretsMigration( } const now = options.now ?? new Date(); - const backupId = formatBackupId(now); + const backupId = resolveUniqueBackupId(plan.stateDir, now); const backup = createBackupManifest({ stateDir: plan.stateDir, targets: plan.backupTargets,