From 942e5f5ac428e25c0034d7829d38a9f28f60c9ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Jul 2026 03:37:50 -0400 Subject: [PATCH] fix(node): preserve failed transaction rollbacks --- sdks/node/src/plugins/managed-installation.ts | 52 ++++++++- sdks/node/test/codex-transaction.test.mjs | 105 ++++++++++++++++++ 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/sdks/node/src/plugins/managed-installation.ts b/sdks/node/src/plugins/managed-installation.ts index 3c9d41e..7eca954 100644 --- a/sdks/node/src/plugins/managed-installation.ts +++ b/sdks/node/src/plugins/managed-installation.ts @@ -29,7 +29,8 @@ interface FileChange { interface StagedChange { readonly change: FileChange; readonly temporaryPath: string; - readonly rollbackPath?: string; + rollbackPath?: string; + committedSnapshot?: FileSnapshot; committed: boolean; } export interface ManagedRuntimePaths { @@ -361,16 +362,63 @@ async function commit( await assertUnchanged(staged.change, displayName); await renameFile(staged.temporaryPath, staged.change.filePath); staged.committed = true; + const current = await readPhysicalFile( + staged.change.filePath, + staged.change.label, + staged.change.maximumBytes, + ); + if (!current || current.contents !== staged.change.next) { + throw new Error( + `${staged.change.label} changed immediately after commit: ${staged.change.filePath}`, + ); + } + staged.committedSnapshot = current; +} + +async function assertCommittedUnchanged(staged: StagedChange): Promise { + const current = await readPhysicalFile( + staged.change.filePath, + staged.change.label, + staged.change.maximumBytes, + ); + const committed = staged.committedSnapshot; + if (!current || !committed + || current.contents !== committed.contents + || current.device !== committed.device + || current.inode !== committed.inode) { + throw new Error( + `${staged.change.label} changed during transaction recovery: ${staged.change.filePath}`, + ); + } +} + +function preserveRollback(staged: StagedChange, cause: unknown): Error { + if (!staged.rollbackPath) return asError(cause); + const rollbackPath = staged.rollbackPath; + staged.rollbackPath = undefined; + return new Error( + `${errorMessage(cause)}; original content preserved at ${rollbackPath}`, + { cause: asError(cause) }, + ); } async function rollback(staged: StagedChange, renameFile: RenameFile): Promise { if (!staged.committed) return; + try { + await assertCommittedUnchanged(staged); + } catch (error) { + throw preserveRollback(staged, error); + } if (!staged.change.original) { await unlinkOptional(staged.change.filePath); return; } if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`); - await renameFile(staged.rollbackPath, staged.change.filePath); + try { + await renameFile(staged.rollbackPath, staged.change.filePath); + } catch (error) { + throw preserveRollback(staged, error); + } } async function cleanup(staged: StagedChange): Promise { diff --git a/sdks/node/test/codex-transaction.test.mjs b/sdks/node/test/codex-transaction.test.mjs index 6d15076..f126bf7 100644 --- a/sdks/node/test/codex-transaction.test.mjs +++ b/sdks/node/test/codex-transaction.test.mjs @@ -43,6 +43,15 @@ async function assertNoTransactionFiles(fixture) { assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false); } +async function preservedRollback(fixture, basename) { + const entries = await readdir(fixture.agentDir); + const matches = entries.filter( + (name) => name.startsWith(`.${basename}.`) && name.endsWith('.rollback'), + ); + assert.equal(matches.length, 1, entries.join(', ')); + return path.join(fixture.agentDir, matches[0]); +} + test('Codex rolls back all five files when a transaction commit fails', async () => { const fixture = await createFixture(); try { @@ -131,6 +140,102 @@ test('Codex detects concurrent hook changes before committing runtime updates', } }); +test('managed installation preserves rollback data after a committed file changes', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const original = await readFile(fixture.guardScriptPath, 'utf-8'); + const source = ` + import { rename, rm, writeFile } from 'node:fs/promises'; + import { readDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + commitCodexInstallation, + prepareCodexInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const document = await readDocument(); + const prepared = await prepareCodexInstallation( + JSON.parse(process.env.ELYDORA_INSTALL_CONFIG), + { document, changed: false }, + ); + let calls = 0; + await commitCodexInstallation(prepared, async (from, to) => { + calls += 1; + if (calls === 1) { + await rename(from, to); + await rm(to); + await writeFile(to, 'external change\\n'); + return; + } + if (calls === 2) throw new Error('injected later commit failure'); + await rename(from, to); + }); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, { orgId: 'updated' })), + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /original content preserved at/i); + assert.equal(await readFile(fixture.guardScriptPath, 'utf-8'), 'external change\n'); + assert.equal(await readFile( + await preservedRollback(fixture, 'guard.js'), + 'utf-8', + ), original); + } finally { + await fixture.close(); + } +}); + +test('managed installation preserves rollback data after restore rename failure', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const original = await readFile(fixture.guardScriptPath, 'utf-8'); + const source = ` + import { rename } from 'node:fs/promises'; + import { readDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + commitCodexInstallation, + prepareCodexInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const document = await readDocument(); + const prepared = await prepareCodexInstallation( + JSON.parse(process.env.ELYDORA_INSTALL_CONFIG), + { document, changed: false }, + ); + let calls = 0; + await commitCodexInstallation(prepared, async (from, to) => { + calls += 1; + if (calls === 2) throw new Error('injected later commit failure'); + if (from.endsWith('.rollback')) throw new Error('injected rollback failure'); + await rename(from, to); + }); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, { orgId: 'updated' })), + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /original content preserved at/i); + assert.equal(await readFile( + await preservedRollback(fixture, 'guard.js'), + 'utf-8', + ), original); + } finally { + await fixture.close(); + } +}); + test('Codex rejects a stale hook document before staging files', async () => { const fixture = await createFixture(); try {