fix: preserve failed transaction rollbacks
This commit is contained in:
1 parent
a9adba1fc1
commit
d8bff04380
3 files changed
+155
-2
No files matched your search
@@ -40,7 +40,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo
|
|||||||
- Avoid compatibility shims without a named public or user configuration contract.
|
- Avoid compatibility shims without a named public or user configuration contract.
|
||||||
- Keep private keys and API tokens out of process arguments and generated setup commands. Accept them through hidden terminal input or owner-only credential files.
|
- Keep private keys and API tokens out of process arguments and generated setup commands. Accept them through hidden terminal input or owner-only credential files.
|
||||||
- Persist credential-bearing files through owner-only same-directory temporary files and atomic rename.
|
- Persist credential-bearing files through owner-only same-directory temporary files and atomic rename.
|
||||||
- Read runtime config, private keys, status cache, chain state, and error logs through physical-file descriptors with identity checks. Write cache and chain state atomically, and append error logs through no-follow owner-only descriptors.
|
- Read runtime config, private keys, status cache, chain state, and error logs through physical-file descriptors with identity checks. Write cache and validated chain state atomically, and append error logs through no-follow owner-only descriptors. Preserve rollback artifacts when recovery cannot safely restore an original file and include the recovery path in the surfaced error.
|
||||||
- Resolve every agent runtime directory as one physical child of `~/.elydora`; reject separators, traversal segments, cross-platform reserved names, symbolic-link directories, and linked identity configs before writes or recursive removal. Validate stored directory identity before changing host CLI configuration, and require an explicit agent ID when discovery is ambiguous.
|
- Resolve every agent runtime directory as one physical child of `~/.elydora`; reject separators, traversal segments, cross-platform reserved names, symbolic-link directories, and linked identity configs before writes or recursive removal. Validate stored directory identity before changing host CLI configuration, and require an explicit agent ID when discovery is ambiguous.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
try {
|
||||||
await renameFile(staged.rollbackPath, staged.change.filePath);
|
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> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user