Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed
- Case persistence now checks the exact formatted UTF-8 replay budget before writing files. Oversized combined evidence is rejected without publishing an unreadable case or replacing the previous latest pointer.

## [0.3.0] - 2026-10-09

The first tagged release since 0.2.0. `package.json` carried 0.2.1 to 0.2.5
Expand Down
79 changes: 48 additions & 31 deletions bin/aas.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1531,39 +1531,53 @@ export function persistRunBundle({
const runsDir = join(outputRoot, "runs");
const finalDir = join(runsDir, runId);
const temporaryDir = join(runsDir, `.${runId}.${process.pid}.${randomUUID()}.tmp`);
// Assemble and size the exact documents before publishing any case files.
// Individually bounded child outputs can exceed the portable case budget
// when combined or pretty-printed. Such a case cannot be reopened by any
// reader, so it must not replace latest.json or appear as a successful save.
const serializedStages = {};
const stageValues = {};
const stageManifest = {};
for (const stage of STAGE_NAMES) {
const current = stages[stage] ?? { status: "skipped", reason: "not_reached" };
const artifact = stageArtifact(stage, current.raw);
stageManifest[stage] = {
status: current.status,
reason: current.reason ?? null,
code: current.code ?? null,
stderr: current.stderr ? clipChildStderr(current.stderr) : null,
artifact: artifact?.path ?? null,
};
if (artifact) {
stageValues[stage] = current.raw;
serializedStages[artifact.path] = `${JSON.stringify(current.raw, null, 2)}\n`;
}
}
const manifest = {
schema_version: "agent-action-stack.run/v1",
run_id: runId,
created_at: now,
exit_code: exitCode,
// Additive within run/v1: readers treat a missing field as null.
stack_version: orchestratorVersion,
component_provenance: componentProvenance,
stages: stageManifest,
report: "report.json",
};
const serializedManifest = `${JSON.stringify(manifest, null, 2)}\n`;
const serializedReport = `${JSON.stringify(report, null, 2)}\n`;
const serializedBundle = `${JSON.stringify({ manifest, report, stages: stageValues }, null, 2)}\n`;
if ([serializedManifest, serializedReport, serializedBundle, ...Object.values(serializedStages)]
.some((text) => Buffer.byteLength(text, "utf8") > CHILD_JSON_LIMIT)) {
throw new Error("Run bundle exceeds the replay byte limit; no case was saved.");
}
mkdirSync(temporaryDir, { recursive: true });
try {
const stageManifest = {};
for (const stage of STAGE_NAMES) {
const current = stages[stage] ?? { status: "skipped", reason: "not_reached" };
const artifact = stageArtifact(stage, current.raw);
stageManifest[stage] = {
status: current.status,
reason: current.reason ?? null,
code: current.code ?? null,
stderr: current.stderr ? clipChildStderr(current.stderr) : null,
artifact: artifact?.path ?? null,
};
if (artifact) {
writeAtomicFile(
join(temporaryDir, artifact.path),
`${JSON.stringify(current.raw, null, 2)}\n`,
);
}
for (const [path, text] of Object.entries(serializedStages)) {
writeAtomicFile(join(temporaryDir, path), text);
}
const manifest = {
schema_version: "agent-action-stack.run/v1",
run_id: runId,
created_at: now,
exit_code: exitCode,
// Additive within run/v1: readers treat a missing field as null.
stack_version: orchestratorVersion,
component_provenance: componentProvenance,
stages: stageManifest,
report: "report.json",
};
writeAtomicFile(join(temporaryDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
writeAtomicFile(join(temporaryDir, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
writeAtomicFile(join(temporaryDir, "manifest.json"), serializedManifest);
writeAtomicFile(join(temporaryDir, "report.json"), serializedReport);
if (existsSync(finalDir)) throw new Error(`Run bundle already exists: ${runId}`);
renameSync(temporaryDir, finalDir);
const pointer = {
Expand Down Expand Up @@ -1696,7 +1710,10 @@ export async function runDemo(args = [], options = {}) {
prove: stageRecord("skipped", { reason: "not_reached" }),
},
};
const finalize = () => {
// Return this promise without awaiting it inside the stage handlers below.
// Persistence failures must escape, not be rewritten as component failures
// that discard raw evidence and publish a different, smaller error case.
const finalize = async () => {
report.stages = Object.fromEntries(STAGE_NAMES.map((stage) => {
const { raw: _raw, ...visible } = report.stages[stage] ?? stages[stage];
return [stage, { ...visible, status: stages[stage].status }];
Expand Down
82 changes: 81 additions & 1 deletion test/stack.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2120,7 +2120,9 @@ test('case exports reject mismatched identities and oversized persisted reports'

test('export byte budget includes the actual formatted download representation',async()=>{
const outputRoot=tempRoot();
await runDemo([],{paths:{outputRoot},runId:'formatted-budget',componentResolver:()=>[],runDecideFn:async()=>({ok:false,raw:{passed:false,rows:Array(30000).fill({x:0})},status:0})});
const result = await runDemo([],{paths:{outputRoot},runId:'formatted-budget',componentResolver:()=>[],runDecideFn:async()=>({ok:false,raw:{passed:false},status:0})});
// Preserve the reader's guard against externally modified historical cases.
writeFileSync(join(result.bundleDir, 'stages/decide.json'), JSON.stringify({passed:false,rows:Array(30000).fill({x:0})}));
assert.throws(()=>exportRunBundle('formatted-budget',{outputRoot}),/byte limit/);
});

Expand Down Expand Up @@ -2733,3 +2735,81 @@ test("npm test reports every declared test", () => {
assert.ok(counts.length > 0, `no test count in the runner summary:\n${result.stdout.slice(-800)}`);
assert.ok(reported >= declared, `runner reported ${reported} results but ${declared} tests are declared`);
});


test('case persistence rejects a combined oversized case before replacing latest', () => {
const outputRoot = tempRoot();
const save = (runId, stages) => persistRunBundle({
outputRoot, runId, report: {run_id: runId, stages: {}}, stages,
componentProvenance: [], exitCode: 0,
});
save('previous-readable-case', {});
const latest = readFileSync(join(outputRoot, 'latest.json'), 'utf8');
assert.throws(() => save('oversized-combination', {
decide: {status: 'passed', raw: {pad: 'x'.repeat(600000)}},
act: {status: 'passed', raw: {pad: 'y'.repeat(600000)}},
}), /replay byte limit; no case was saved/);
assert.equal(readFileSync(join(outputRoot, 'latest.json'), 'utf8'), latest);
assert.deepEqual(readdirSync(join(outputRoot, 'runs')), ['previous-readable-case']);
assert.equal(exportRunBundle('previous-readable-case', {outputRoot}).manifest.run_id, 'previous-readable-case');
});

test('case persistence accounts for pretty printing and UTF-8 bytes', () => {
for (const raw of [{rows: Array(30000).fill({x: 0})}, {pad: '界'.repeat(360000)}]) {
const outputRoot = tempRoot();
assert.throws(() => persistRunBundle({
outputRoot, runId: 'oversized-representation', report: {run_id: 'oversized-representation'},
stages: {decide: {status: 'passed', raw}}, componentProvenance: [], exitCode: 0,
}), /replay byte limit; no case was saved/);
assert.equal(existsSync(join(outputRoot, 'latest.json')), false);
assert.equal(existsSync(join(outputRoot, 'runs')), false);
}
});

test('successfully persisted cases round-trip at the exact portable byte limit', () => {
const outputRoot = tempRoot();
const runId = 'exact-portable-limit';
const parameters = {
outputRoot, runId, report: {run_id: runId}, stages: {decide: {status: 'passed', raw: {pad: ''}}},
componentProvenance: [], exitCode: 0, now: '2026-10-15T00:00:00.000Z',
};
const probeRoot = tempRoot();
persistRunBundle({...parameters, outputRoot: probeRoot});
const empty = exportRunBundle(runId, {outputRoot: probeRoot});
const size = Buffer.byteLength(JSON.stringify(empty, null, 2) + '\n');
parameters.stages.decide.raw.pad = 'x'.repeat(CHILD_JSON_LIMIT - size);
persistRunBundle(parameters);
const result = exportRunBundle(runId, {outputRoot});
assert.equal(Buffer.byteLength(JSON.stringify(result, null, 2) + '\n'), CHILD_JSON_LIMIT);
assert.equal(result.stages.decide.pad, parameters.stages.decide.raw.pad);
assert.throws(() => persistRunBundle({...parameters, outputRoot: tempRoot(),
stages: {decide: {status: 'passed', raw: {pad: parameters.stages.decide.raw.pad + 'x'}}},
}), /replay byte limit/);
});


test("runDemo preserves the previous case when finalization fails in any stage", async () => {
const large = { rows: Array.from({ length: 30_000 }, () => ({ x: 0 })) };
const scenarios = [
{ name: "policy-denied", decide: { ok: false, raw: { passed: false, ...large } } },
{ name: "act-failed", act: { ok: false, raw: { ...large } } },
{ name: "settled", act: { ok: true, raw: { outcome: "settled", ...large } } },
{ name: "prove-passed", prove: { ok: true, raw: { ...large } } },
{ name: "prove-failed", prove: { ok: false, raw: { ...large } } },
];
for (const scenario of scenarios) {
const outputRoot = tempRoot();
await runDemo([], stubOptions(outputRoot, { runId: "previous" }));
const previous = readFileSync(join(outputRoot, "latest.json"), "utf8");
await assert.rejects(runDemo([], {
paths: { outputRoot },
runId: scenario.name,
componentResolver: () => [],
runDecideFn: async () => scenario.decide ?? { ok: true, raw: { passed: true } },
runActFn: async () => scenario.act ?? { ok: true, raw: { outcome: "disputed" } },
runProveFn: async () => scenario.prove,
}), /replay byte limit; no case was saved/, scenario.name);
assert.equal(readFileSync(join(outputRoot, "latest.json"), "utf8"), previous, scenario.name);
assert.deepEqual(readdirSync(join(outputRoot, "runs")), ["previous"], scenario.name);
}
});
Loading