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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ Each invocation writes one atomic bundle under `.out/runs/<run-id>/`:
`.out/latest.json` is an atomic pointer to the most recent complete bundle. A
failed or skipped stage cannot leave an older stage artifact looking current.

List runs newest-first with `aas runs`, and remove oldest runs beyond a
window with `aas prune --keep <n>` (`--dry-run` previews). Pruning never
deletes the run the latest pointer identifies, and nothing is deleted
without an explicit `--keep`.

## Guided local GUI

Run `npm run gui` and open the printed loopback URL. The GUI calls the same
Expand Down
147 changes: 147 additions & 0 deletions bin/aas.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
unlinkSync,
Expand Down Expand Up @@ -245,6 +246,8 @@ Commands:
demo Run decide, act, and prove and persist one run bundle
export Print one run bundle as portable JSON (or write it with --out)
replay Re-verify an exported bundle offline without rerunning the action
runs List persisted runs newest-first
prune Remove oldest runs beyond --keep (latest stays; --dry-run previews)

Options:
--response pass|fail Policy fixture to evaluate (default: pass)
Expand Down Expand Up @@ -1013,7 +1016,83 @@ export function readRunBundle(outputRoot, runId) {
return { manifest, report, stages };
}

const RUN_ID_PATTERN = /^[A-Za-z0-9._-]+$/;

function runsDirectory(outputRoot) {
return join(outputRoot, "runs");
}

/**
* List persisted runs newest-first. Entries without a readable manifest
* (interrupted writes, stray files) are omitted; export and replay still
* fail closed on them when addressed directly.
*/
export function listRuns({ outputRoot = DEFAULT_PATHS.outputRoot } = {}) {
const dir = runsDirectory(outputRoot);
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") return [];
throw error;
}
const runs = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".") || !RUN_ID_PATTERN.test(entry.name)) continue;
let manifest;
try {
manifest = JSON.parse(readFileSync(join(dir, entry.name, "manifest.json"), "utf8"));
} catch {
continue;
}
if (!manifest || typeof manifest !== "object") continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Unrelated run directories can be deleted

A valid-named directory with any JSON object in manifest.json passes listRuns without a matching run ID or schema. pruneRuns can recursively delete that unrelated directory outside retention.

Suggested change
if (!manifest || typeof manifest !== "object") continue;
if (
!manifest
|| typeof manifest !== "object"
|| Array.isArray(manifest)
|| manifest.schema_version !== "agent-action-stack.run/v1"
|| manifest.run_id !== entry.name
) continue;
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const stages = {};
for (const name of STAGE_NAMES) stages[name] = manifest.stages?.[name]?.status ?? "unknown";
runs.push({
run_id: entry.name,
created_at: typeof manifest.created_at === "string" ? manifest.created_at : null,
exit_code: manifest.exit_code ?? null,
stages,
});
}
runs.sort((left, right) => (left.run_id < right.run_id ? 1 : left.run_id > right.run_id ? -1 : 0));
return runs;
}

