fix(node): preserve failed transaction rollbacks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 03:37:50 -04:00
1 parent e4d463d085
commit 942e5f5ac4
2 files changed
+155 -2

No files matched your search

+50 -2
View File
@@ -29,7 +29,8 @@ interface FileChange {
interface StagedChange { interface StagedChange {
readonly change: FileChange; readonly change: FileChange;
readonly temporaryPath: string; readonly temporaryPath: string;
readonly rollbackPath?: string; rollbackPath?: string;
committedSnapshot?: FileSnapshot;
committed: boolean; committed: boolean;
} }
export interface ManagedRuntimePaths { export interface ManagedRuntimePaths {
@@ -361,16 +362,63 @@ async function commit(
await assertUnchanged(staged.change, displayName); await assertUnchanged(staged.change, displayName);
await renameFile(staged.temporaryPath, staged.change.filePath); await renameFile(staged.temporaryPath, staged.change.filePath);
staged.committed = true; 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<void> {
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<void> { async function rollback(staged: StagedChange, renameFile: RenameFile): Promise<void> {
if (!staged.committed) return; if (!staged.committed) return;
try {
await assertCommittedUnchanged(staged);
} catch (error) {
throw preserveRollback(staged, error);
}
if (!staged.change.original) { if (!staged.change.original) {
await unlinkOptional(staged.change.filePath); await unlinkOptional(staged.change.filePath);
return; return;
} }
if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`); 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<void> { async function cleanup(staged: StagedChange): Promise<void> {
+105
View File
@@ -43,6 +43,15 @@ async function assertNoTransactionFiles(fixture) {
assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false); 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 () => { test('Codex rolls back all five files when a transaction commit fails', async () => {
const fixture = await createFixture(); const fixture = await createFixture();
try { 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 () => { test('Codex rejects a stale hook document before staging files', async () => {
const fixture = await createFixture(); const fixture = await createFixture();
try { try {