Files
stack/agents/rocko/work/queue-56/build.patch
T

1226 lines
53 KiB
Diff

diff --git a/agents/rocko/work/queue-56/byte-check.sh b/agents/rocko/work/queue-56/byte-check.sh
new file mode 100755
index 00000000..2a0f9e94
--- /dev/null
+++ b/agents/rocko/work/queue-56/byte-check.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Row 56 byte-identity check. Runs `mosaic-task.mjs list` and `show` from a
+# baseline tree and a candidate tree against the same seeded data roots and
+# compares stdout, stderr and exit codes byte for byte. It also checks that
+# neither tree changed the data root.
+# Usage: byte-check.sh BASE_TREE CAND_TREE WORK_DIR (WORK_DIR must not exist)
+set -euo pipefail
+BASE="$(cd "$1" && pwd)"; CAND="$(cd "$2" && pwd)"; W="$3"
+HERE="$(cd "$(dirname "$0")" && pwd)"
+[ ! -e "$W" ] || { echo "byte-check: $W exists" >&2; exit 4; }
+mkdir -p "$W/out"; W="$(cd "$W" && pwd)"
+"$HERE/seed.sh" "$W/data"
+mkdir -p "$W/empty"
+conf() { printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m"}}\n' "$2" > "$W/$1.json"; }
+conf seeded "$W/data"; conf empty "$W/empty"; conf absent "$W/no-such-root"
+
+snapshot() { (cd "$W/data" && find . -printf '%p %y %s %T@ %l\n' | sort && find . -type f -print0 | sort -z | xargs -0 sha256sum); }
+snapshot > "$W/before.txt"
+
+CASES=(
+ "seeded list"
+ "empty list"
+ "absent list"
+ "seeded show r-20260101T000000Z-aaaaaa"
+ "seeded show r-20260101T000100Z-bbbbbb"
+ "seeded show r-20260101T000200Z-cccccc"
+ "seeded show r-20260101T000300Z-dddddd"
+ "seeded show r-20260101T000400Z-eeeeee"
+ "seeded show r-20260101T000500Z-ffffff"
+ "seeded show r-20260101T000700Z-hhhhhh"
+ "seeded show r-20260101T000800Z-iiiiii"
+ "seeded show r-zz.weird_name-1"
+ "seeded show r-missing"
+ "seeded show ../escape"
+ "seeded show"
+ "absent show r-20260101T000000Z-aaaaaa"
+)
+for tree in base cand; do
+ root="$BASE"; [ "$tree" = cand ] && root="$CAND"
+ i=0
+ for c in "${CASES[@]}"; do
+ i=$((i + 1))
+ read -r cfg op arg <<<"$c"
+ o="$W/out/$tree/$(printf '%02d' "$i")"
+ mkdir -p "$o"
+ printf '%s\n' "$c" > "$o/case"
+ rc=0
+ (cd "$root" && env -u NODE_OPTIONS MOSAIC_CONFIG="$W/$cfg.json" node scripts/mosaic-task.mjs "$op" ${arg:+"$arg"} >"$o/stdout" 2>"$o/stderr") || rc=$?
+ printf '%s\n' "$rc" > "$o/exit"
+ [ -f "$o/stdout" ] && [ -f "$o/stderr" ] || { echo "byte-check: case $i ($c) left no output in $o" >&2; exit 4; }
+ done
+done
+snapshot > "$W/after.txt"
+
+status=0
+if diff -r "$W/out/base" "$W/out/cand" > "$W/diff.txt"; then
+ echo "byte-check: ${#CASES[@]} cases, stdout, stderr and exit identical"
+else
+ echo "byte-check: DIFFERENCES (see $W/diff.txt)"; status=1
+fi
+if cmp -s "$W/before.txt" "$W/after.txt"; then
+ echo "byte-check: data root unchanged"
+else
+ echo "byte-check: DATA ROOT CHANGED"; status=1
+fi
+exit "$status"
diff --git a/agents/rocko/work/queue-56/delta-check.sh b/agents/rocko/work/queue-56/delta-check.sh
new file mode 100755
index 00000000..a502d1d1
--- /dev/null
+++ b/agents/rocko/work/queue-56/delta-check.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+# Row 56 deliberate deltas. Outside the seeded data root of byte-check.sh,
+# list and show change on purpose where a record links out of the data
+# root, a document isn't a JSON object, a run is a regular file, or the
+# runs directory can't be read. This prints the baseline and candidate
+# output for each such case (first stderr line only) so the change is on
+# record. It asserts nothing; the package tests assert the new behaviour.
+# Usage: delta-check.sh BASE_TREE CAND_TREE WORK_DIR (WORK_DIR must not exist)
+set -euo pipefail
+BASE="$(cd "$1" && pwd)"; CAND="$(cd "$2" && pwd)"; W="$3"
+[ ! -e "$W" ] || { echo "delta-check: $W exists" >&2; exit 4; }
+mkdir -p "$W"; W="$(cd "$W" && pwd)"
+A=r-20260101T000000Z-aaaaaa
+RESULT='{"runVersion":1,"taskId":"t-out","status":"succeeded","request":"r","response":"s","provider":"p","model":"m","startedAt":"a","finishedAt":"b","durationMs":1,"exitCode":0,"signal":null}'
+
+root() { # name -> creates W/name/{data,outside}, writes config, echoes data root
+ mkdir -p "$W/$1/data" "$W/$1/outside"
+ printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m"}}\n' "$W/$1/data" > "$W/$1/config.json"
+ echo "$W/$1/data"
+}
+run_case() { # name op [arg]
+ local name="$1"; shift
+ for tree in base cand; do
+ local dir="$BASE"; [ "$tree" = cand ] && dir="$CAND"
+ local rc=0 out err
+ out="$(cd "$dir" && env MOSAIC_CONFIG="$W/$name/config.json" node scripts/mosaic-task.mjs "$@" 2>"$W/$name/$tree.err")" || rc=$?
+ err="$(head -1 "$W/$name/$tree.err")"
+ printf ' %s: exit %s\n' "$tree" "$rc"
+ [ -z "$out" ] || printf '%s\n' "$out" | sed 's/^/ out| /'
+ [ -z "$err" ] || printf ' err| %s\n' "$err"
+ done
+}
+hdr() { printf '\n== %s\n' "$*"; }
+
+D="$(root run-link)"; mkdir -p "$D/runs"; printf '%s\n' "$RESULT" > "$W/run-link/outside/result.json"; ln -s "$W/run-link/outside" "$D/runs/$A"
+hdr "run directory linked out of the data root: list"; run_case run-link list
+hdr "run directory linked out of the data root: show"; run_case run-link show "$A"
+
+D="$(root doc-link)"; mkdir -p "$D/runs/$A"; printf '%s\n' "$RESULT" > "$W/doc-link/outside/result.json"; ln -s "$W/doc-link/outside/result.json" "$D/runs/$A/result.json"
+hdr "result.json linked out of the data root: list"; run_case doc-link list
+hdr "result.json linked out of the data root: show"; run_case doc-link show "$A"
+
+D="$(root runs-link)"; mkdir -p "$W/runs-link/outside/$A"; printf '%s\n' "$RESULT" > "$W/runs-link/outside/$A/result.json"; ln -s "$W/runs-link/outside" "$D/runs"
+hdr "runs directory linked out of the data root: list"; run_case runs-link list
+hdr "runs directory linked out of the data root: show"; run_case runs-link show "$A"
+
+D="$(root non-object)"; mkdir -p "$D/runs/$A"; printf '5\n' > "$D/runs/$A/result.json"; printf '[]\n' > "$D/runs/$A/task.json"
+hdr "result.json is 5 and task.json is []: list"; run_case non-object list
+hdr "result.json is 5 and task.json is []: show"; run_case non-object show "$A"
+
+D="$(root run-file)"; mkdir -p "$D/runs"; printf 'x\n' > "$D/runs/$A"
+hdr "run is a regular file: show"; run_case run-file show "$A"
+
+D="$(root loop)"; mkdir -p "$D/runs"; ln -s r-b "$D/runs/$A"; ln -s "$A" "$D/runs/r-b"
+hdr "run is a link loop: show"; run_case loop show "$A"
+
+if [ "$(id -u)" != 0 ]; then
+ D="$(root unreadable)"; mkdir -p "$D/runs/$A"; chmod 000 "$D/runs"
+ hdr "runs directory unreadable: list"; run_case unreadable list
+ hdr "runs directory unreadable: show"; run_case unreadable show "$A"
+ chmod 755 "$D/runs"
+ D="$(root run-unreadable)"; mkdir -p "$D/runs/$A"; printf '%s\n' "$RESULT" > "$D/runs/$A/result.json"; chmod 000 "$D/runs/$A"
+ hdr "run directory unreadable: show"; run_case run-unreadable show "$A"
+ chmod 755 "$D/runs/$A"
+fi
diff --git a/agents/rocko/work/queue-56/seed.sh b/agents/rocko/work/queue-56/seed.sh
new file mode 100755
index 00000000..ad623a70
--- /dev/null
+++ b/agents/rocko/work/queue-56/seed.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# Seeds a data root for the row 56 byte-identity check. Usage: seed.sh DIR
+# DIR must not exist. Writes run records only under DIR.
+set -euo pipefail
+D="$1"
+[ ! -e "$D" ] || { echo "seed: $D exists" >&2; exit 4; }
+R="$D/runs"
+mkdir -p "$R" "$D/state"
+run() { mkdir -p "$R/$1"; }
+put() { printf '%s\n' "$3" > "$R/$1/$2"; }
+
+A=r-20260101T000000Z-aaaaaa
+run $A
+put $A task.json '{"taskVersion":1,"id":"t-full","prompt":"say hi","mission":"m.json"}'
+put $A mission.json '{"missionVersion":1,"id":"m-full","objective":"Exercise every show line"}'
+put $A result.json '{"runVersion":1,"runId":"'$A'","taskId":"t-full","missionId":"m-full","status":"succeeded","reason":null,"request":"say \"hi\"\nplease","response":"hi","expectedExact":"hi","retriedFrom":"r-20251231T000000Z-000000","workspace":"ws1","tools":["read","ls"],"session":"s1","sessionForkFrom":null,"exitCode":0,"signal":null,"provider":"zai","model":"m","startedAt":"2026-01-01T00:00:00.000Z","finishedAt":"2026-01-01T00:00:01.000Z","durationMs":1000}'
+: > "$R/$A/stderr.txt"
+mkdir "$R/$A/workspace"
+
+B=r-20260101T000100Z-bbbbbb
+run $B
+put $B task.json '{"taskVersion":1,"id":"t-fail","prompt":"x"}'
+put $B result.json '{"runVersion":1,"runId":"'$B'","taskId":"t-fail","missionId":null,"status":"failed","reason":"expect-mismatch","request":"x","response":"y","expectedExact":null,"workspace":null,"tools":null,"session":null,"sessionForkFrom":null,"exitCode":null,"signal":"SIGKILL","provider":"zai","model":"m","startedAt":"2026-01-01T00:01:00.000Z","finishedAt":"2026-01-01T00:01:02.000Z","durationMs":2000}'
+
+C=r-20260101T000200Z-cccccc
+run $C
+put $C task.json '{"taskVersion":1,"id":"t-incomplete","prompt":"x"}'
+
+E=r-20260101T000300Z-dddddd
+run $E
+put $E result.json '{"status": "succeeded", truncated'
+
+N=r-20260101T000400Z-eeeeee
+run $N
+put $N result.json 'null'
+
+F=r-20260101T000500Z-ffffff
+run $F
+put $F mission.json '{not json'
+put $F result.json '{"runVersion":1,"runId":"'$F'","taskId":"a-task-id-longer-than-eighteen","missionId":"m","status":"succeeded-with-a-long-status","reason":null,"request":"","response":"","workspace":":run","tools":[],"session":"named-session","exitCode":0,"signal":null,"provider":"p","model":"m","startedAt":"s","finishedAt":"f","durationMs":0}'
+
+printf 'not a run directory\n' > "$R/r-20260101T000600Z-gggggg"
+ln -s "$A" "$R/r-20260101T000700Z-hhhhhh"
+ln -s "r-does-not-exist" "$R/r-20260101T000800Z-iiiiii"
+run r-zz.weird_name-1
+mkdir "$R/x-not-a-run"
+printf '{"at":"2026-01-01T00:00:00Z","event":"pruned","runId":"r-old"}\n' > "$R/.pruned.log"
+printf 'notes\n' > "$R/notes.txt"
diff --git a/packages/runs/README.md b/packages/runs/README.md
new file mode 100644
index 00000000..8fd78082
--- /dev/null
+++ b/packages/runs/README.md
@@ -0,0 +1,92 @@
+# Runs
+
+Read-only readers for the run records under `<dataRoot>/runs/` and the
+release state under `<dataRoot>/state/` (#1545). `scripts/mosaic-task.mjs
+list` and `show` read through it, and so will the console's runs view.
+This README covers what the code does and the limits it accepts.
+
+```sh
+node --test packages/runs/tests/
+```
+
+## What it reads
+
+| Export | Returns |
+|---|---|
+| `listRunIds(dataRoot)` | every name under `runs/` that starts with `r-`, sorted, which is oldest first |
+| `listRunRecords(dataRoot)` | `[{ runId, result }]` for those names; `result` is `null` for an incomplete or unreadable record |
+| `readRunRecord(dataRoot, runId)` | `{ runId, result, task, mission, artifacts }`, or `null` when the run doesn't exist or isn't a directory |
+| `readRunDocument(dataRoot, runId, name)` | `result.json`, `task.json` or `mission.json` from one run, or `null` |
+| `readActivePointer(dataRoot)` | `state/active.json`, or `null` when no release has been activated |
+| `readActivationLog(dataRoot, { last })` | `{ entries, malformed }` from `state/activation-log.jsonl`, oldest first |
+| `isRunId(value)`, `RUN_ID_PATTERN` | the run id shape `mosaic-task.mjs` checks: `r-` then 1 to 64 of `[A-Za-z0-9._-]`, starting with a letter or digit |
+
+The caller passes `dataRoot`; this package doesn't read the system
+config. `artifacts` are names in directory order, as `show` has always
+printed them.
+
+## Limits
+
+- **Nothing writes.** No reader creates, changes, prunes or locks
+ anything. Run records stay write-once evidence; pruning stays in
+ `mosaic-task.mjs prune`.
+- **Nothing follows a link out of the data root.** The configured data
+ root is trusted as given, even when it is itself a link. Every path
+ below it is resolved, and one that resolves outside it is refused or
+ read as unreadable:
+ - `runs/` or `state/` resolving outside refuses with a `RunsError`;
+ - a run directory resolving outside: `readRunRecord` refuses, and
+ `listRunRecords` gives that run a `null` result;
+ - a run document resolving outside reads as `null`;
+ - `active.json` or `activation-log.jsonl` resolving outside refuses.
+
+ Only a path that exists can resolve. A link with nothing behind it reads
+ as missing wherever it points. A `state/` link out of the data root to a
+ path that doesn't exist reads as no release and an empty log, and a
+ `runs/` link like that lists nothing. A link that stays inside the data
+ root is followed.
+- **Run documents are JSON objects or `null`.** A document that is
+ missing, unreadable, not JSON, or JSON but not an object (`null`, `5`,
+ `[]`) reads as `null`. Run records are never validated beyond that,
+ because older records carry older shapes.
+- **The release pointer is strict.** `active.json` must be the version 1
+ shape `scripts/release.sh` writes: exactly `pointerVersion` 1 and
+ non-empty strings `release`, `imageTag` and `activatedAt`. Anything else
+ refuses with exit code 2 rather than being guessed at.
+- **The activation log is read per line.** One bad line never refuses
+ the log. An entry needs string `at`, `event`, `release` and `imageTag`,
+ an optional string `note`, and no other keys. A line that isn't JSON,
+ or is JSON with another shape, is counted in `malformed` and skipped.
+ That is stricter than `release.sh rollback`, which skips only lines that
+ aren't JSON and would act on an entry with an extra key or a non-string
+ field. Blank lines aren't counted. `last` must be a positive integer and
+ keeps the newest entries.
+- **Errors.** Every refusal is a `RunsError` with `exitCode` 2 (invalid
+ data) or 4 (a file or environment problem), matching
+ `mosaic-task.mjs`. A missing data root, `runs/` or `state/` file is not
+ an error. A path that can't be resolved for another reason (a link
+ loop, a permission error) or a directory that can't be read refuses
+ instead of reading as empty.
+
+## How mosaic-task.mjs uses it
+
+`list` and `show` print exactly what they printed before this package
+existed, for any data root with no link out of it, no run document that
+is JSON but not an object, and no unreadable directory.
+`agents/rocko/work/queue-56/byte-check.sh` checks that byte for byte
+against a seeded data root. Where those conditions don't hold, the
+output changes on purpose:
+
+| Case | Before | Now |
+|---|---|---|
+| run directory or `result.json` linked out | read through the link | `list`: `unknown`; `show`: refuses (run directory) or `result.json: (missing or unreadable)` |
+| `runs/` linked out | read through the link | `list` and `show` refuse, exit 4 |
+| `result.json` is `5` or `[]` | `list` crashed; `show` printed `undefined` fields | `unknown`; `(missing or unreadable)` |
+| `task.json` or `mission.json` is JSON but not an object | `show` printed its snapshot line | snapshot line omitted |
+| run is a regular file | `show` crashed | `run not found`, exit 4 |
+| `runs/` unreadable | `list` printed nothing | refuses, exit 4 |
+| run directory unreadable (mode 000) | `show` printed two lines, then crashed, exit 1 | refuses with EACCES, exit 4 |
+| link loop or a permission error resolving a run | `run not found` | refuses with the error code, exit 4 |
+
+`agents/rocko/work/queue-56/delta-check.sh` prints the before and after
+for each case.
diff --git a/packages/runs/package.json b/packages/runs/package.json
new file mode 100644
index 00000000..42d2fecc
--- /dev/null
+++ b/packages/runs/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@mosaic/runs",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Read-only readers for run records under <dataRoot>/runs/ and the release pointer and activation log under <dataRoot>/state/.",
+ "license": "UNLICENSED",
+ "type": "module",
+ "engines": { "node": ">=24" },
+ "exports": { ".": "./src/index.mjs" },
+ "scripts": { "test": "node --test tests/" }
+}
diff --git a/packages/runs/src/errors.mjs b/packages/runs/src/errors.mjs
new file mode 100644
index 00000000..37e2cbae
--- /dev/null
+++ b/packages/runs/src/errors.mjs
@@ -0,0 +1,9 @@
+// Exit codes follow scripts/mosaic-task.mjs: 2 invalid data, 4 a file or
+// environment problem. Every refusal in this package is a RunsError.
+export class RunsError extends Error {
+ constructor(message, exitCode = 4) {
+ super(message);
+ this.name = "RunsError";
+ this.exitCode = exitCode;
+ }
+}
diff --git a/packages/runs/src/index.mjs b/packages/runs/src/index.mjs
new file mode 100644
index 00000000..3084277c
--- /dev/null
+++ b/packages/runs/src/index.mjs
@@ -0,0 +1,10 @@
+// @mosaic/runs: read-only readers for run records under <dataRoot>/runs/
+// and the release pointer and activation log under <dataRoot>/state/.
+// Nothing here writes, prunes or follows a link out of the data root.
+
+export { RunsError } from "./errors.mjs";
+export { RUNS_DIRNAME, STATE_DIRNAME } from "./paths.mjs";
+export {
+ RUN_ID_PATTERN, RUN_DOCUMENTS, isRunId, listRunIds, readRunDocument, listRunRecords, readRunRecord,
+} from "./runs.mjs";
+export { POINTER_FILE, ACTIVATION_LOG_FILE, readActivePointer, readActivationLog } from "./state.mjs";
diff --git a/packages/runs/src/paths.mjs b/packages/runs/src/paths.mjs
new file mode 100644
index 00000000..1dbb5539
--- /dev/null
+++ b/packages/runs/src/paths.mjs
@@ -0,0 +1,49 @@
+import fs from "node:fs";
+import path from "node:path";
+import { RunsError } from "./errors.mjs";
+
+export const RUNS_DIRNAME = "runs";
+export const STATE_DIRNAME = "state";
+
+export function isMissing(error) {
+ return error?.code === "ENOENT" || error?.code === "ENOTDIR";
+}
+
+function isInside(root, target) {
+ const relative = path.relative(root, target);
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
+}
+
+// Resolves <dataRoot>/<parts...> through any symbolic links and returns the
+// real path, or null when it doesn't exist. The data root is trusted as
+// configured; a path below it that resolves outside it refuses, so no reader
+// here follows a link out of the data root.
+export function resolveInside(dataRoot, ...parts) {
+ const target = path.join(dataRoot, ...parts);
+ let root;
+ let real;
+ try {
+ root = fs.realpathSync(dataRoot);
+ real = fs.realpathSync(target);
+ } catch (error) {
+ if (isMissing(error)) return null;
+ throw new RunsError(`cannot resolve ${target}: ${error.code ?? error.message}`);
+ }
+ if (!isInside(root, real)) {
+ throw new RunsError(`${target} resolves outside the data root (${root})`);
+ }
+ return real;
+}
+
+// A JSON object read from <dataRoot>/<parts...>, or null when the file is
+// missing, unreadable, not JSON, not an object or outside the data root.
+export function readJsonObject(dataRoot, ...parts) {
+ try {
+ const file = resolveInside(dataRoot, ...parts);
+ if (file === null) return null;
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/packages/runs/src/runs.mjs b/packages/runs/src/runs.mjs
new file mode 100644
index 00000000..03730c33
--- /dev/null
+++ b/packages/runs/src/runs.mjs
@@ -0,0 +1,72 @@
+import fs from "node:fs";
+import { RunsError } from "./errors.mjs";
+import { RUNS_DIRNAME, isMissing, readJsonObject, resolveInside } from "./paths.mjs";
+
+export const RUN_ID_PATTERN = /^r-[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
+export const RUN_DOCUMENTS = ["result.json", "task.json", "mission.json"];
+
+export function isRunId(value) {
+ return typeof value === "string" && RUN_ID_PATTERN.test(value);
+}
+
+function requireRunId(runId) {
+ if (!isRunId(runId)) throw new RunsError(`invalid run id: ${JSON.stringify(runId)} (expected r-<id>)`);
+}
+
+// Every name under <dataRoot>/runs/ that starts with "r-", sorted. Run ids
+// begin with a UTC stamp, so the order is oldest first. A missing data root
+// or runs directory is an empty list.
+export function listRunIds(dataRoot) {
+ const root = resolveInside(dataRoot, RUNS_DIRNAME);
+ if (root === null) return [];
+ let names;
+ try {
+ names = fs.readdirSync(root);
+ } catch (error) {
+ if (isMissing(error)) return [];
+ throw new RunsError(`cannot read ${root}: ${error.code ?? error.message}`);
+ }
+ return names.filter((name) => name.startsWith("r-")).sort();
+}
+
+// One of RUN_DOCUMENTS from a run, or null when it is missing, unreadable,
+// not a JSON object or outside the data root.
+export function readRunDocument(dataRoot, runId, name) {
+ requireRunId(runId);
+ if (!RUN_DOCUMENTS.includes(name)) throw new RunsError(`unknown run document: ${JSON.stringify(name)}`);
+ return readJsonObject(dataRoot, RUNS_DIRNAME, runId, name);
+}
+
+// [{ runId, result }] for every listed run; result is null for an
+// incomplete or unreadable record. Names come from listRunIds, so a name
+// that starts with "r-" but isn't a valid run id is still listed.
+export function listRunRecords(dataRoot) {
+ return listRunIds(dataRoot).map((runId) => ({
+ runId,
+ result: readJsonObject(dataRoot, RUNS_DIRNAME, runId, "result.json"),
+ }));
+}
+
+// One run's documents and artifact names, or null when the run directory
+// doesn't exist or isn't a directory. Artifacts are in directory order.
+export function readRunRecord(dataRoot, runId) {
+ requireRunId(runId);
+ // The runs directory first, so a refusal names the link that escapes.
+ if (resolveInside(dataRoot, RUNS_DIRNAME) === null) return null;
+ const dir = resolveInside(dataRoot, RUNS_DIRNAME, runId);
+ if (dir === null) return null;
+ let artifacts;
+ try {
+ artifacts = fs.readdirSync(dir);
+ } catch (error) {
+ if (isMissing(error)) return null;
+ throw new RunsError(`cannot read ${dir}: ${error.code ?? error.message}`);
+ }
+ return {
+ runId,
+ result: readRunDocument(dataRoot, runId, "result.json"),
+ task: readRunDocument(dataRoot, runId, "task.json"),
+ mission: readRunDocument(dataRoot, runId, "mission.json"),
+ artifacts,
+ };
+}
diff --git a/packages/runs/src/state.mjs b/packages/runs/src/state.mjs
new file mode 100644
index 00000000..af5cc927
--- /dev/null
+++ b/packages/runs/src/state.mjs
@@ -0,0 +1,83 @@
+import fs from "node:fs";
+import { RunsError } from "./errors.mjs";
+import { STATE_DIRNAME, resolveInside } from "./paths.mjs";
+
+export const POINTER_FILE = "active.json";
+export const ACTIVATION_LOG_FILE = "activation-log.jsonl";
+
+const POINTER_KEYS = ["pointerVersion", "release", "imageTag", "activatedAt"];
+const LOG_KEYS = ["at", "event", "release", "imageTag", "note"];
+
+function isNonEmptyString(value) {
+ return typeof value === "string" && value.length > 0;
+}
+
+function readStateFile(dataRoot, name) {
+ const file = resolveInside(dataRoot, STATE_DIRNAME, name);
+ if (file === null) return null;
+ try {
+ return { file, text: fs.readFileSync(file, "utf8") };
+ } catch (error) {
+ throw new RunsError(`cannot read ${file}: ${error.code ?? error.message}`);
+ }
+}
+
+// The active release pointer that scripts/release.sh writes, or null when
+// no release has been activated. A pointer that isn't the version 1 shape
+// refuses rather than being guessed at.
+export function readActivePointer(dataRoot) {
+ const state = readStateFile(dataRoot, POINTER_FILE);
+ if (state === null) return null;
+ let pointer;
+ try {
+ pointer = JSON.parse(state.text);
+ } catch (error) {
+ throw new RunsError(`release pointer is not valid JSON (${state.file}): ${error.message}`, 2);
+ }
+ const invalid = (why) => new RunsError(`release pointer ${why} (${state.file})`, 2);
+ if (typeof pointer !== "object" || pointer === null || Array.isArray(pointer)) throw invalid("must be a JSON object");
+ for (const key of Object.keys(pointer)) {
+ if (!POINTER_KEYS.includes(key)) throw invalid(`has an unsupported key: "${key}"`);
+ }
+ if (pointer.pointerVersion !== 1) throw invalid(`has unsupported pointerVersion ${JSON.stringify(pointer.pointerVersion)}`);
+ for (const key of ["release", "imageTag", "activatedAt"]) {
+ if (!isNonEmptyString(pointer[key])) throw invalid(`needs a non-empty string "${key}"`);
+ }
+ return { pointerVersion: 1, release: pointer.release, imageTag: pointer.imageTag, activatedAt: pointer.activatedAt };
+}
+
+function logEntry(line) {
+ let entry;
+ try {
+ entry = JSON.parse(line);
+ } catch {
+ return null;
+ }
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return null;
+ if (Object.keys(entry).some((key) => !LOG_KEYS.includes(key))) return null;
+ if (!["at", "event", "release", "imageTag"].every((key) => typeof entry[key] === "string")) return null;
+ if (entry.note !== undefined && typeof entry.note !== "string") return null;
+ return entry;
+}
+
+// The activation log as { entries, malformed }, oldest first. A line that
+// isn't JSON, or is JSON but not the entry shape release.sh writes, is
+// counted in malformed and skipped. This is stricter than release.sh
+// rollback, which skips only lines that aren't JSON. A missing log is empty.
+// With last, only the newest last well-formed entries are returned.
+export function readActivationLog(dataRoot, { last } = {}) {
+ if (last !== undefined && (!Number.isInteger(last) || last < 1)) {
+ throw new RunsError(`last must be a positive integer (got ${JSON.stringify(last)})`);
+ }
+ const state = readStateFile(dataRoot, ACTIVATION_LOG_FILE);
+ if (state === null) return { entries: [], malformed: 0 };
+ const entries = [];
+ let malformed = 0;
+ for (const line of state.text.split("\n")) {
+ if (line.trim() === "") continue;
+ const entry = logEntry(line);
+ if (entry === null) malformed += 1;
+ else entries.push(entry);
+ }
+ return { entries: last === undefined ? entries : entries.slice(-last), malformed };
+}
diff --git a/packages/runs/tests/helpers.mjs b/packages/runs/tests/helpers.mjs
new file mode 100644
index 00000000..428c0721
--- /dev/null
+++ b/packages/runs/tests/helpers.mjs
@@ -0,0 +1,60 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+// A fresh scratch directory for one test, removed after it. Returns
+// { dir, dataRoot, outside }: the data root and a sibling outside it.
+export function scratch(t) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mosaic-runs-test-"));
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const dataRoot = path.join(dir, "data");
+ const outside = path.join(dir, "outside");
+ fs.mkdirSync(dataRoot);
+ fs.mkdirSync(outside);
+ return { dir, dataRoot, outside };
+}
+
+export function write(file, content) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, typeof content === "string" ? content : `${JSON.stringify(content)}\n`);
+}
+
+// A listing of every path under dir with its type, size, mtime and link
+// target, to show that a reader changed nothing.
+export function tree(dir) {
+ const lines = [];
+ const walk = (current) => {
+ for (const name of fs.readdirSync(current).sort()) {
+ const file = path.join(current, name);
+ const stat = fs.lstatSync(file);
+ const link = stat.isSymbolicLink() ? fs.readlinkSync(file) : "";
+ lines.push(`${path.relative(dir, file)} ${stat.mode} ${stat.size} ${stat.mtimeMs} ${link}`);
+ if (stat.isDirectory()) walk(file);
+ }
+ };
+ walk(dir);
+ return lines.join("\n");
+}
+
+export const RESULT = {
+ runVersion: 1,
+ runId: "r-20260101T000000Z-aaaaaa",
+ taskId: "t-one",
+ missionId: null,
+ status: "succeeded",
+ reason: null,
+ request: "hi",
+ response: "hi",
+ expectedExact: null,
+ workspace: null,
+ tools: null,
+ session: null,
+ sessionForkFrom: null,
+ exitCode: 0,
+ signal: null,
+ provider: "zai",
+ model: "m",
+ startedAt: "2026-01-01T00:00:00.000Z",
+ finishedAt: "2026-01-01T00:00:01.000Z",
+ durationMs: 1000,
+};
diff --git a/packages/runs/tests/runs.test.mjs b/packages/runs/tests/runs.test.mjs
new file mode 100644
index 00000000..a652f62d
--- /dev/null
+++ b/packages/runs/tests/runs.test.mjs
@@ -0,0 +1,243 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import {
+ RunsError, isRunId, listRunIds, listRunRecords, readRunDocument, readRunRecord,
+} from "../src/index.mjs";
+import { RESULT, scratch, tree, write } from "./helpers.mjs";
+
+const A = "r-20260101T000000Z-aaaaaa";
+const B = "r-20260101T000100Z-bbbbbb";
+
+test("isRunId accepts the run id shape and nothing else", () => {
+ for (const id of [A, "r-x", "r-zz.weird_name-1", `r-${"a".repeat(64)}`]) assert.equal(isRunId(id), true, id);
+ for (const id of ["r-", "r-.x", "r-_x", `r-${"a".repeat(65)}`, "x-1", "r-a/b", "../r-a", "r-a b", 1, null, undefined]) {
+ assert.equal(isRunId(id), false, String(id));
+ }
+});
+
+test("a missing data root or runs directory lists nothing", (t) => {
+ const { dir, dataRoot } = scratch(t);
+ assert.deepEqual(listRunIds(path.join(dir, "absent")), []);
+ assert.deepEqual(listRunIds(dataRoot), []);
+ assert.deepEqual(listRunRecords(dataRoot), []);
+});
+
+test("runs as a regular file lists nothing, as before", (t) => {
+ const { dataRoot } = scratch(t);
+ write(path.join(dataRoot, "runs"), "not a directory\n");
+ assert.deepEqual(listRunIds(dataRoot), []);
+});
+
+test("listRunIds keeps r- names only, sorted oldest first", (t) => {
+ const { dataRoot } = scratch(t);
+ const runs = path.join(dataRoot, "runs");
+ for (const name of [B, A, "x-other", "r-zz"]) fs.mkdirSync(path.join(runs, name), { recursive: true });
+ write(path.join(runs, ".pruned.log"), "{}\n");
+ write(path.join(runs, "r-a-file"), "x\n");
+ assert.deepEqual(listRunIds(dataRoot), [A, B, "r-a-file", "r-zz"]);
+});
+
+test("listRunRecords returns each result, or null for an incomplete or unreadable one", (t) => {
+ const { dataRoot } = scratch(t);
+ const runs = path.join(dataRoot, "runs");
+ write(path.join(runs, A, "result.json"), RESULT);
+ write(path.join(runs, B, "task.json"), { id: "t" });
+ write(path.join(runs, "r-c", "result.json"), "{ truncated");
+ write(path.join(runs, "r-d", "result.json"), "null\n");
+ write(path.join(runs, "r-e", "result.json"), "5\n");
+ write(path.join(runs, "r-f", "result.json"), "[1]\n");
+ fs.mkdirSync(path.join(runs, "r-g", "result.json"), { recursive: true });
+ write(path.join(runs, "r-h"), "a file\n");
+ const records = listRunRecords(dataRoot);
+ assert.deepEqual(records.map((r) => r.runId), [A, B, "r-c", "r-d", "r-e", "r-f", "r-g", "r-h"]);
+ assert.deepEqual(records[0].result, RESULT);
+ for (const record of records.slice(1)) assert.equal(record.result, null, record.runId);
+});
+
+test("readRunRecord returns documents and artifacts in directory order", (t) => {
+ const { dataRoot } = scratch(t);
+ const dir = path.join(dataRoot, "runs", A);
+ write(path.join(dir, "result.json"), RESULT);
+ write(path.join(dir, "task.json"), { taskVersion: 1, id: "t-one" });
+ write(path.join(dir, "mission.json"), { id: "m", objective: "o" });
+ write(path.join(dir, "stderr.txt"), "");
+ fs.mkdirSync(path.join(dir, "workspace"));
+ const record = readRunRecord(dataRoot, A);
+ assert.equal(record.runId, A);
+ assert.deepEqual(record.result, RESULT);
+ assert.deepEqual(record.task, { taskVersion: 1, id: "t-one" });
+ assert.deepEqual(record.mission, { id: "m", objective: "o" });
+ assert.deepEqual(record.artifacts, fs.readdirSync(dir));
+});
+
+test("readRunRecord is null for a missing run, a dangling link or a file", (t) => {
+ const { dataRoot } = scratch(t);
+ const runs = path.join(dataRoot, "runs");
+ assert.equal(readRunRecord(dataRoot, A), null);
+ fs.mkdirSync(runs);
+ fs.symlinkSync("r-nowhere", path.join(runs, A));
+ write(path.join(runs, B), "a file\n");
+ assert.equal(readRunRecord(dataRoot, A), null);
+ assert.equal(readRunRecord(dataRoot, B), null);
+});
+
+test("readRunRecord and readRunDocument refuse an invalid run id before touching the disk", (t) => {
+ const { dataRoot } = scratch(t);
+ for (const id of ["../data", "r-a/../../x", "", "r-"]) {
+ assert.throws(() => readRunRecord(dataRoot, id), (e) => e instanceof RunsError && e.exitCode === 4 && /invalid run id/.test(e.message));
+ assert.throws(() => readRunDocument(dataRoot, id, "result.json"), RunsError);
+ }
+});
+
+test("readRunDocument reads only the three run documents", (t) => {
+ const { dataRoot } = scratch(t);
+ write(path.join(dataRoot, "runs", A, "stderr.txt"), "{}\n");
+ assert.throws(() => readRunDocument(dataRoot, A, "stderr.txt"), /unknown run document/);
+ assert.throws(() => readRunDocument(dataRoot, A, "../../x.json"), /unknown run document/);
+ assert.equal(readRunDocument(dataRoot, A, "task.json"), null);
+});
+
+test("a link inside the data root is followed", (t) => {
+ const { dataRoot } = scratch(t);
+ const runs = path.join(dataRoot, "runs");
+ write(path.join(runs, A, "result.json"), RESULT);
+ fs.symlinkSync(A, path.join(runs, B));
+ assert.deepEqual(readRunRecord(dataRoot, B).result, RESULT);
+ assert.deepEqual(listRunRecords(dataRoot)[1], { runId: B, result: RESULT });
+});
+
+test("a data root that is itself a link is trusted as configured", (t) => {
+ const { dir, dataRoot } = scratch(t);
+ write(path.join(dataRoot, "runs", A, "result.json"), RESULT);
+ const alias = path.join(dir, "alias");
+ fs.symlinkSync(dataRoot, alias);
+ assert.deepEqual(listRunRecords(alias), [{ runId: A, result: RESULT }]);
+});
+
+test("a run directory linked out of the data root is never read", (t) => {
+ const { dataRoot, outside } = scratch(t);
+ write(path.join(outside, "result.json"), RESULT);
+ write(path.join(outside, "task.json"), { id: "t" });
+ fs.mkdirSync(path.join(dataRoot, "runs"));
+ fs.symlinkSync(outside, path.join(dataRoot, "runs", A));
+ assert.deepEqual(listRunRecords(dataRoot), [{ runId: A, result: null }]);
+ assert.throws(() => readRunRecord(dataRoot, A), (e) => e instanceof RunsError && e.exitCode === 4 && /resolves outside the data root/.test(e.message));
+});
+
+test("a document linked out of the data root reads as null", (t) => {
+ const { dataRoot, outside } = scratch(t);
+ write(path.join(outside, "secret.json"), RESULT);
+ fs.mkdirSync(path.join(dataRoot, "runs", A), { recursive: true });
+ for (const name of ["result.json", "task.json", "mission.json"]) {
+ fs.symlinkSync(path.join(outside, "secret.json"), path.join(dataRoot, "runs", A, name));
+ }
+ const record = readRunRecord(dataRoot, A);
+ assert.equal(record.result, null);
+ assert.equal(record.task, null);
+ assert.equal(record.mission, null);
+ assert.deepEqual(listRunRecords(dataRoot), [{ runId: A, result: null }]);
+});
+
+test("a runs directory linked out of the data root refuses", (t) => {
+ const { dataRoot, outside } = scratch(t);
+ write(path.join(outside, A, "result.json"), RESULT);
+ fs.symlinkSync(outside, path.join(dataRoot, "runs"));
+ assert.throws(() => listRunIds(dataRoot), /runs resolves outside the data root/);
+ assert.throws(() => listRunRecords(dataRoot), RunsError);
+ assert.throws(() => readRunRecord(dataRoot, A), /runs resolves outside the data root/);
+});
+
+test("a relative link that climbs out of the data root refuses", (t) => {
+ const { dataRoot } = scratch(t);
+ fs.mkdirSync(path.join(dataRoot, "runs"));
+ fs.symlinkSync("../../outside", path.join(dataRoot, "runs", A));
+ assert.throws(() => readRunRecord(dataRoot, A), /resolves outside the data root/);
+});
+
+test("a sibling whose name starts with the data root's name is outside it", (t) => {
+ const { dir, dataRoot } = scratch(t);
+ const sibling = path.join(dir, "data-sibling");
+ write(path.join(sibling, "result.json"), RESULT);
+ fs.mkdirSync(path.join(dataRoot, "runs"));
+ fs.symlinkSync(sibling, path.join(dataRoot, "runs", A));
+ assert.throws(() => readRunRecord(dataRoot, A), /resolves outside the data root/);
+});
+
+test("a link loop refuses instead of reading as missing", (t) => {
+ const { dataRoot } = scratch(t);
+ fs.mkdirSync(path.join(dataRoot, "runs"));
+ fs.symlinkSync(B, path.join(dataRoot, "runs", A));
+ fs.symlinkSync(A, path.join(dataRoot, "runs", B));
+ assert.throws(() => readRunRecord(dataRoot, A), (e) => e instanceof RunsError && /ELOOP/.test(e.message));
+ assert.deepEqual(listRunRecords(dataRoot), [{ runId: A, result: null }, { runId: B, result: null }]);
+});
+
+test("an unreadable runs directory refuses instead of listing nothing", { skip: process.getuid?.() === 0 && "root reads anything" }, (t) => {
+ const { dataRoot } = scratch(t);
+ const runs = path.join(dataRoot, "runs");
+ fs.mkdirSync(path.join(runs, A), { recursive: true });
+ fs.chmodSync(runs, 0o000);
+ try {
+ assert.throws(() => listRunIds(dataRoot), (e) => e instanceof RunsError && /EACCES/.test(e.message));
+ } finally {
+ fs.chmodSync(runs, 0o755);
+ }
+});
+
+test("an unreadable run directory refuses instead of reading as missing", { skip: process.getuid?.() === 0 && "root reads anything" }, (t) => {
+ const { dataRoot } = scratch(t);
+ const run = path.join(dataRoot, "runs", A);
+ write(path.join(run, "result.json"), RESULT);
+ fs.chmodSync(run, 0o000);
+ try {
+ assert.throws(() => readRunRecord(dataRoot, A), (e) => e instanceof RunsError && /EACCES/.test(e.message));
+ } finally {
+ fs.chmodSync(run, 0o755);
+ }
+});
+
+test("a link to the data root's parent is outside it", (t) => {
+ const { dataRoot } = scratch(t);
+ fs.mkdirSync(path.join(dataRoot, "runs"));
+ fs.symlinkSync("../..", path.join(dataRoot, "runs", A));
+ assert.throws(() => readRunRecord(dataRoot, A), /resolves outside the data root/);
+});
+
+// readdir order is the filesystem's; node may already return it sorted, so
+// the directory listing is mocked to come back reversed.
+test("listRunIds sorts whatever order the directory returns", (t) => {
+ const { dataRoot } = scratch(t);
+ for (const id of [A, B]) fs.mkdirSync(path.join(dataRoot, "runs", id), { recursive: true });
+ const readdirSync = fs.readdirSync;
+ t.mock.method(fs, "readdirSync", (...args) => readdirSync(...args).sort().reverse());
+ assert.deepEqual(fs.readdirSync(path.join(dataRoot, "runs")), [B, A]);
+ assert.deepEqual(listRunIds(dataRoot), [A, B]);
+});
+
+// list has always shown any r- name, including ones show refuses as ids.
+test("listRunRecords lists an r- name that isn't a valid run id, as before", (t) => {
+ const { dataRoot } = scratch(t);
+ write(path.join(dataRoot, "runs", "r-.bad", "result.json"), {});
+ assert.equal(isRunId("r-.bad"), false);
+ assert.deepEqual(listRunIds(dataRoot), ["r-.bad"]);
+ assert.deepEqual(listRunRecords(dataRoot), [{ runId: "r-.bad", result: {} }]);
+});
+
+test("the readers write nothing", (t) => {
+ const { dataRoot } = scratch(t);
+ const runs = path.join(dataRoot, "runs");
+ write(path.join(runs, A, "result.json"), RESULT);
+ write(path.join(runs, A, "task.json"), { id: "t" });
+ write(path.join(runs, B, "result.json"), "{ bad");
+ fs.symlinkSync(A, path.join(runs, "r-link"));
+ const before = tree(dataRoot);
+ listRunIds(dataRoot);
+ listRunRecords(dataRoot);
+ readRunRecord(dataRoot, A);
+ readRunRecord(dataRoot, B);
+ readRunRecord(dataRoot, "r-absent");
+ readRunDocument(dataRoot, A, "mission.json");
+ assert.equal(tree(dataRoot), before);
+});
diff --git a/packages/runs/tests/state.test.mjs b/packages/runs/tests/state.test.mjs
new file mode 100644
index 00000000..4b126f31
--- /dev/null
+++ b/packages/runs/tests/state.test.mjs
@@ -0,0 +1,131 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { RunsError, readActivationLog, readActivePointer } from "../src/index.mjs";
+import { scratch, tree, write } from "./helpers.mjs";
+
+const POINTER = { pointerVersion: 1, release: "0.0.12", imageTag: "mosaic-poc-agent:0.84.4-r0.0.12", activatedAt: "2026-09-03T20:58:15Z" };
+
+function entry(at, event, release, extra = {}) {
+ return { at, event, release, imageTag: `mosaic-poc-agent:0.84.4-r${release}`, ...extra };
+}
+
+function writeLog(dataRoot, lines) {
+ write(path.join(dataRoot, "state", "activation-log.jsonl"), lines.map((l) => (typeof l === "string" ? l : JSON.stringify(l))).join("\n") + "\n");
+}
+
+test("no pointer is null", (t) => {
+ const { dir, dataRoot } = scratch(t);
+ assert.equal(readActivePointer(dataRoot), null);
+ assert.equal(readActivePointer(path.join(dir, "absent")), null);
+});
+
+test("the pointer release.sh writes reads back", (t) => {
+ const { dataRoot } = scratch(t);
+ // The exact bytes of release.sh's printf.
+ write(path.join(dataRoot, "state", "active.json"),
+ '{"pointerVersion":1,"release":"0.0.12","imageTag":"mosaic-poc-agent:0.84.4-r0.0.12","activatedAt":"2026-09-03T20:58:15Z"}\n');
+ assert.deepEqual(readActivePointer(dataRoot), POINTER);
+});
+
+test("a pointer that isn't the version 1 shape refuses with exit 2", (t) => {
+ const { dataRoot } = scratch(t);
+ const file = path.join(dataRoot, "state", "active.json");
+ const cases = [
+ ["{ truncated", /not valid JSON/],
+ ["[]", /must be a JSON object/],
+ ["null", /must be a JSON object/],
+ [{ ...POINTER, extra: 1 }, /unsupported key: "extra"/],
+ [{ ...POINTER, pointerVersion: 2 }, /unsupported pointerVersion 2/],
+ [{ ...POINTER, release: "" }, /non-empty string "release"/],
+ [{ ...POINTER, imageTag: 5 }, /non-empty string "imageTag"/],
+ [{ pointerVersion: 1, release: "0.0.1", imageTag: "x" }, /non-empty string "activatedAt"/],
+ ];
+ for (const [content, pattern] of cases) {
+ write(file, content);
+ assert.throws(() => readActivePointer(dataRoot), (e) => e instanceof RunsError && e.exitCode === 2 && pattern.test(e.message), String(pattern));
+ }
+});
+
+test("a pointer that is a directory refuses with exit 4", (t) => {
+ const { dataRoot } = scratch(t);
+ fs.mkdirSync(path.join(dataRoot, "state", "active.json"), { recursive: true });
+ assert.throws(() => readActivePointer(dataRoot), (e) => e instanceof RunsError && e.exitCode === 4 && /EISDIR/.test(e.message));
+});
+
+test("a state file linked out of the data root refuses", (t) => {
+ const { dataRoot, outside } = scratch(t);
+ write(path.join(outside, "active.json"), POINTER);
+ write(path.join(outside, "log.jsonl"), `${JSON.stringify(entry("a", "activate", "0.0.1"))}\n`);
+ fs.mkdirSync(path.join(dataRoot, "state"));
+ fs.symlinkSync(path.join(outside, "active.json"), path.join(dataRoot, "state", "active.json"));
+ fs.symlinkSync(path.join(outside, "log.jsonl"), path.join(dataRoot, "state", "activation-log.jsonl"));
+ assert.throws(() => readActivePointer(dataRoot), /resolves outside the data root/);
+ assert.throws(() => readActivationLog(dataRoot), /resolves outside the data root/);
+});
+
+test("a state directory linked out of the data root refuses", (t) => {
+ const { dataRoot, outside } = scratch(t);
+ write(path.join(outside, "active.json"), POINTER);
+ fs.symlinkSync(outside, path.join(dataRoot, "state"));
+ assert.throws(() => readActivePointer(dataRoot), /state\/active.json resolves outside the data root/);
+});
+
+test("a state directory linked out to nothing reads as no release and an empty log", (t) => {
+ const { dataRoot, outside } = scratch(t);
+ fs.symlinkSync(path.join(outside, "absent"), path.join(dataRoot, "state"));
+ assert.equal(readActivePointer(dataRoot), null);
+ assert.deepEqual(readActivationLog(dataRoot), { entries: [], malformed: 0 });
+});
+
+test("a missing log is empty", (t) => {
+ const { dataRoot } = scratch(t);
+ assert.deepEqual(readActivationLog(dataRoot), { entries: [], malformed: 0 });
+});
+
+test("the log reads oldest first and counts malformed lines", (t) => {
+ const { dataRoot } = scratch(t);
+ const good = [
+ entry("2026-09-03T20:57:00Z", "package", "0.0.12"),
+ entry("2026-09-03T20:57:47Z", "activate", "0.0.12"),
+ entry("2026-09-03T20:57:50Z", "refused", "0.0.11", { note: "health check failed (fault-injected)" }),
+ entry("2026-09-03T20:58:15Z", "rollback", "0.0.11"),
+ ];
+ writeLog(dataRoot, [
+ good[0],
+ "{ torn line",
+ good[1],
+ "",
+ " ",
+ "[]",
+ { ...good[1], extra: true },
+ { ...good[1], at: 5 },
+ { ...good[1], note: null },
+ { at: good[1].at, event: good[1].event, release: good[1].release },
+ { ...good[1], release: 5 },
+ good[2],
+ good[3],
+ ]);
+ assert.deepEqual(readActivationLog(dataRoot), { entries: good, malformed: 7 });
+ assert.deepEqual(readActivationLog(dataRoot, { last: 2 }), { entries: good.slice(2), malformed: 7 });
+ assert.deepEqual(readActivationLog(dataRoot, { last: 99 }).entries, good);
+});
+
+test("last must be a positive integer", (t) => {
+ const { dataRoot } = scratch(t);
+ for (const last of [0, -1, 1.5, "2", null]) {
+ assert.throws(() => readActivationLog(dataRoot, { last }), /last must be a positive integer/, String(last));
+ }
+});
+
+test("the state readers write nothing", (t) => {
+ const { dataRoot } = scratch(t);
+ write(path.join(dataRoot, "state", "active.json"), POINTER);
+ writeLog(dataRoot, [entry("a", "activate", "0.0.1"), "{ bad"]);
+ const before = tree(dataRoot);
+ readActivePointer(dataRoot);
+ readActivationLog(dataRoot);
+ readActivationLog(dataRoot, { last: 1 });
+ assert.equal(tree(dataRoot), before);
+});
diff --git a/packages/runs/tests/task-cli.test.mjs b/packages/runs/tests/task-cli.test.mjs
new file mode 100644
index 00000000..ed13ecde
--- /dev/null
+++ b/packages/runs/tests/task-cli.test.mjs
@@ -0,0 +1,111 @@
+// scripts/mosaic-task.mjs list and show read through this package. These
+// spawn the real script against a seeded data root and a scratch config.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { spawnSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import { RESULT, scratch, write } from "./helpers.mjs";
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
+const A = "r-20260101T000000Z-aaaaaa";
+const B = "r-20260101T000100Z-bbbbbb";
+
+function task(t, dataRoot, ...args) {
+ const config = path.join(path.dirname(dataRoot), "config.json");
+ write(config, { configVersion: 1, environment: "development", dataRoot, execution: { backend: "docker", provider: "zai", model: "m" } });
+ const env = { ...process.env, MOSAIC_CONFIG: config };
+ delete env.NODE_TEST_CONTEXT;
+ const proc = spawnSync(process.execPath, [path.join(ROOT, "scripts", "mosaic-task.mjs"), ...args], { cwd: ROOT, env, encoding: "utf8" });
+ return { status: proc.status, stdout: proc.stdout, stderr: proc.stderr };
+}
+
+test("list prints each run in the established format", (t) => {
+ const { dataRoot } = scratch(t);
+ write(path.join(dataRoot, "runs", A, "result.json"), { ...RESULT, workspace: "ws1", session: "s1" });
+ write(path.join(dataRoot, "runs", B, "task.json"), { id: "t" });
+ const out = task(t, dataRoot, "list");
+ assert.equal(out.status, 0, out.stderr);
+ assert.equal(out.stdout,
+ `${A} succeeded task=t-one ws=ws1 session=s1\n` +
+ `${B} unknown task=- ws=- session=-\n`);
+});
+
+test("show prints the run in the established format", (t) => {
+ const { dataRoot } = scratch(t);
+ const dir = path.join(dataRoot, "runs", A);
+ write(path.join(dir, "result.json"), { ...RESULT, missionId: "m", tools: ["read"], expectedExact: "hi" });
+ write(path.join(dir, "mission.json"), { id: "m", objective: "obj" });
+ const out = task(t, dataRoot, "show", A);
+ assert.equal(out.status, 0, out.stderr);
+ assert.equal(out.stdout, [
+ `run: ${A}`,
+ "status: succeeded",
+ "task: t-one",
+ "mission: m",
+ "tools: read",
+ "adapter: (see config) provider=zai model=m",
+ 'request: "hi"',
+ 'response: "hi"',
+ 'expected: "hi"',
+ "timing: 2026-01-01T00:00:00.000Z -> 2026-01-01T00:00:01.000Z (1000 ms)",
+ "exit: 0",
+ "mission snapshot: m - obj",
+ `artifacts: ${fs.readdirSync(dir).join(", ")}`,
+ "",
+ ].join("\n"));
+});
+
+test("show of a missing run and an invalid id exit 4 as before", (t) => {
+ const { dataRoot } = scratch(t);
+ const missing = task(t, dataRoot, "show", A);
+ assert.equal(missing.status, 4);
+ assert.equal(missing.stderr, `mosaic-task: run not found: ${A} (under ${path.join(dataRoot, "runs")})\n`);
+ const invalid = task(t, dataRoot, "show", "../x");
+ assert.equal(invalid.status, 4);
+ assert.equal(invalid.stderr, 'mosaic-task: invalid run id: "../x" (expected r-<id>)\n');
+});
+
+test("list and show refuse a runs directory linked out of the data root", (t) => {
+ const { dataRoot, outside } = scratch(t);
+ write(path.join(outside, A, "result.json"), RESULT);
+ fs.symlinkSync(outside, path.join(dataRoot, "runs"));
+ for (const args of [["list"], ["show", A]]) {
+ const out = task(t, dataRoot, ...args);
+ assert.equal(out.status, 4, args.join(" "));
+ assert.equal(out.stdout, "");
+ assert.match(out.stderr, /^mosaic-task: .*runs resolves outside the data root/);
+ }
+});
+
+test("show refuses a run linked out of the data root; list reports it unknown", (t) => {
+ const { dataRoot, outside } = scratch(t);
+ write(path.join(outside, "result.json"), RESULT);
+ fs.mkdirSync(path.join(dataRoot, "runs"));
+ fs.symlinkSync(outside, path.join(dataRoot, "runs", A));
+ const show = task(t, dataRoot, "show", A);
+ assert.equal(show.status, 4);
+ assert.equal(show.stdout, "");
+ assert.match(show.stderr, /resolves outside the data root/);
+ const list = task(t, dataRoot, "list");
+ assert.equal(list.status, 0, list.stderr);
+ assert.equal(list.stdout, `${A} unknown task=- ws=- session=-\n`);
+});
+
+test("list shows an r- name that isn't a valid run id, as before", (t) => {
+ const { dataRoot } = scratch(t);
+ write(path.join(dataRoot, "runs", "r-.bad", "result.json"), RESULT);
+ const out = task(t, dataRoot, "list");
+ assert.equal(out.status, 0, out.stderr);
+ assert.equal(out.stdout, "r-.bad succeeded task=t-one ws=- session=-\n");
+});
+
+test("show refuses an invalid id before reading the config", (t) => {
+ const { dir } = scratch(t);
+ const env = { ...process.env, MOSAIC_CONFIG: path.join(dir, "missing.json") };
+ delete env.NODE_TEST_CONTEXT;
+ const proc = spawnSync(process.execPath, [path.join(ROOT, "scripts", "mosaic-task.mjs"), "show", "../x"], { cwd: ROOT, env, encoding: "utf8" });
+ assert.equal(proc.status, 4, proc.stderr);
+ assert.equal(proc.stderr, 'mosaic-task: invalid run id: "../x" (expected r-<id>)\n');
+});
diff --git a/scripts/mosaic-task.mjs b/scripts/mosaic-task.mjs
index 6d10ef0a..cf8399a0 100755
--- a/scripts/mosaic-task.mjs
+++ b/scripts/mosaic-task.mjs
@@ -35,6 +35,7 @@ import { randomBytes } from "node:crypto";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { BusinessError, validateRoleDocument } from "../packages/business/src/index.mjs";
+import { RunsError, isRunId, listRunRecords, readRunRecord } from "../packages/runs/src/index.mjs";
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const RUNS_DIRNAME = "runs";
@@ -457,53 +458,43 @@ function runTask(taskFile, options = {}) {
process.exit(status === "succeeded" ? 0 : 1);
}
-function listRuns() {
- const resolved = loadConfig();
- const root = runsRoot(resolved);
- let entries = [];
+function readRuns(read) {
try {
- entries = fs.readdirSync(root).filter((name) => name.startsWith("r-")).sort();
- } catch {
- // No runs yet.
+ return read();
+ } catch (error) {
+ if (error instanceof RunsError) fail(error.exitCode, error.message);
+ throw error;
}
- for (const runId of entries) {
+}
+
+function listRuns() {
+ const resolved = loadConfig();
+ for (const { runId, result } of readRuns(() => listRunRecords(resolved.dataRoot))) {
+ // An incomplete or unreadable run record (result null) reports as unknown.
let status = "unknown";
let taskId = "-";
let workspace = "-";
let session = "-";
- try {
- const result = JSON.parse(fs.readFileSync(path.join(root, runId, "result.json"), "utf8"));
+ if (result) {
status = result.status;
taskId = result.taskId;
workspace = result.workspace ?? "-";
session = result.session ?? "-";
- } catch {
- // Incomplete run record; report as unknown.
}
process.stdout.write(`${runId} ${status.padEnd(9)} task=${taskId.padEnd(18)} ws=${String(workspace).padEnd(10)} session=${session}\n`);
}
}
function showRun(runId) {
- if (!/^r-[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(runId)) {
+ if (!isRunId(runId)) {
fail(4, `invalid run id: ${JSON.stringify(runId)} (expected r-<id>)`);
}
const resolved = loadConfig();
- const dir = path.join(runsRoot(resolved), runId);
- if (!fs.existsSync(dir)) {
+ const record = readRuns(() => readRunRecord(resolved.dataRoot, runId));
+ if (record === null) {
fail(4, `run not found: ${runId} (under ${runsRoot(resolved)})`);
}
-
- const read = (name) => {
- try {
- return JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
- } catch {
- return null;
- }
- };
- const result = read("result.json");
- const task = read("task.json");
- const mission = read("mission.json");
+ const { result, task, mission } = record;
process.stdout.write(`run: ${runId}\n`);
if (result) {
@@ -529,7 +520,7 @@ function showRun(runId) {
}
if (task) process.stdout.write(`task snapshot: ${"task.json"} present\n`);
if (mission) process.stdout.write(`mission snapshot: ${mission.id} - ${mission.objective}\n`);
- process.stdout.write(`artifacts: ${fs.readdirSync(dir).map((f) => `${f}`).join(", ")}\n`);
+ process.stdout.write(`artifacts: ${record.artifacts.join(", ")}\n`);
process.exit(0);
}