function readLatestRunId(outputRoot) {
try {
const pointer = JSON.parse(readFileSync(join(outputRoot, "latest.json"), "utf8"));
const runId = pointer?.run_id;
return typeof runId === "string" && RUN_ID_PATTERN.test(runId) ? runId : null;
} catch {
return null;
Comment on lines +1067 to +1068

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Unreadable latest pointers disable retention

When latest.json is unreadable, malformed, or invalid, readLatestRunId silently treats it as absent. pruneRuns can then delete its target when that run falls outside the keep window.

Prompt for agents
Make pruneRuns fail closed when latest.json exists but cannot be read, parsed, or validated. Only treat ENOENT as an absent pointer. Validate the pointer schema and run_id before any deletion, and surface other failures so pruning cannot silently drop latest-target protection.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
}

/**
* Remove oldest runs beyond `keep`, newest-first retention. The run the
* latest pointer identifies is always kept so exports and downloads never
* dangle; dry runs report without deleting. Returns kept/removed run ids.
*/
export function pruneRuns({ outputRoot = DEFAULT_PATHS.outputRoot, keep, dryRun = false } = {}) {
if (!Number.isInteger(keep) || keep < 1) {
throw new UsageError("prune requires --keep <positive integer>");
}
const runs = [...listRuns({ outputRoot })].reverse();
const latest = readLatestRunId(outputRoot);
const keepSet = new Set(runs.slice(-keep).map((run) => run.run_id));
if (latest !== null && runs.some((run) => run.run_id === latest)) keepSet.add(latest);
const removed = [];
for (const run of runs) {
if (keepSet.has(run.run_id)) continue;
removed.push(run.run_id);
if (!dryRun) rmSync(join(runsDirectory(outputRoot), run.run_id), { recursive: true, force: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Concurrent pruning can erase completed runs

When overlapping demos finish out of order, pruneRuns can delete a published run before its latest pointer is written. persistRunBundle then writes that pointer after deletion. The run is lost, and latest exports fail.

Prompt for agents
Prevent pruneRuns in bin/aas.mjs from racing with persistRunBundle across processes. persistRunBundle publishes the final run directory before updating latest.json, so an older-ID run that completes after a newer-ID run can be deleted during that interval and then become the latest pointer target. Serialize publication and pruning with a shared lock, or introduce a lifecycle marker that makes newly published runs ineligible for deletion until pointer publication completes. Preserve atomic latest-pointer updates and ensure interrupted operations remain recoverable.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
return { kept: [...keepSet], removed, latest, dryRun };
}

/** Export one run bundle as a single portable JSON document. */

export function exportRunBundle(runId, { outputRoot = DEFAULT_PATHS.outputRoot } = {}) {
return readRunBundle(outputRoot, runId);
}
Expand Down Expand Up @@ -1382,6 +1461,63 @@ function readReplayInput(source, { stdin = process.stdin } = {}) {
return text;
}

function runRunsCommand(args, { asJson } = {}) {
if (args.some((token) => token !== "--json")) {
throw new UsageError(`Unsupported runs option (expected [--json])`);
}
const runs = listRuns({});
if (asJson) {
process.stdout.write(`${JSON.stringify({ ok: true, runs }, null, 2)}\n`);
} else if (runs.length === 0) {
process.stdout.write("no runs yet\n");
} else {
for (const run of runs) {
process.stdout.write(
`${run.run_id} exit=${run.exit_code ?? "?"} decide=${run.stages.decide} act=${run.stages.act} prove=${run.stages.prove}\n`,
);
}
}
process.exitCode = 0;
}

function runPruneCommand(args, { asJson } = {}) {
let keep = null;
let dryRun = false;
for (let index = 0; index < args.length; index += 1) {
const token = args[index];
if (token === "--keep") {
const value = args[index + 1];
if (value === undefined || value.startsWith("-")) throw new UsageError("Missing value for prune option: --keep");
if (keep !== null) throw new UsageError("Duplicate prune option: --keep");
if (!/^[0-9]+$/.test(value) || Number(value) < 1) throw new UsageError("prune requires --keep <positive integer>");
keep = Number(value);
index += 1;
} else if (token === "--dry-run") {
dryRun = true;
} else if (token !== "--json") {
throw new UsageError(`Unsupported prune option: ${token} (expected --keep <n> [--dry-run] [--json])`);
}
}
if (keep === null) throw new UsageError("Usage: aas prune --keep <positive integer> [--dry-run] [--json]");
const result = pruneRuns({ keep, dryRun });
if (asJson) {
process.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, 2)}\n`);
} else if (dryRun) {
process.stdout.write(
result.removed.length === 0
? `would keep ${result.kept.length} run(s); nothing to remove\n`
: `would remove ${result.removed.length} run(s): ${result.removed.join(", ")}\n`,
);
} else {
process.stdout.write(
result.removed.length === 0
? `kept ${result.kept.length} run(s); nothing removed\n`
: `removed ${result.removed.length} run(s): ${result.removed.join(", ")}\n`,
);
}
process.exitCode = 0;
}

function printReplayReport(result, asJson) {
if (asJson) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
Expand Down Expand Up @@ -1489,6 +1625,17 @@ export async function main(argv = process.argv.slice(2), options = {}) {
process.exitCode = 0;
return;
}
if (command === "runs" || command === "prune") {
try {
if (command === "runs") runRunsCommand(argv.slice(1), { asJson });
else runPruneCommand(argv.slice(1), { asJson });
} catch (error) {
const usage = error instanceof UsageError;
writeCliError(error, { asJson, usage });
process.exitCode = usage ? 2 : 1;
}
return;
}
if (command === "export" || command === "replay") {
try {
if (command === "export") runExportCommand(argv.slice(1), { asJson });
Expand Down
68 changes: 68 additions & 0 deletions test/stack.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import {
runProve,
runProveRail,
selectPython,
listRuns,
pruneRuns,
validateRailReview,
writeAtomicFile,
} from "../bin/aas.mjs";
Expand Down Expand Up @@ -1273,3 +1275,69 @@ test("export and replay CLI validate arguments and missing files", async () => {
const extra = await captureMain(["replay", "a", "b"]);
assert.equal(extra.exitCode, 2);
});

async function makeRuns(outputRoot, count) {
const ids = [];
for (let index = 0; index < count; index += 1) {
const runId = `2026-09-06T050000000Z-run${String(index).padStart(2, "0")}`;
const result = await runDemo(["--response", "pass"], stubOptions(outputRoot, { runId }));
assert.equal(result.exitCode, 0);
ids.push(runId);
}
return ids;
}

test("runs lists persisted runs newest-first", async () => {
const outputRoot = tempRoot();
assert.deepEqual(listRuns({ outputRoot }), []);
const ids = await makeRuns(outputRoot, 3);
const listed = listRuns({ outputRoot });
assert.deepEqual(listed.map((run) => run.run_id), [...ids].reverse());
assert.equal(listed[0].stages.decide, "passed");
assert.equal(listed[0].exit_code, 0);
});

test("prune keeps the newest runs and never the latest pointer target", async () => {
const outputRoot = tempRoot();
const ids = await makeRuns(outputRoot, 5);
const preview = pruneRuns({ outputRoot, keep: 2, dryRun: true });
assert.deepEqual(preview.removed.sort(), [ids[0], ids[1], ids[2]].sort());
assert.equal(listRuns({ outputRoot }).length, 5);
const done = pruneRuns({ outputRoot, keep: 2 });
assert.deepEqual(done.removed.sort(), [ids[0], ids[1], ids[2]].sort());
assert.deepEqual(listRuns({ outputRoot }).map((run) => run.run_id).sort(), [ids[3], ids[4]].sort());
const exported = exportRunBundle(ids[4], { outputRoot });
assert.equal(exported.report.run_id, ids[4]);
});

test("prune protects the latest pointer target beyond the keep window", async () => {
const outputRoot = tempRoot();
const ids = await makeRuns(outputRoot, 3);
writeFileSync(join(outputRoot, "latest.json"), `${JSON.stringify({ run_id: ids[0], manifest: `runs/${ids[0]}/manifest.json` })}\n`);
const done = pruneRuns({ outputRoot, keep: 1 });
assert.deepEqual(done.removed, [ids[1]]);
assert.deepEqual(listRuns({ outputRoot }).map((run) => run.run_id).sort(), [ids[0], ids[2]].sort());
});

test("prune validates input and handles empty stores", async () => {
const outputRoot = tempRoot();
assert.throws(() => pruneRuns({ outputRoot }), /positive integer/);
assert.throws(() => pruneRuns({ outputRoot, keep: 0 }), /positive integer/);
assert.throws(() => pruneRuns({ outputRoot, keep: -2 }), /positive integer/);
assert.throws(() => pruneRuns({ outputRoot, keep: 1.5 }), /positive integer/);
assert.deepEqual(pruneRuns({ outputRoot, keep: 5 }), { kept: [], removed: [], latest: null, dryRun: false });
});

test("runs and prune CLI commands validate arguments", async () => {
const listed = await captureMain(["runs"]);
assert.equal(listed.exitCode, 0);
const junk = await captureMain(["runs", "--bogus"]);
assert.equal(junk.exitCode, 2);
const missing = await captureMain(["prune"]);
assert.equal(missing.exitCode, 2);
assert.match(missing.stderr, /Usage: aas prune/);
const zero = await captureMain(["prune", "--keep", "0"]);
assert.equal(zero.exitCode, 2);
const words = await captureMain(["prune", "--keep", "many"]);
assert.equal(words.exitCode, 2);
});
Loading