diff --git a/agents/rocko/work/queue-56/BUILD.md b/agents/rocko/work/queue-56/BUILD.md new file mode 100644 index 00000000..99c66858 --- /dev/null +++ b/agents/rocko/work/queue-56/BUILD.md @@ -0,0 +1,258 @@ +# Row 56 (#1545): runs and releases reader module, candidate packet, round 2 + +Author: Rocko. Reviewers: Darkwing and Filbert. Brief: +`docs/plans/2026-10-10_design-implementation.md`, section "Runs and releases +reader module". Base: `75ab1646` (`base.txt`), the origin/refactor Sage +pushed with the round 1 review records. Round 1 was candidate `ed3c5392` +over `dc96f87b`. No file in this candidate changed upstream between the two +bases. The packet is uncommitted. I made no commits, pushes or Gitea calls, +and read no token or private binding. + +The round 2 section comes first. The round 1 text follows and still holds +except where round 2 corrects it; the corrections are marked in place. + +## Round 2 changes (against ed3c5392) + +Round 1 was approved by Darkwing (#1545 comment 27115, rev 348) and +Filbert (comment 27116). Sage ruled round 2 in: tests and wording only. +No `src/` logic changes. `scripts/mosaic-task.mjs` is byte-identical to +round 1. `out/round2-delta.diff` is the exact diff of the six files that +changed: + +| File | Change | Answers | +|---|---|---| +| `packages/runs/tests/task-cli.test.mjs` | +2 tests: `show ../x` with a missing config exits 4 "invalid run id", not 3; `list` shows `r-.bad` as before | Darkwing 1 (M31), Darkwing 2 | +| `packages/runs/tests/runs.test.mjs` | +1 test: `listRunIds` and `listRunRecords` on `r-.bad` give `["r-.bad"]` and `[{ runId: "r-.bad", result: {} }]`, the same as base | Darkwing 2 (D17, D20) | +| `packages/runs/tests/state.test.mjs` | malformed log lines gain an entry with no `imageTag` and `{ ...good[1], release: 5 }`, so malformed goes 5 → 7; +1 test: a `state/` link out to nothing reads as no release and an empty log | Darkwing 3 (D22), Filbert N2, Darkwing 4a | +| `packages/runs/src/state.mjs` | comment above `readActivationLog` only: says what is skipped and that rollback skips only lines that aren't JSON | Filbert N1, Darkwing 4b | +| `packages/runs/README.md` | links with nothing behind them read as missing, `state/` included; the activation log bullet says what the module skips against rollback; a delta row for `show` on a mode-000 run directory | Darkwing 4a, 4b, 4c; Filbert N1 | +| `agents/rocko/work/queue-56/delta-check.sh` | + the case "run directory unreadable: show" | Darkwing 4c | + +Rollback's actual rule, from `scripts/release.sh` lines 91–99: it drops +empty lines, skips lines that `JSON.parse` rejects, and acts on any parsed +`activate` or `rollback` entry with a truthy `imageTag`. The module keeps +its strict entry shape, so a line rollback would use can count as +malformed here. + +### Round 2 evidence (scratch worktree of the round 2 candidate at 75ab1646) + +- packages/runs: 41/41 (`out/runs-tests.log`). +- test-task: 98 passed, 0 failed, with Docker (`out/gate/test-task.log`). + The other suites aren't rerun, because round 2 changes only package + tests, comments, docs and the check script; round 1's results for them + stand. +- byte-check against a base worktree at 75ab1646: 16 cases, stdout, + stderr and exit identical; data root unchanged (`out/byte-check.txt`). +- delta-check: 13 cases now (`out/delta-check.txt`). The new one: + base `show` printed `run:` and `result.json: (missing or unreadable)` + and then crashed in `readdirSync`, exit 1; the candidate refuses with + `cannot read …: EACCES`, exit 4. +- mutants: 37 (`out/mutants.sh`, `out/mutants.txt`). Round 1's 32, plus + Darkwing's D17, D20 and D22, Filbert's N2, and S1 (the dangling `state/` + link reading as missing). 36 are killed. Only M04 survives, and it is + equivalent (below). Each new kill comes from an assertion in the new + tests, not a syntax or reference error. +- `build.patch`: `git diff --cached --binary 75ab1646`, 15 files, + +1068/−27. It applies to 75ab1646 and the manifest matches + (`out/apply-75ab1646.txt`). + +### Correction: M31 was never equivalent + +Round 1 called M31 equivalent: "`readRunRecord` throws the same message". +That is wrong. The `isRunId` check in `showRun` is the only id check that +runs before `loadConfig`. Without it, `show ../x` with a missing config +exits 3 with a configuration problem instead of 4 "invalid run id" +(Darkwing's probe P1). The round 1 paragraph even said the check stays "so +that an invalid id refuses before the config loads", which contradicted +its own verdict. No test covered that case. The new CLI test does, and +M31 is killed. + +# Round 1 (candidate ed3c5392 over dc96f87b) + +Round 1 evidence is under `out/round1/`. Paths below that start `out/` mean `out/round1/` in this packet. +The round 1 packet files are in `r1/`, unchanged, so the posted hashes still check: +`r1/build.patch` dd7b469b…, `r1/candidate-manifest.sha256` ed3c5392… and +`r1/packet-manifest.sha256` e0497ad1…. That manifest lists the round 1 +paths, so it checks against the round 1 tree, not this directory. + +## Files (`files.txt`, 15) + +| File | Change | +|---|---| +| `packages/runs/package.json` | new: `@mosaic/runs`, private, no dependencies | +| `packages/runs/src/errors.mjs` | new: `RunsError(message, exitCode = 4)` | +| `packages/runs/src/paths.mjs` | new: `resolveInside` containment, `readJsonObject` | +| `packages/runs/src/runs.mjs` | new: `isRunId`, `listRunIds`, `listRunRecords`, `readRunDocument`, `readRunRecord` | +| `packages/runs/src/state.mjs` | new: `readActivePointer`, `readActivationLog` | +| `packages/runs/src/index.mjs` | new: re-exports | +| `packages/runs/README.md` | new: what it reads, the limits, the deliberate deltas | +| `packages/runs/tests/*.mjs` | new: 37 tests in round 1 (runs 22, state 10, task-cli 5) plus helpers; 41 in round 2 (runs 23, state 11, task-cli 7) | +| `scripts/mosaic-task.mjs` | `list` and `show` read through the module, +18/−27 | +| `agents/rocko/work/queue-56/{seed,byte-check,delta-check}.sh` | the byte-identity and delta checks | + +`build.patch` is `git diff --cached --binary dc96f87b` over those files: ++1021/−27 in round 1. Round 2's `build.patch` is over 75ab1646, +1068/−27. `candidate-manifest.sha256` hashes the 15 files. + +Q14 freeze: nothing under `packages/bus`, `packages/tasks`, `packages/cli`, +`scripts/bus-service.sh` or `scripts/mosaic` changes. Apart from the import, +`scripts/mosaic-task.mjs` is the only file under `scripts/` that changes. The +module covers the runs view's needs, but the view itself, `release.sh` and +pruning are untouched. + +## Design + +- **Containment.** `resolveInside` runs `realpathSync` on the data root and + the target, then applies a `path.relative` check: + - `..`, `../…` or an absolute relative path counts as outside; + - the configured data root is trusted even when it is itself a link; + - links that stay inside it are followed. +- **Outside paths.** + - `runs/`, `state/`, a run directory read by `readRunRecord`, `active.json` + and the activation log refuse when they resolve outside. + - A run document that resolves outside reads as `null`, the way an + unreadable one always has. +- **Missing versus broken.** ENOENT and ENOTDIR read as missing. Anything + else (ELOOP, EACCES) refuses with the error code instead of reading as + empty. +- **Documents.** A run document is a JSON object or `null`. Run records are + not validated further, because older records carry older shapes. +- **Release pointer.** `active.json` is strict: exactly the version 1 shape + `release.sh` writes. Bad data exits 2, and a read error exits 4. +- **Activation log.** The log is lenient per line, like `release.sh + rollback`. It returns `{ entries, malformed }`, and `last` keeps the newest + entries. + **Corrected in round 2:** not "like rollback". Rollback skips only + lines that aren't JSON; this module also counts wrong-shaped entries. +- **Errors.** Every refusal is a `RunsError` with exit code 2 or 4. + `mosaic-task.mjs` maps it to `fail(exitCode, message)` through + `readRuns`. +- **Read-only.** No function writes. The "readers write nothing" test and + byte-check's before/after snapshot of the data root both check this. + +## Gate (`out/gate/`, sequential, scratch worktree of the candidate) + +| Suite | Result | +|---|---| +| packages/runs tests | 34/34 at gate time; 37/37 after the mutant round (`out/runs-tests-final.log`) | +| test-auth | 15 passed, 0 failed | +| test-conductor | 17 passed, 0 failed | +| test-config | 24 passed, 0 failed | +| test-discord | first run 57 passed, 1 failed; rerun 66 passed, 0 failed (below) | +| test-extension-package | 18 passed, 0 failed | +| test-foundation | 44 passed, 0 failed | +| test-queue | 27 passed, 0 failed | +| test-release | 14 passed, 0 failed | +| test-task | 98 passed, 0 failed | + +**test-discord's first-run failure was environmental.** The failing case was +"pi binary present at node_modules/.bin/pi for the extension checks". The +scratch worktree has no `node_modules`, and the base worktree lacks it too. +I symlinked the checkout's `node_modules` into the scratch tree and reran +the suite: 66 passed, 0 failed. The extra 8 cases are the extension checks +that the missing binary had skipped. Then I removed the symlink +(`out/gate/test-discord-r2.log`). Nothing in this candidate touches +packages/discord. Sage's rerun in a tree that has `node_modules` should see +66. + +After the gate, the only changes were tests, the README table row and the +check scripts. No `src/` or `scripts/mosaic-task.mjs` change came after it. + +## Byte identity (`out/byte-check.txt`) + +`byte-check.sh BASE CAND WORK` seeds a data root with `seed.sh`. The seed +holds: + +- runs a–f; +- a run that is a file; +- an internal link and a dangling link; +- `r-zz.weird_name-1`; +- a non-`r-` name, `.pruned.log` and a stray file. + +It runs 16 cases from both trees: `list` on seeded, empty and absent roots, +and `show` on each run, a missing run, `../escape`, no argument and an +absent root. It diffs stdout, stderr and exit codes. + +``` +byte-check: 16 cases, stdout, stderr and exit identical +byte-check: data root unchanged +``` + +**Correction to my own harness.** My first rerun in this round passed a +relative WORK_DIR. Every redirect inside the `cd` subshell failed, both trees +recorded exit 1, and the diff still reported "identical": a false pass. +Earlier runs used absolute paths and were not affected. Both scripts now +make WORK_DIR absolute before doing anything else. `byte-check.sh` also +exits 4 if any case leaves no stdout or stderr file. The output above +comes from the fixed script. + +## Deliberate deltas (`out/delta-check.txt`) + +These cases fall outside the byte-identity domain: a link out of the data +root, a document that is JSON but not an object, a run that is a file, a +link loop, and an unreadable directory. Each one changes on purpose. The +README table lists them, and `delta-check.sh` prints base against candidate: + +- run directory linked out: `list` shows `unknown`; `show` refuses, exit 4. +- `result.json` linked out: `list` shows `unknown`; `show` prints + `(missing or unreadable)`. +- `runs/` linked out: `list` and `show` refuse, exit 4. +- `result.json` is `5`: base `list` crashed (exit 1); base `show` printed + `undefined` fields. The candidate gives `unknown` and + `(missing or unreadable)`. +- `task.json` is `[]`: base printed the snapshot line; the candidate + omits it. +- run is a regular file: base `show` crashed in `readdirSync`; the + candidate gives `run not found`, exit 4. +- link loop: base said `run not found`; the candidate refuses with ELOOP, + exit 4. +- `runs/` unreadable: base `list` printed nothing, exit 0; the candidate + refuses with EACCES, exit 4. + +## Mutants (`out/mutants.sh`, `out/mutants.txt`) + +I ran 32 single-line mutants of `src/` and `scripts/mosaic-task.mjs` +against the packages/runs tests. + +First round: 26 killed, 4 survived and 2 didn't apply. Three of the +survivors were real gaps, and I added a test for each: + +- **M02** dropped the exact `..` check. A link to the data root's parent got + through. New test: "a link to the data root's parent is outside it". +- **M09** dropped the sort. Node returned this directory already sorted, + so no fixture could catch it. New test: it mocks `fs.readdirSync` to + return the names reversed. +- **M15** read every readdir error on a run directory as missing. New + test: "an unreadable run directory refuses instead of reading as + missing". + +Final round: 30 killed, 2 survived. Both survivors are equivalent: + +- **M04** dropped `!path.isAbsolute(relative)`. On POSIX, `path.relative` + between two absolute paths is never absolute, so the branch is + unreachable on Linux. I kept it as a guard for other platforms. +- **M31** dropped the CLI's `isRunId` pre-check in `show`. `readRunRecord` + throws the same message with exit 4, and `readRuns` maps it to the same + `fail`. The pre-check stays so that an invalid id refuses before the + config loads, as it did before. + + **Corrected in round 2:** M31 was not equivalent. See "Correction: M31 + was never equivalent" above; it is killed in round 2. + +## Follow-up (not fixed, out of scope) + +This defect predates the candidate and is unchanged: `list` crashes with +a `padEnd` TypeError when `result.json` is an object without a string +`status` or `taskId`. Base and candidate behave the same here, so +byte-identity holds. A fix would change `list` output and belongs in its +own row. + +## Reproduce + +```sh +git worktree add --detach /tmp/r56-base 75ab1646 +git worktree add --detach /tmp/r56-cand 75ab1646 +(cd /tmp/r56-cand && git apply --index /build.patch && sha256sum -c /candidate-manifest.sha256) +env -u NODE_TEST_CONTEXT node --test '/tmp/r56-cand/packages/runs/tests/*.test.mjs' +bash /tmp/r56-cand/agents/rocko/work/queue-56/byte-check.sh /tmp/r56-base /tmp/r56-cand /tmp/r56-bc +bash /tmp/r56-cand/agents/rocko/work/queue-56/delta-check.sh /tmp/r56-base /tmp/r56-cand /tmp/r56-dc +``` diff --git a/agents/rocko/work/queue-56/base.txt b/agents/rocko/work/queue-56/base.txt new file mode 100644 index 00000000..cb6de8ff --- /dev/null +++ b/agents/rocko/work/queue-56/base.txt @@ -0,0 +1 @@ +75ab164698176178f196009f14f994e0192048a7 diff --git a/agents/rocko/work/queue-56/build.patch b/agents/rocko/work/queue-56/build.patch new file mode 100644 index 00000000..bbecf766 --- /dev/null +++ b/agents/rocko/work/queue-56/build.patch @@ -0,0 +1,1225 @@ +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 `/runs/` and the ++release state under `/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 /runs/ and the release pointer and activation log under /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 /runs/ ++// and the release pointer and activation log under /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 / 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 /, 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-)`); ++} ++ ++// Every name under /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-)\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-)\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-)`); + } + 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); + } + diff --git a/agents/rocko/work/queue-56/files.txt b/agents/rocko/work/queue-56/files.txt new file mode 100644 index 00000000..3b9e6697 --- /dev/null +++ b/agents/rocko/work/queue-56/files.txt @@ -0,0 +1,15 @@ +agents/rocko/work/queue-56/byte-check.sh +agents/rocko/work/queue-56/delta-check.sh +agents/rocko/work/queue-56/seed.sh +packages/runs/package.json +packages/runs/README.md +packages/runs/src/errors.mjs +packages/runs/src/index.mjs +packages/runs/src/paths.mjs +packages/runs/src/runs.mjs +packages/runs/src/state.mjs +packages/runs/tests/helpers.mjs +packages/runs/tests/runs.test.mjs +packages/runs/tests/state.test.mjs +packages/runs/tests/task-cli.test.mjs +scripts/mosaic-task.mjs diff --git a/agents/rocko/work/queue-56/out/apply-75ab1646.txt b/agents/rocko/work/queue-56/out/apply-75ab1646.txt new file mode 100644 index 00000000..8afd685b --- /dev/null +++ b/agents/rocko/work/queue-56/out/apply-75ab1646.txt @@ -0,0 +1 @@ +applies to 75ab1646, manifest matches diff --git a/agents/rocko/work/queue-56/out/byte-check.txt b/agents/rocko/work/queue-56/out/byte-check.txt new file mode 100644 index 00000000..bd5c1e89 --- /dev/null +++ b/agents/rocko/work/queue-56/out/byte-check.txt @@ -0,0 +1,3 @@ +byte-check: 16 cases, stdout, stderr and exit identical +byte-check: data root unchanged +rc=0 diff --git a/agents/rocko/work/queue-56/out/delta-check.txt b/agents/rocko/work/queue-56/out/delta-check.txt new file mode 100644 index 00000000..c88c2acc --- /dev/null +++ b/agents/rocko/work/queue-56/out/delta-check.txt @@ -0,0 +1,119 @@ + +== run directory linked out of the data root: list + base: exit 0 + out| r-20260101T000000Z-aaaaaa succeeded task=t-out ws=- session=- + cand: exit 0 + out| r-20260101T000000Z-aaaaaa unknown task=- ws=- session=- + +== run directory linked out of the data root: show + base: exit 0 + out| run: r-20260101T000000Z-aaaaaa + out| status: succeeded + out| task: t-out + out| adapter: (see config) provider=p model=m + out| request: "r" + out| response: "s" + out| timing: a -> b (1 ms) + out| exit: 0 + out| artifacts: result.json + cand: exit 4 + err| mosaic-task: /mnt/storage/scratch/rocko-r56/dc3/run-link/data/runs/r-20260101T000000Z-aaaaaa resolves outside the data root (/mnt/storage/scratch/rocko-r56/dc3/run-link/data) + +== result.json linked out of the data root: list + base: exit 0 + out| r-20260101T000000Z-aaaaaa succeeded task=t-out ws=- session=- + cand: exit 0 + out| r-20260101T000000Z-aaaaaa unknown task=- ws=- session=- + +== result.json linked out of the data root: show + base: exit 0 + out| run: r-20260101T000000Z-aaaaaa + out| status: succeeded + out| task: t-out + out| adapter: (see config) provider=p model=m + out| request: "r" + out| response: "s" + out| timing: a -> b (1 ms) + out| exit: 0 + out| artifacts: result.json + cand: exit 0 + out| run: r-20260101T000000Z-aaaaaa + out| result.json: (missing or unreadable) + out| artifacts: result.json + +== runs directory linked out of the data root: list + base: exit 0 + out| r-20260101T000000Z-aaaaaa succeeded task=t-out ws=- session=- + cand: exit 4 + err| mosaic-task: /mnt/storage/scratch/rocko-r56/dc3/runs-link/data/runs resolves outside the data root (/mnt/storage/scratch/rocko-r56/dc3/runs-link/data) + +== runs directory linked out of the data root: show + base: exit 0 + out| run: r-20260101T000000Z-aaaaaa + out| status: succeeded + out| task: t-out + out| adapter: (see config) provider=p model=m + out| request: "r" + out| response: "s" + out| timing: a -> b (1 ms) + out| exit: 0 + out| artifacts: result.json + cand: exit 4 + err| mosaic-task: /mnt/storage/scratch/rocko-r56/dc3/runs-link/data/runs resolves outside the data root (/mnt/storage/scratch/rocko-r56/dc3/runs-link/data) + +== result.json is 5 and task.json is []: list + base: exit 1 + err| file:///mnt/storage/scratch/rocko-r56/base2/scripts/mosaic-task.mjs:483 + cand: exit 0 + out| r-20260101T000000Z-aaaaaa unknown task=- ws=- session=- + +== result.json is 5 and task.json is []: show + base: exit 0 + out| run: r-20260101T000000Z-aaaaaa + out| status: undefined + out| task: undefined + out| adapter: (see config) provider=undefined model=undefined + out| request: undefined + out| response: undefined + out| timing: undefined -> undefined (undefined ms) + out| exit: undefined + out| task snapshot: task.json present + out| artifacts: result.json, task.json + cand: exit 0 + out| run: r-20260101T000000Z-aaaaaa + out| result.json: (missing or unreadable) + out| artifacts: result.json, task.json + +== run is a regular file: show + base: exit 1 + out| run: r-20260101T000000Z-aaaaaa + out| result.json: (missing or unreadable) + err| node:fs:1954 + cand: exit 4 + err| mosaic-task: run not found: r-20260101T000000Z-aaaaaa (under /mnt/storage/scratch/rocko-r56/dc3/run-file/data/runs) + +== run is a link loop: show + base: exit 4 + err| mosaic-task: run not found: r-20260101T000000Z-aaaaaa (under /mnt/storage/scratch/rocko-r56/dc3/loop/data/runs) + cand: exit 4 + err| mosaic-task: cannot resolve /mnt/storage/scratch/rocko-r56/dc3/loop/data/runs/r-20260101T000000Z-aaaaaa: ELOOP + +== runs directory unreadable: list + base: exit 0 + cand: exit 4 + err| mosaic-task: cannot read /mnt/storage/scratch/rocko-r56/dc3/unreadable/data/runs: EACCES + +== runs directory unreadable: show + base: exit 4 + err| mosaic-task: run not found: r-20260101T000000Z-aaaaaa (under /mnt/storage/scratch/rocko-r56/dc3/unreadable/data/runs) + cand: exit 4 + err| mosaic-task: cannot resolve /mnt/storage/scratch/rocko-r56/dc3/unreadable/data/runs/r-20260101T000000Z-aaaaaa: EACCES + +== run directory unreadable: show + base: exit 1 + out| run: r-20260101T000000Z-aaaaaa + out| result.json: (missing or unreadable) + err| node:fs:1954 + cand: exit 4 + err| mosaic-task: cannot read /mnt/storage/scratch/rocko-r56/dc3/run-unreadable/data/runs/r-20260101T000000Z-aaaaaa: EACCES +rc=0 diff --git a/agents/rocko/work/queue-56/out/gate/test-task.log b/agents/rocko/work/queue-56/out/gate/test-task.log new file mode 100644 index 00000000..19d4b6d2 --- /dev/null +++ b/agents/rocko/work/queue-56/out/gate/test-task.log @@ -0,0 +1,102 @@ +OK valid task validates (exit 0) +OK unknown task key exits 2 (exit 2) +OK unsupported taskVersion exits 2 (exit 2) +OK invalid task id exits 2 (exit 2) +OK empty prompt exits 2 (exit 2) +OK NUL in expectExact exits 2 (exit 2) +OK out-of-range timeout exits 2 (exit 2) +OK missing mission file exits 4 (exit 4) +OK task with valid mission validates (exit 0) +OK invalid mission exits 2 (exit 2) +OK validate missing task exits 4 (exit 4) +OK validation does not modify the task file +OK prune dry-run exits 0 (exit 0) +OK dry-run deleted nothing +OK prune --keep=2 --yes removes oldest (exit 0) +OK kept exactly 2 newest runs +OK newest run kept, oldest pruned +OK append-only receipt written (3 entries) +OK sessions/workspaces untouched by prune +OK prune with invalid keep exits 4 (exit 4) +OK mock adapter: gate passes on matching mock response (exit 0) +OK mock adapter: expect-mismatch recorded (exit 1) +OK mismatch reason recorded +OK unknown adapter fails closed (exit 1) +OK mission task runs via mock adapter (exit 0) +OK mission section injected into generated prompt +OK retry of mission run succeeds (exit 0) +OK retriedFrom lineage recorded +OK mission section present after retry (relative path resolved) +OK retry of missing run exits 4 (exit 4) +OK skill install bundled ms-tools (exit 0) +OK installed to skills-available +OK double install refuses (exit 1) +OK activate enables skill (exit 0) +OK enabled dir populated +OK uninstall while enabled refuses (exit 1) +OK deactivate moves back to available (exit 0) +OK uninstall removes from available (exit 0) +OK seat with enabled skill launches +OK skill path delivered to adapter +OK shipped researcher contract resolves (ceiling + network) +OK resolve-role refuses a non-role document (conductor policy) (exit 2) +OK resolve-role refuses name/filename mismatch (exit 2) +OK resolve-role refuses unknown network declaration (exit 2) +OK resolve-role refuses duplicate role tool (exit 2) +OK resolve-role refuses unsupported tool (exit 2) +OK resolve-role refuses missing contract file (exit 4) +OK version 1 role prints no contract line +OK shipped version 2 roles resolve with their contracts (pm, cto, coder, reviewer) +OK resolve-role accepts a minimal version 2 role (exit 0) +OK resolve-role refuses an action outside the vocabulary (exit 2) +OK resolve-role refuses a gated-only action in a role file (exit 2) +OK resolve-role refuses a Vikunja verb no role may hold (exit 2) +OK resolve-role refuses a version 2 role whose contract is missing (exit 2) +OK resolve-role refuses a contract that is a symbolic link (exit 2) +OK seat launches under role ceiling +OK role ceiling narrows seat tools (read,write,bash -> read,bash) +OK narrowing recorded loudly +OK missing role contract refuses launch (exit 2) +OK missing-contract refusal names the role +OK empty ceiling intersection -> tool-free seat +OK tool-free outcome recorded loudly +OK overridden agents dir without seat definition refuses (exit 4) +OK seat-resolution refusal names the seat +OK policy: mission only -> mission tools +OK policy: task only -> task tools +OK policy: both -> intersection (task narrowed) +OK policy: empty intersection -> tool-free +OK invalid mission capabilities rejected (exit 2) +mosaic-task: unsupported mission tool: "sudo" (supported: read, write, edit, bash, grep, find, ls) +OK task run with user layer present (exit 0) +OK user context dispatched into generated prompt +OK workspace+tools task runs via mock (exit 0) +OK workspace path + tools delivered to adapter +OK persistent workspace created on host +OK plain task still runs (no workspace/tools) (exit 0) +OK workspace var present but empty when absent +OK tools empty when absent +OK unknown tool exits 2 (exit 2) +OK workspace traversal exits 2 (exit 2) +OK live hello task succeeds with exact marker +OK result.json written in run dir +OK result.json contents are correct +OK wrong expectExact fails with exit 1 (reason: expect-mismatch) +OK each run gets a distinct run dir (no clobber) +OK list shows both runs +OK fork base: teach succeeds (exit 0) +OK fork child recalls ancestor context (exit 0) +OK forked child recalled ancestor code word +OK ancestor session untouched by fork +OK child session dir has its own branch file +OK onboard without name exits 4 (non-interactive) (exit 4) +OK onboard --name renders profile (exit 0) +OK profile written +OK canon structure: required filled, optional placeholdered +OK canon sections present +OK user recall run succeeds +OK recalled user name (response: Jason) +OK no agent identity on headless run + +selftest: 98 passed, 0 failed +rc=0 diff --git a/agents/rocko/work/queue-56/out/mutants.sh b/agents/rocko/work/queue-56/out/mutants.sh new file mode 100755 index 00000000..fe5f5deb --- /dev/null +++ b/agents/rocko/work/queue-56/out/mutants.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Row 56 mutants: each line applies one perl substitution to one file, runs +# the packages/runs tests, and expects a failure (killed). Restores after. +cd /mnt/storage/scratch/rocko-r56/r2 || exit 4 +G=/mnt/storage/scratch/rocko-r56/gate2 +: > "$G/mutants.txt" +mut() { # id file perl-expr + local id="$1" f="$2" e="$3" + cp "$f" "$f.orig" + perl -0pi -e "$e" "$f" + if cmp -s "$f" "$f.orig"; then echo "$id NOT-APPLIED" >> "$G/mutants.txt"; mv -f "$f.orig" "$f"; return; fi + if timeout 300 env -u NODE_TEST_CONTEXT node --test 'packages/runs/tests/*.test.mjs' > "$G/mut-$id.log" 2>&1; then + echo "$id SURVIVED" >> "$G/mutants.txt" + else + echo "$id killed" >> "$G/mutants.txt" + fi + mv -f "$f.orig" "$f" +} +P=packages/runs/src/paths.mjs; R=packages/runs/src/runs.mjs; S=packages/runs/src/state.mjs; T=scripts/mosaic-task.mjs +mut M01 $P 's/return relative === "" \|\|/return true ||/' +mut M02 $P 's/relative !== "\.\." && //' +mut M03 $P 's/startsWith\(`\.\.\$\{path\.sep\}`\)/startsWith("..x")/' +mut M04 $P 's/ && !path\.isAbsolute\(relative\)\)/)/' +mut M05 $P 's/ && !Array\.isArray\(value\) \? value/ ? value/' +mut M06 $P 's/error\?\.code === "ENOENT" \|\| error\?\.code === "ENOTDIR"/error?.code === "ENOENT"/' +mut M07 $P 's/if \(isMissing\(error\)\) return null;\n throw new RunsError\(`cannot resolve/return null;\n throw new RunsError(`cannot resolve/' +mut M08 $P 's/if \(!isInside\(root, real\)\)/if (false)/' +mut M09 $R 's/filter\(\(name\) => name\.startsWith\("r-"\)\)\.sort\(\)/filter((name) => name.startsWith("r-"))/' +mut M10 $R 's/filter\(\(name\) => name\.startsWith\("r-"\)\)/filter(() => true)/' +mut M11 $R 's/if \(isMissing\(error\)\) return \[\];\n throw/return [];\n throw/' +mut M12 $R 's/if \(!RUN_DOCUMENTS\.includes\(name\)\) throw/if (false) throw/' +mut M13 $R 's/export function readRunRecord\(dataRoot, runId\) \{\n requireRunId\(runId\);/export function readRunRecord(dataRoot, runId) {/' +mut M14 $R 's/if \(resolveInside\(dataRoot, RUNS_DIRNAME\) === null\) return null;\n//' +mut M15 $R 's/if \(isMissing\(error\)\) return null;\n throw new RunsError\(`cannot read \$\{dir\}/return null;\n throw new RunsError(`cannot read ${dir}/' +mut M16 $R 's/RUN_ID_PATTERN = \/\^r-/RUN_ID_PATTERN = \/r-/' +mut M17 $S 's/if \(!POINTER_KEYS\.includes\(key\)\) throw/if (false) throw/' +mut M18 $S 's/if \(pointer\.pointerVersion !== 1\)/if (false)/' +mut M19 $S 's/new RunsError\(`release pointer \$\{why\} \(\$\{state\.file\}\)`, 2\)/new RunsError(`release pointer ${why} (${state.file})`)/' +mut M20 $S 's/if \(Object\.keys\(entry\)\.some\(\(key\) => !LOG_KEYS\.includes\(key\)\)\) return null;//' +mut M21 $S 's/if \(entry\.note !== undefined && typeof entry\.note !== "string"\) return null;//' +mut M22 $S 's/entries\.slice\(-last\)/entries.slice(0, last)/' +mut M23 $S 's/if \(line\.trim\(\) === ""\) continue;//' +mut M24 $S 's/if \(last !== undefined && \(!Number\.isInteger\(last\) \|\| last < 1\)\)/if (false)/' +mut M25 $S 's/ throw new RunsError\(`cannot read \$\{file\}/ return null; throw new RunsError(`cannot read ${file}/' +mut M26 $S 's/if \(!isNonEmptyString\(pointer\[key\]\)\)/if (pointer[key] === undefined)/' +mut M27 $S 's/\["at", "event", "release", "imageTag"\]\.every/["event"].every/' +mut M28 $T 's/ if \(result\) \{\n status = result\.status;/ if (true) {\n status = result.status;/' +mut M29 $T 's/ if \(record === null\) \{\n fail\(4, `run not found/ if (false) {\n fail(4, `run not found/' +mut M30 $T 's/if \(error instanceof RunsError\) fail\(error\.exitCode, error\.message\);/if (false) fail(error.exitCode, error.message);/' +mut M31 $T 's/ if \(!isRunId\(runId\)\) \{\n fail\(4, `invalid run id/ if (false) {\n fail(4, `invalid run id/' +mut M32 $T 's/artifacts\.join\(", "\)/artifacts.join(",")/' +mut D17 $R 's/result: readJsonObject\(dataRoot, RUNS_DIRNAME, runId, "result\.json"\)/result: readRunDocument(dataRoot, runId, "result.json")/' +mut D20 $R 's/names\.filter\(\(name\) => name\.startsWith\("r-"\)\)/names.filter((name) => name.startsWith("r-") \&\& isRunId(name))/' +mut D22 $S 's/\["at", "event", "release", "imageTag"\]\.every/["at", "event", "release"].every/' +mut N2 $S 's/\["at", "event", "release", "imageTag"\]\.every/["at", "event", "imageTag"].every/' +mut S1 $S 's/if \(file === null\) return null;\n try \{\n return \{ file, text/if (file === null) throw new RunsError("x");\n try {\n return { file, text/' +echo done >> "$G/mutants.txt" diff --git a/agents/rocko/work/queue-56/out/mutants.txt b/agents/rocko/work/queue-56/out/mutants.txt new file mode 100644 index 00000000..0ea8bc8e --- /dev/null +++ b/agents/rocko/work/queue-56/out/mutants.txt @@ -0,0 +1,38 @@ +M01 killed +M02 killed +M03 killed +M04 SURVIVED +M05 killed +M06 killed +M07 killed +M08 killed +M09 killed +M10 killed +M11 killed +M12 killed +M13 killed +M14 killed +M15 killed +M16 killed +M17 killed +M18 killed +M19 killed +M20 killed +M21 killed +M22 killed +M23 killed +M24 killed +M25 killed +M26 killed +M27 killed +M28 killed +M29 killed +M30 killed +M31 killed +M32 killed +D17 killed +D20 killed +D22 killed +N2 killed +S1 killed +done diff --git a/agents/rocko/work/queue-56/out/round1/apply-923957e2.txt b/agents/rocko/work/queue-56/out/round1/apply-923957e2.txt new file mode 100644 index 00000000..c0ec5275 --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/apply-923957e2.txt @@ -0,0 +1 @@ +applies to 923957e2, manifest matches diff --git a/agents/rocko/work/queue-56/out/round1/apply-dc96f87b.txt b/agents/rocko/work/queue-56/out/round1/apply-dc96f87b.txt new file mode 100644 index 00000000..cc2216cd --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/apply-dc96f87b.txt @@ -0,0 +1 @@ +applies to dc96f87b, manifest matches diff --git a/agents/rocko/work/queue-56/out/round1/byte-check.txt b/agents/rocko/work/queue-56/out/round1/byte-check.txt new file mode 100644 index 00000000..bd5c1e89 --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/byte-check.txt @@ -0,0 +1,3 @@ +byte-check: 16 cases, stdout, stderr and exit identical +byte-check: data root unchanged +rc=0 diff --git a/agents/rocko/work/queue-56/out/round1/delta-check.txt b/agents/rocko/work/queue-56/out/round1/delta-check.txt new file mode 100644 index 00000000..76d5c23f --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/delta-check.txt @@ -0,0 +1,111 @@ + +== run directory linked out of the data root: list + base: exit 0 + out| r-20260101T000000Z-aaaaaa succeeded task=t-out ws=- session=- + cand: exit 0 + out| r-20260101T000000Z-aaaaaa unknown task=- ws=- session=- + +== run directory linked out of the data root: show + base: exit 0 + out| run: r-20260101T000000Z-aaaaaa + out| status: succeeded + out| task: t-out + out| adapter: (see config) provider=p model=m + out| request: "r" + out| response: "s" + out| timing: a -> b (1 ms) + out| exit: 0 + out| artifacts: result.json + cand: exit 4 + err| mosaic-task: /mnt/storage/scratch/rocko-r56/dc2/run-link/data/runs/r-20260101T000000Z-aaaaaa resolves outside the data root (/mnt/storage/scratch/rocko-r56/dc2/run-link/data) + +== result.json linked out of the data root: list + base: exit 0 + out| r-20260101T000000Z-aaaaaa succeeded task=t-out ws=- session=- + cand: exit 0 + out| r-20260101T000000Z-aaaaaa unknown task=- ws=- session=- + +== result.json linked out of the data root: show + base: exit 0 + out| run: r-20260101T000000Z-aaaaaa + out| status: succeeded + out| task: t-out + out| adapter: (see config) provider=p model=m + out| request: "r" + out| response: "s" + out| timing: a -> b (1 ms) + out| exit: 0 + out| artifacts: result.json + cand: exit 0 + out| run: r-20260101T000000Z-aaaaaa + out| result.json: (missing or unreadable) + out| artifacts: result.json + +== runs directory linked out of the data root: list + base: exit 0 + out| r-20260101T000000Z-aaaaaa succeeded task=t-out ws=- session=- + cand: exit 4 + err| mosaic-task: /mnt/storage/scratch/rocko-r56/dc2/runs-link/data/runs resolves outside the data root (/mnt/storage/scratch/rocko-r56/dc2/runs-link/data) + +== runs directory linked out of the data root: show + base: exit 0 + out| run: r-20260101T000000Z-aaaaaa + out| status: succeeded + out| task: t-out + out| adapter: (see config) provider=p model=m + out| request: "r" + out| response: "s" + out| timing: a -> b (1 ms) + out| exit: 0 + out| artifacts: result.json + cand: exit 4 + err| mosaic-task: /mnt/storage/scratch/rocko-r56/dc2/runs-link/data/runs resolves outside the data root (/mnt/storage/scratch/rocko-r56/dc2/runs-link/data) + +== result.json is 5 and task.json is []: list + base: exit 1 + err| file:///mnt/storage/scratch/rocko-r56/base/scripts/mosaic-task.mjs:483 + cand: exit 0 + out| r-20260101T000000Z-aaaaaa unknown task=- ws=- session=- + +== result.json is 5 and task.json is []: show + base: exit 0 + out| run: r-20260101T000000Z-aaaaaa + out| status: undefined + out| task: undefined + out| adapter: (see config) provider=undefined model=undefined + out| request: undefined + out| response: undefined + out| timing: undefined -> undefined (undefined ms) + out| exit: undefined + out| task snapshot: task.json present + out| artifacts: result.json, task.json + cand: exit 0 + out| run: r-20260101T000000Z-aaaaaa + out| result.json: (missing or unreadable) + out| artifacts: result.json, task.json + +== run is a regular file: show + base: exit 1 + out| run: r-20260101T000000Z-aaaaaa + out| result.json: (missing or unreadable) + err| node:fs:1954 + cand: exit 4 + err| mosaic-task: run not found: r-20260101T000000Z-aaaaaa (under /mnt/storage/scratch/rocko-r56/dc2/run-file/data/runs) + +== run is a link loop: show + base: exit 4 + err| mosaic-task: run not found: r-20260101T000000Z-aaaaaa (under /mnt/storage/scratch/rocko-r56/dc2/loop/data/runs) + cand: exit 4 + err| mosaic-task: cannot resolve /mnt/storage/scratch/rocko-r56/dc2/loop/data/runs/r-20260101T000000Z-aaaaaa: ELOOP + +== runs directory unreadable: list + base: exit 0 + cand: exit 4 + err| mosaic-task: cannot read /mnt/storage/scratch/rocko-r56/dc2/unreadable/data/runs: EACCES + +== runs directory unreadable: show + base: exit 4 + err| mosaic-task: run not found: r-20260101T000000Z-aaaaaa (under /mnt/storage/scratch/rocko-r56/dc2/unreadable/data/runs) + cand: exit 4 + err| mosaic-task: cannot resolve /mnt/storage/scratch/rocko-r56/dc2/unreadable/data/runs/r-20260101T000000Z-aaaaaa: EACCES +rc=0 diff --git a/agents/rocko/work/queue-56/out/round1/gate/run.sh b/agents/rocko/work/queue-56/out/round1/gate/run.sh new file mode 100755 index 00000000..44df1dc1 --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/gate/run.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Row 56 gate: packages/runs tests then every scripts/test-*.sh, sequentially. +cd /mnt/storage/scratch/rocko-r56/tree || exit 4 +G=/mnt/storage/scratch/rocko-r56/gate +: > "$G/summary.txt" +env -u NODE_TEST_CONTEXT node --test 'packages/runs/tests/*.test.mjs' > "$G/runs.log" 2>&1 +echo "packages/runs rc=$? $(grep -E '^ℹ (pass|fail) ' "$G/runs.log" | tr '\n' ' ')" >> "$G/summary.txt" +for s in scripts/test-*.sh; do + n="$(basename "$s" .sh)" + env -u NODE_TEST_CONTEXT bash "$s" > "$G/$n.log" 2>&1 + echo "$n rc=$? $(grep -aiE 'pass(ed)?[:=]? *[0-9]+|[0-9]+ *(passed|failed)|PASS|FAIL' "$G/$n.log" | tail -1)" >> "$G/summary.txt" +done +echo done >> "$G/summary.txt" diff --git a/agents/rocko/work/queue-56/out/round1/gate/runs.log b/agents/rocko/work/queue-56/out/round1/gate/runs.log new file mode 100644 index 00000000..a916aaf0 --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/gate/runs.log @@ -0,0 +1,42 @@ +✔ isRunId accepts the run id shape and nothing else (1.679687ms) +✔ a missing data root or runs directory lists nothing (1.711645ms) +✔ runs as a regular file lists nothing, as before (0.711141ms) +✔ listRunIds keeps r- names only, sorted oldest first (0.883213ms) +✔ listRunRecords returns each result, or null for an incomplete or unreadable one (2.444986ms) +✔ readRunRecord returns documents and artifacts in directory order (1.167168ms) +✔ readRunRecord is null for a missing run, a dangling link or a file (0.70133ms) +✔ readRunRecord and readRunDocument refuse an invalid run id before touching the disk (0.793561ms) +✔ readRunDocument reads only the three run documents (0.730363ms) +✔ a link inside the data root is followed (1.147751ms) +✔ a data root that is itself a link is trusted as configured (0.641352ms) +✔ a run directory linked out of the data root is never read (0.659011ms) +✔ a document linked out of the data root reads as null (1.093445ms) +✔ a runs directory linked out of the data root refuses (0.615519ms) +✔ a relative link that climbs out of the data root refuses (0.563326ms) +✔ a sibling whose name starts with the data root's name is outside it (0.703948ms) +✔ a link loop refuses instead of reading as missing (0.914245ms) +✔ an unreadable runs directory refuses instead of listing nothing (0.587674ms) +✔ the readers write nothing (2.658199ms) +✔ no pointer is null (2.102149ms) +✔ the pointer release.sh writes reads back (1.402897ms) +✔ a pointer that isn't the version 1 shape refuses with exit 2 (2.240364ms) +✔ a pointer that is a directory refuses with exit 4 (0.676911ms) +✔ a state file linked out of the data root refuses (2.216034ms) +✔ a state directory linked out of the data root refuses (0.96548ms) +✔ a missing log is empty (0.663032ms) +✔ the log reads oldest first and counts malformed lines (1.490356ms) +✔ last must be a positive integer (0.835225ms) +✔ the state readers write nothing (1.481171ms) +✔ list prints each run in the established format (94.815455ms) +✔ show prints the run in the established format (81.715499ms) +✔ show of a missing run and an invalid id exit 4 as before (145.886523ms) +✔ list and show refuse a runs directory linked out of the data root (171.425218ms) +✔ show refuses a run linked out of the data root; list reports it unknown (196.821506ms) +ℹ tests 34 +ℹ suites 0 +ℹ pass 34 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 774.958121 diff --git a/agents/rocko/work/queue-56/out/round1/gate/summary.txt b/agents/rocko/work/queue-56/out/round1/gate/summary.txt new file mode 100644 index 00000000..16ea9ce7 --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/gate/summary.txt @@ -0,0 +1,11 @@ +packages/runs rc=0 ℹ pass 34 ℹ fail 0 +test-auth rc=0 selftest: 15 passed, 0 failed +test-conductor rc=0 selftest: 17 passed, 0 failed +test-config rc=0 selftest: 24 passed, 0 failed +test-discord rc=1 discord suite: 57 passed, 1 failed +test-extension-package rc=0 extension package selftest: 18 passed, 0 failed +test-foundation rc=0 selftest: 44 passed, 0 failed +test-queue rc=0 queue suite: 27 passed, 0 failed +test-release rc=0 selftest: 14 passed, 0 failed +test-task rc=0 selftest: 98 passed, 0 failed +done diff --git a/agents/rocko/work/queue-56/out/round1/gate/test-auth.log b/agents/rocko/work/queue-56/out/round1/gate/test-auth.log new file mode 100644 index 00000000..ac228760 --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/gate/test-auth.log @@ -0,0 +1,17 @@ +OK status with missing harness credential exits 3 and still lists accounts +OK status reports harness credential (read-only) + mosaic accounts +OK api key material never reaches output +OK oauth token material never reaches output +OK unparseable credential file exits 2 +OK symlinked credential file exits 4 +OK env-side credential names reported +OK env var values never reach output +OK accounts without an accounts dir reports none and creates nothing +OK accounts lists files and marks the active one +OK loose account perms flagged in listing +OK agent --auth with missing account file refuses (exit 4) +OK agent --auth with non-0600 account file refuses +OK agent --auth with invalid account name refuses +OK auth.sh without valid config refuses + +selftest: 15 passed, 0 failed diff --git a/agents/rocko/work/queue-56/out/round1/gate/test-conductor.log b/agents/rocko/work/queue-56/out/round1/gate/test-conductor.log new file mode 100644 index 00000000..c345187f --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/gate/test-conductor.log @@ -0,0 +1,55 @@ +Note: switching to 'dc96f87b1776cc74d0ea5ba7701db418cc21e1b8'. + +You are in 'detached HEAD' state. You can look around, make experimental +changes and commit them, and you can discard any commits you make in this +state without impacting any branches by switching back to a branch. + +If you want to create a new branch to retain commits you create, you may +do so (now or later) by using -c with the switch command. Example: + + git switch -c + +Or undo this operation with: + + git switch - + +Turn off this advice by setting config variable advice.detachedHead to false + +Not currently on any branch. +nothing to commit, working tree clean +Note: switching to 'dc96f87b1776cc74d0ea5ba7701db418cc21e1b8'. + +You are in 'detached HEAD' state. You can look around, make experimental +changes and commit them, and you can discard any commits you make in this +state without impacting any branches by switching back to a branch. + +If you want to create a new branch to retain commits you create, you may +do so (now or later) by using -c with the switch command. Example: + + git switch -c + +Or undo this operation with: + + git switch - + +Turn off this advice by setting config variable advice.detachedHead to false + +OK dry-run: allowed change, exit 0, nothing committed (exit 0) +OK dry-run committed nothing +OK apply: allowed change exits 0 (exit 0) +OK apply: attribution in commit subject +OK apply: target tree clean after commit +OK disallowed path refused (exit 1) +OK disallowed path: target untouched +OK syntax gate refused broken .mjs (exit 1) +OK syntax gate: target untouched +OK suite failure refused (exit 1) +OK suite failure: target reverted to clean +OK disabled policy refused (exit 2) +OK disabled policy: target untouched +OK failed run refused (exit 1) +OK failed run: target untouched +OK missing run exits 4 (exit 4) +OK invalid policy exits 2 (exit 2) + +selftest: 17 passed, 0 failed diff --git a/agents/rocko/work/queue-56/out/round1/gate/test-config.log b/agents/rocko/work/queue-56/out/round1/gate/test-config.log new file mode 100644 index 00000000..76c05ef7 --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/gate/test-config.log @@ -0,0 +1,26 @@ +OK absent adapter defaults to pi +OK adapter mock validates (exit 0) +OK unsupported adapter exits 2 (exit 2) +OK env exports adapter +OK bootstrap creates default when absent (exit 0) +OK bootstrap wrote config file +OK bootstrap is idempotent on existing config (exit 0) +OK bootstrap did not rewrite existing config +OK validate missing config exits 3 (exit 3) +OK malformed JSON exits 2 (exit 2) +OK unsupported configVersion exits 2 (exit 2) +OK unknown top-level key exits 2 (exit 2) +OK unknown execution key exits 2 (exit 2) +OK unsupported backend exits 2 (exit 2) +OK unsupported environment exits 2 (exit 2) +OK relative dataRoot exits 2 (exit 2) +OK non-canonical dataRoot exits 2 (exit 2) +OK filesystem root dataRoot exits 2 (exit 2) +OK home directory dataRoot exits 2 (exit 2) +OK dataRoot containing config dir exits 2 (exit 2) +OK control character in provider exits 2 (exit 2) +OK symlinked config file exits 2 (exit 2) +OK env exports resolve correctly +OK failed validation modified nothing + +selftest: 24 passed, 0 failed diff --git a/agents/rocko/work/queue-56/out/round1/gate/test-discord-r2.log b/agents/rocko/work/queue-56/out/round1/gate/test-discord-r2.log new file mode 100644 index 00000000..b013f9ff --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/gate/test-discord-r2.log @@ -0,0 +1,70 @@ +toolchain: node v26.8.1 + +OK syntax: packages/discord/src/approvals.mjs +OK syntax: packages/discord/src/authorize.mjs +OK syntax: packages/discord/src/binding.mjs +OK syntax: packages/discord/src/cli.mjs +OK syntax: packages/discord/src/connector.mjs +OK syntax: packages/discord/src/context.mjs +OK syntax: packages/discord/src/engine-pi.mjs +OK syntax: packages/discord/src/errors.mjs +OK syntax: packages/discord/src/gateway.mjs +OK syntax: packages/discord/src/git.mjs +OK syntax: packages/discord/src/journal.mjs +OK syntax: packages/discord/src/notify.mjs +OK syntax: packages/discord/src/rest.mjs +OK syntax: packages/discord/src/setspark.mjs +OK syntax: packages/discord/src/tools.mjs +OK syntax: packages/discord/src/web.mjs +OK syntax: packages/discord/bin/git-credential.mjs +OK syntax: packages/discord/extension/tools.mjs +OK syntax: packages/discord/tests/approvals.test.mjs +OK syntax: packages/discord/tests/authorize.test.mjs +OK syntax: packages/discord/tests/binding.test.mjs +OK syntax: packages/discord/tests/connector.test.mjs +OK syntax: packages/discord/tests/context.test.mjs +OK syntax: packages/discord/tests/engine.test.mjs +OK syntax: packages/discord/tests/fake-pi.mjs +OK syntax: packages/discord/tests/gateway.test.mjs +OK syntax: packages/discord/tests/git.test.mjs +OK syntax: packages/discord/tests/helpers.mjs +OK syntax: packages/discord/tests/journal.test.mjs +OK syntax: packages/discord/tests/notify.test.mjs +OK syntax: packages/discord/tests/recover.test.mjs +OK syntax: packages/discord/tests/rest.test.mjs +OK syntax: packages/discord/tests/setspark.test.mjs +OK syntax: packages/discord/tests/tools.test.mjs +OK syntax: packages/discord/tests/web.test.mjs +OK syntax: packages/discord/fixtures/claim-worker.mjs +OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs +OK syntax: scripts/discord.sh +OK syntax: scripts/discord-service.sh +OK packages/discord declares no dependencies +OK no bot-token-shaped string in packages/discord +OK fixture binding uses placeholder ids only +OK fixture binding validates +OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool +OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start +OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root +OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token +OK real pi with protocol vault adds reserve_id to the git verbs +OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key +OK real pi refuses a git key on a read-only root (fail closed) +OK real pi with the pilot flags (--no-tools) exposes no tool at all +OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed) +OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT +OK node --test packages/discord/tests/ (ℹ pass 178) +OK scripts/discord.sh --help exits 0 +OK scripts/discord.sh check without a binding exits 4 +OK scripts/discord.sh recover without a binding exits 4 +OK scripts/discord.sh reload without a binding exits 4 +OK scripts/discord-service.sh without a command exits 4 +OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP +OK service install writes the rendered unit (0644) and leaves no temp file +OK service install a second time reports unchanged +OK systemd-analyze verify accepts the rendered unit +OK service uninstall removes the unit file +OK service install with an unknown flag exits 4 +OK service install with USER unset finishes and names the account for lingering + +discord suite: 66 passed, 0 failed diff --git a/agents/rocko/work/queue-56/out/round1/gate/test-discord.log b/agents/rocko/work/queue-56/out/round1/gate/test-discord.log new file mode 100644 index 00000000..18f8666f --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/gate/test-discord.log @@ -0,0 +1,62 @@ +toolchain: node v26.8.1 + +OK syntax: packages/discord/src/approvals.mjs +OK syntax: packages/discord/src/authorize.mjs +OK syntax: packages/discord/src/binding.mjs +OK syntax: packages/discord/src/cli.mjs +OK syntax: packages/discord/src/connector.mjs +OK syntax: packages/discord/src/context.mjs +OK syntax: packages/discord/src/engine-pi.mjs +OK syntax: packages/discord/src/errors.mjs +OK syntax: packages/discord/src/gateway.mjs +OK syntax: packages/discord/src/git.mjs +OK syntax: packages/discord/src/journal.mjs +OK syntax: packages/discord/src/notify.mjs +OK syntax: packages/discord/src/rest.mjs +OK syntax: packages/discord/src/setspark.mjs +OK syntax: packages/discord/src/tools.mjs +OK syntax: packages/discord/src/web.mjs +OK syntax: packages/discord/bin/git-credential.mjs +OK syntax: packages/discord/extension/tools.mjs +OK syntax: packages/discord/tests/approvals.test.mjs +OK syntax: packages/discord/tests/authorize.test.mjs +OK syntax: packages/discord/tests/binding.test.mjs +OK syntax: packages/discord/tests/connector.test.mjs +OK syntax: packages/discord/tests/context.test.mjs +OK syntax: packages/discord/tests/engine.test.mjs +OK syntax: packages/discord/tests/fake-pi.mjs +OK syntax: packages/discord/tests/gateway.test.mjs +OK syntax: packages/discord/tests/git.test.mjs +OK syntax: packages/discord/tests/helpers.mjs +OK syntax: packages/discord/tests/journal.test.mjs +OK syntax: packages/discord/tests/notify.test.mjs +OK syntax: packages/discord/tests/recover.test.mjs +OK syntax: packages/discord/tests/rest.test.mjs +OK syntax: packages/discord/tests/setspark.test.mjs +OK syntax: packages/discord/tests/tools.test.mjs +OK syntax: packages/discord/tests/web.test.mjs +OK syntax: packages/discord/fixtures/claim-worker.mjs +OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs +OK syntax: scripts/discord.sh +OK syntax: scripts/discord-service.sh +OK packages/discord declares no dependencies +OK no bot-token-shaped string in packages/discord +OK fixture binding uses placeholder ids only +OK fixture binding validates +FAIL pi binary present at node_modules/.bin/pi for the extension checks +OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT +OK node --test packages/discord/tests/ (ℹ pass 178) +OK scripts/discord.sh --help exits 0 +OK scripts/discord.sh check without a binding exits 4 +OK scripts/discord.sh recover without a binding exits 4 +OK scripts/discord.sh reload without a binding exits 4 +OK scripts/discord-service.sh without a command exits 4 +OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP +OK service install writes the rendered unit (0644) and leaves no temp file +OK service install a second time reports unchanged +OK systemd-analyze verify accepts the rendered unit +OK service uninstall removes the unit file +OK service install with an unknown flag exits 4 +OK service install with USER unset finishes and names the account for lingering + +discord suite: 57 passed, 1 failed diff --git a/agents/rocko/work/queue-56/out/round1/gate/test-extension-package.log b/agents/rocko/work/queue-56/out/round1/gate/test-extension-package.log new file mode 100644 index 00000000..19fc998e --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/gate/test-extension-package.log @@ -0,0 +1,21 @@ +OK initial ordinary-file install +OK installed tree matches canonical source +OK installed tree has no symlinks +OK check detects installation drift +OK sync refuses to overwrite installation drift +OK check detects an extra destination file +OK check detects an extra destination directory +OK check rejects a destination symlink +OK sync accepts a canonical source update +OK updated installation matches canonical source +scripts/test-extension-package.sh: line 14: 561594 Killed "$@" > /dev/null 2>&1 +OK forced interruption kills the replacing process +OK next invocation recovers old consistent installation +OK interrupted replacement rolled back +OK sync succeeds after interruption recovery +OK unlocked stale lock file does not block +OK active lock refuses a concurrent sync +OK source symlink fails closed +OK nested second entrypoint fails closed + +extension package selftest: 18 passed, 0 failed diff --git a/agents/rocko/work/queue-56/out/round1/gate/test-foundation.log b/agents/rocko/work/queue-56/out/round1/gate/test-foundation.log new file mode 100644 index 00000000..6338e1e6 --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/gate/test-foundation.log @@ -0,0 +1,53 @@ +toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0 + +OK syntax: scripts/foundation-inspect.mjs +OK syntax: scripts/foundation/strict-json.mjs +OK syntax: scripts/foundation/canonical.mjs +OK syntax: scripts/foundation/resolve.mjs +OK syntax: scripts/foundation/validate-record.mjs +OK syntax: scripts/foundation/fixtures/build-fixtures.mjs +OK syntax: scripts/foundation/canonical.test.mjs +OK syntax: scripts/foundation/cli.test.mjs +OK syntax: scripts/foundation/fixtures.test.mjs +OK syntax: scripts/foundation/resolve.test.mjs +OK syntax: scripts/foundation/strict-json.test.mjs +OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written) +OK fixture generator runs +OK checked-in fixtures/bundles equal a fresh generation +OK checked-in fixtures/raw equal a fresh generation +OK checked-in fixtures/index.json equal a fresh generation +OK checked-in demo bundles equal a fresh generation +OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT +OK node --test scripts/foundation/ (ℹ pass 80) +OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof) + platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999) + node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases) + schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10 + profile column (schema-valid records only): profile-valid 510, profile-invalid 30 + profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns +OK oracle: zero schema-column disagreements with the pinned checker +OK oracle: strict-only profile refusals are counted and asserted +OK demo: permitted read preview exits 0 (exit 0) +OK demo: permitted file.change preview exits 0 (exit 0) +OK demo: assignment.change proposal is unresolved (exit 3) (exit 3) +OK demo: revoked registration is refused (exit 3) (exit 3) +OK demo: message is not authority (exit 3) (exit 3) +OK usage: no arguments exits 2 (exit 2) +OK io: missing file exits 4 (exit 4) +OK io: directory exits 4 (exit 4) +OK io: symlink exits 4 (O_NOFOLLOW) (exit 4) +OK bound: oversize fixture exits 2 (exit 2) +OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2) +OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2) +OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0) +OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed +OK text output starts with the disclaimer +OK json output is valid JSON with result allowed and exactly the charter §7 fields +OK json golden matches byte-for-byte +OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal +OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs +OK canary never printed (bundle run and credential-file run) +OK a non-bundle JSON file is refused at the shape gate, not read into output +OK no field of the non-bundle file is echoed + +selftest: 44 passed, 0 failed diff --git a/agents/rocko/work/queue-56/out/round1/gate/test-queue.log b/agents/rocko/work/queue-56/out/round1/gate/test-queue.log new file mode 100644 index 00000000..9cfa0927 --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/gate/test-queue.log @@ -0,0 +1,35 @@ +toolchain: node v26.8.1, git version 2.55.0 + +OK syntax: packages/queue/src/cli.mjs +OK syntax: packages/queue/src/errors.mjs +OK syntax: packages/queue/src/io.mjs +OK syntax: packages/queue/src/lock.mjs +OK syntax: packages/queue/src/queue.mjs +OK syntax: packages/queue/src/review.mjs +OK syntax: packages/queue/src/store.mjs +OK syntax: packages/queue/tests/commit.test.mjs +OK syntax: packages/queue/tests/data.test.mjs +OK syntax: packages/queue/tests/dispatch.test.mjs +OK syntax: packages/queue/tests/helpers.mjs +OK syntax: packages/queue/tests/lock.test.mjs +OK syntax: packages/queue/tests/migration.test.mjs +OK syntax: packages/queue/tests/review.test.mjs +OK syntax: packages/queue/tests/store.test.mjs +OK syntax: packages/queue/tests/write.test.mjs +OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs +OK syntax: packages/queue/tests/fixtures/kill-at.mjs +OK syntax: packages/queue/tests/fixtures/lock-child.mjs +OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh +OK syntax: scripts/queue-commit.sh +OK syntax: scripts/git-hooks/pre-commit +OK syntax: scripts/mosaic +OK queue-commit.sh, the guard and scripts/mosaic are executable +OK packages/queue declares no dependencies +ℹ tests 148 +ℹ pass 148 +ℹ fail 0 +OK node --test packages/queue/tests/ +OK scripts/mosaic queue help +skip queue verify and render --check: this checkout (/mnt/storage/scratch/rocko-r56/tree) is not the queue's canonical root (/mnt/storage/src/mosaic-stack) + +queue suite: 27 passed, 0 failed diff --git a/agents/rocko/work/queue-56/out/round1/gate/test-release.log b/agents/rocko/work/queue-56/out/round1/gate/test-release.log new file mode 100644 index 00000000..7202b85d --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/gate/test-release.log @@ -0,0 +1,16 @@ +OK valid RELEASE resolves (exit 0) +OK invalid RELEASE exits 1 (exit 1) +OK missing RELEASE exits 1 (exit 1) +OK valid RELEASE leaves image tag consistent with version +OK status safe on empty state (exit 0) +OK status created no pointer +OK fault-injected activation refuses (exit 1) +OK refused activation wrote no pointer +OK refusal logged exactly once with valid fields +OK healthy activation succeeds (exit 0) +OK pointer written with valid fields +OK repeat activation succeeds (log grows) (exit 0) +OK log is append-only across activations +OK rollback without previous refuses (exit 1) + +selftest: 14 passed, 0 failed diff --git a/agents/rocko/work/queue-56/out/round1/gate/test-task.log b/agents/rocko/work/queue-56/out/round1/gate/test-task.log new file mode 100644 index 00000000..df6724a7 --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/gate/test-task.log @@ -0,0 +1,101 @@ +OK valid task validates (exit 0) +OK unknown task key exits 2 (exit 2) +OK unsupported taskVersion exits 2 (exit 2) +OK invalid task id exits 2 (exit 2) +OK empty prompt exits 2 (exit 2) +OK NUL in expectExact exits 2 (exit 2) +OK out-of-range timeout exits 2 (exit 2) +OK missing mission file exits 4 (exit 4) +OK task with valid mission validates (exit 0) +OK invalid mission exits 2 (exit 2) +OK validate missing task exits 4 (exit 4) +OK validation does not modify the task file +OK prune dry-run exits 0 (exit 0) +OK dry-run deleted nothing +OK prune --keep=2 --yes removes oldest (exit 0) +OK kept exactly 2 newest runs +OK newest run kept, oldest pruned +OK append-only receipt written (3 entries) +OK sessions/workspaces untouched by prune +OK prune with invalid keep exits 4 (exit 4) +OK mock adapter: gate passes on matching mock response (exit 0) +OK mock adapter: expect-mismatch recorded (exit 1) +OK mismatch reason recorded +OK unknown adapter fails closed (exit 1) +OK mission task runs via mock adapter (exit 0) +OK mission section injected into generated prompt +OK retry of mission run succeeds (exit 0) +OK retriedFrom lineage recorded +OK mission section present after retry (relative path resolved) +OK retry of missing run exits 4 (exit 4) +OK skill install bundled ms-tools (exit 0) +OK installed to skills-available +OK double install refuses (exit 1) +OK activate enables skill (exit 0) +OK enabled dir populated +OK uninstall while enabled refuses (exit 1) +OK deactivate moves back to available (exit 0) +OK uninstall removes from available (exit 0) +OK seat with enabled skill launches +OK skill path delivered to adapter +OK shipped researcher contract resolves (ceiling + network) +OK resolve-role refuses a non-role document (conductor policy) (exit 2) +OK resolve-role refuses name/filename mismatch (exit 2) +OK resolve-role refuses unknown network declaration (exit 2) +OK resolve-role refuses duplicate role tool (exit 2) +OK resolve-role refuses unsupported tool (exit 2) +OK resolve-role refuses missing contract file (exit 4) +OK version 1 role prints no contract line +OK shipped version 2 roles resolve with their contracts (pm, cto, coder, reviewer) +OK resolve-role accepts a minimal version 2 role (exit 0) +OK resolve-role refuses an action outside the vocabulary (exit 2) +OK resolve-role refuses a gated-only action in a role file (exit 2) +OK resolve-role refuses a Vikunja verb no role may hold (exit 2) +OK resolve-role refuses a version 2 role whose contract is missing (exit 2) +OK resolve-role refuses a contract that is a symbolic link (exit 2) +OK seat launches under role ceiling +OK role ceiling narrows seat tools (read,write,bash -> read,bash) +OK narrowing recorded loudly +OK missing role contract refuses launch (exit 2) +OK missing-contract refusal names the role +OK empty ceiling intersection -> tool-free seat +OK tool-free outcome recorded loudly +OK overridden agents dir without seat definition refuses (exit 4) +OK seat-resolution refusal names the seat +OK policy: mission only -> mission tools +OK policy: task only -> task tools +OK policy: both -> intersection (task narrowed) +OK policy: empty intersection -> tool-free +OK invalid mission capabilities rejected (exit 2) +mosaic-task: unsupported mission tool: "sudo" (supported: read, write, edit, bash, grep, find, ls) +OK task run with user layer present (exit 0) +OK user context dispatched into generated prompt +OK workspace+tools task runs via mock (exit 0) +OK workspace path + tools delivered to adapter +OK persistent workspace created on host +OK plain task still runs (no workspace/tools) (exit 0) +OK workspace var present but empty when absent +OK tools empty when absent +OK unknown tool exits 2 (exit 2) +OK workspace traversal exits 2 (exit 2) +OK live hello task succeeds with exact marker +OK result.json written in run dir +OK result.json contents are correct +OK wrong expectExact fails with exit 1 (reason: expect-mismatch) +OK each run gets a distinct run dir (no clobber) +OK list shows both runs +OK fork base: teach succeeds (exit 0) +OK fork child recalls ancestor context (exit 0) +OK forked child recalled ancestor code word +OK ancestor session untouched by fork +OK child session dir has its own branch file +OK onboard without name exits 4 (non-interactive) (exit 4) +OK onboard --name renders profile (exit 0) +OK profile written +OK canon structure: required filled, optional placeholdered +OK canon sections present +OK user recall run succeeds +OK recalled user name (response: Jason) +OK no agent identity on headless run + +selftest: 98 passed, 0 failed diff --git a/agents/rocko/work/queue-56/out/round1/mutants.sh b/agents/rocko/work/queue-56/out/round1/mutants.sh new file mode 100755 index 00000000..5400d16f --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/mutants.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Row 56 mutants: each line applies one perl substitution to one file, runs +# the packages/runs tests, and expects a failure (killed). Restores after. +cd /mnt/storage/scratch/rocko-r56/tree || exit 4 +G=/mnt/storage/scratch/rocko-r56/gate +: > "$G/mutants.txt" +mut() { # id file perl-expr + local id="$1" f="$2" e="$3" + cp "$f" "$f.orig" + perl -0pi -e "$e" "$f" + if cmp -s "$f" "$f.orig"; then echo "$id NOT-APPLIED" >> "$G/mutants.txt"; mv -f "$f.orig" "$f"; return; fi + if timeout 300 env -u NODE_TEST_CONTEXT node --test 'packages/runs/tests/*.test.mjs' > "$G/mut-$id.log" 2>&1; then + echo "$id SURVIVED" >> "$G/mutants.txt" + else + echo "$id killed" >> "$G/mutants.txt" + fi + mv -f "$f.orig" "$f" +} +P=packages/runs/src/paths.mjs; R=packages/runs/src/runs.mjs; S=packages/runs/src/state.mjs; T=scripts/mosaic-task.mjs +mut M01 $P 's/return relative === "" \|\|/return true ||/' +mut M02 $P 's/relative !== "\.\." && //' +mut M03 $P 's/startsWith\(`\.\.\$\{path\.sep\}`\)/startsWith("..x")/' +mut M04 $P 's/ && !path\.isAbsolute\(relative\)\)/)/' +mut M05 $P 's/ && !Array\.isArray\(value\) \? value/ ? value/' +mut M06 $P 's/error\?\.code === "ENOENT" \|\| error\?\.code === "ENOTDIR"/error?.code === "ENOENT"/' +mut M07 $P 's/if \(isMissing\(error\)\) return null;\n throw new RunsError\(`cannot resolve/return null;\n throw new RunsError(`cannot resolve/' +mut M08 $P 's/if \(!isInside\(root, real\)\)/if (false)/' +mut M09 $R 's/filter\(\(name\) => name\.startsWith\("r-"\)\)\.sort\(\)/filter((name) => name.startsWith("r-"))/' +mut M10 $R 's/filter\(\(name\) => name\.startsWith\("r-"\)\)/filter(() => true)/' +mut M11 $R 's/if \(isMissing\(error\)\) return \[\];\n throw/return [];\n throw/' +mut M12 $R 's/if \(!RUN_DOCUMENTS\.includes\(name\)\) throw/if (false) throw/' +mut M13 $R 's/export function readRunRecord\(dataRoot, runId\) \{\n requireRunId\(runId\);/export function readRunRecord(dataRoot, runId) {/' +mut M14 $R 's/if \(resolveInside\(dataRoot, RUNS_DIRNAME\) === null\) return null;\n//' +mut M15 $R 's/if \(isMissing\(error\)\) return null;\n throw new RunsError\(`cannot read \$\{dir\}/return null;\n throw new RunsError(`cannot read ${dir}/' +mut M16 $R 's/RUN_ID_PATTERN = \/\^r-/RUN_ID_PATTERN = \/r-/' +mut M17 $S 's/if \(!POINTER_KEYS\.includes\(key\)\) throw/if (false) throw/' +mut M18 $S 's/if \(pointer\.pointerVersion !== 1\)/if (false)/' +mut M19 $S 's/new RunsError\(`release pointer \$\{why\} \(\$\{state\.file\}\)`, 2\)/new RunsError(`release pointer ${why} (${state.file})`)/' +mut M20 $S 's/if \(Object\.keys\(entry\)\.some\(\(key\) => !LOG_KEYS\.includes\(key\)\)\) return null;//' +mut M21 $S 's/if \(entry\.note !== undefined && typeof entry\.note !== "string"\) return null;//' +mut M22 $S 's/entries\.slice\(-last\)/entries.slice(0, last)/' +mut M23 $S 's/if \(line\.trim\(\) === ""\) continue;//' +mut M24 $S 's/if \(last !== undefined && \(!Number\.isInteger\(last\) \|\| last < 1\)\)/if (false)/' +mut M25 $S 's/ throw new RunsError\(`cannot read \$\{file\}/ return null; throw new RunsError(`cannot read ${file}/' +mut M26 $S 's/if \(!isNonEmptyString\(pointer\[key\]\)\)/if (pointer[key] === undefined)/' +mut M27 $S 's/\["at", "event", "release", "imageTag"\]\.every/["event"].every/' +mut M28 $T 's/ if \(result\) \{\n status = result\.status;/ if (true) {\n status = result.status;/' +mut M29 $T 's/ if \(record === null\) \{\n fail\(4, `run not found/ if (false) {\n fail(4, `run not found/' +mut M30 $T 's/if \(error instanceof RunsError\) fail\(error\.exitCode, error\.message\);/if (false) fail(error.exitCode, error.message);/' +mut M31 $T 's/ if \(!isRunId\(runId\)\) \{\n fail\(4, `invalid run id/ if (false) {\n fail(4, `invalid run id/' +mut M32 $T 's/artifacts\.join\(", "\)/artifacts.join(",")/' +echo done >> "$G/mutants.txt" diff --git a/agents/rocko/work/queue-56/out/round1/mutants.txt b/agents/rocko/work/queue-56/out/round1/mutants.txt new file mode 100644 index 00000000..ca380240 --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/mutants.txt @@ -0,0 +1,33 @@ +M01 killed +M02 killed +M03 killed +M04 SURVIVED +M05 killed +M06 killed +M07 killed +M08 killed +M09 killed +M10 killed +M11 killed +M12 killed +M13 killed +M14 killed +M15 killed +M16 killed +M17 killed +M18 killed +M19 killed +M20 killed +M21 killed +M22 killed +M23 killed +M24 killed +M25 killed +M26 killed +M27 killed +M28 killed +M29 killed +M30 killed +M31 SURVIVED +M32 killed +done diff --git a/agents/rocko/work/queue-56/out/round1/runs-tests-final.log b/agents/rocko/work/queue-56/out/round1/runs-tests-final.log new file mode 100644 index 00000000..8f3d7d48 --- /dev/null +++ b/agents/rocko/work/queue-56/out/round1/runs-tests-final.log @@ -0,0 +1,46 @@ +✔ isRunId accepts the run id shape and nothing else (0.965437ms) +✔ a missing data root or runs directory lists nothing (1.475094ms) +✔ runs as a regular file lists nothing, as before (0.530968ms) +✔ listRunIds keeps r- names only, sorted oldest first (0.699171ms) +✔ listRunRecords returns each result, or null for an incomplete or unreadable one (1.781005ms) +✔ readRunRecord returns documents and artifacts in directory order (0.916419ms) +✔ readRunRecord is null for a missing run, a dangling link or a file (0.587172ms) +✔ readRunRecord and readRunDocument refuse an invalid run id before touching the disk (0.652461ms) +✔ readRunDocument reads only the three run documents (0.499342ms) +✔ a link inside the data root is followed (0.889387ms) +✔ a data root that is itself a link is trusted as configured (0.553199ms) +✔ a run directory linked out of the data root is never read (0.575368ms) +✔ a document linked out of the data root reads as null (0.894385ms) +✔ a runs directory linked out of the data root refuses (0.51067ms) +✔ a relative link that climbs out of the data root refuses (0.396113ms) +✔ a sibling whose name starts with the data root's name is outside it (0.499228ms) +✔ a link loop refuses instead of reading as missing (0.684475ms) +✔ an unreadable runs directory refuses instead of listing nothing (0.383259ms) +✔ an unreadable run directory refuses instead of reading as missing (0.467145ms) +✔ a link to the data root's parent is outside it (0.369657ms) +✔ listRunIds sorts whatever order the directory returns (0.670714ms) +✔ the readers write nothing (1.473542ms) +✔ no pointer is null (1.564788ms) +✔ the pointer release.sh writes reads back (1.031358ms) +✔ a pointer that isn't the version 1 shape refuses with exit 2 (1.483582ms) +✔ a pointer that is a directory refuses with exit 4 (0.499812ms) +✔ a state file linked out of the data root refuses (1.409385ms) +✔ a state directory linked out of the data root refuses (0.530763ms) +✔ a missing log is empty (0.381369ms) +✔ the log reads oldest first and counts malformed lines (0.826565ms) +✔ last must be a positive integer (0.441409ms) +✔ the state readers write nothing (0.824495ms) +✔ list prints each run in the established format (71.57906ms) +✔ show prints the run in the established format (64.020206ms) +✔ show of a missing run and an invalid id exit 4 as before (101.737552ms) +✔ list and show refuse a runs directory linked out of the data root (131.150958ms) +✔ show refuses a run linked out of the data root; list reports it unknown (134.659276ms) +ℹ tests 37 +ℹ suites 0 +ℹ pass 37 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 547.924201 +rc=0 diff --git a/agents/rocko/work/queue-56/out/round2-delta.diff b/agents/rocko/work/queue-56/out/round2-delta.diff new file mode 100644 index 00000000..ca0e1981 --- /dev/null +++ b/agents/rocko/work/queue-56/out/round2-delta.diff @@ -0,0 +1,156 @@ +diff --git tree/agents/rocko/work/queue-56/delta-check.sh r2/agents/rocko/work/queue-56/delta-check.sh +index 69224ea..a502d1d 100755 +--- tree/agents/rocko/work/queue-56/delta-check.sh ++++ r2/agents/rocko/work/queue-56/delta-check.sh +@@ -59,4 +59,7 @@ if [ "$(id -u)" != 0 ]; then + 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 tree/packages/runs/README.md r2/packages/runs/README.md +index 84230a1..8fd7808 100644 +--- tree/packages/runs/README.md ++++ r2/packages/runs/README.md +@@ -40,7 +40,11 @@ printed them. + - a run document resolving outside reads as `null`; + - `active.json` or `activation-log.jsonl` resolving outside refuses. + +- A link that stays inside the data root is followed. ++ 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, +@@ -49,11 +53,14 @@ printed them. + 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 lenient per line.** An entry needs string `at`, +- `event`, `release` and `imageTag`, an optional string `note`, and no +- other keys. Other lines are counted in `malformed` and skipped, the way +- `release.sh rollback` skips them. Blank lines aren't counted. `last` +- must be a positive integer and keeps the newest entries. ++- **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 +@@ -78,6 +85,7 @@ output changes on purpose: + | `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 +diff --git tree/packages/runs/src/state.mjs r2/packages/runs/src/state.mjs +index a92c871..af5cc92 100644 +--- tree/packages/runs/src/state.mjs ++++ r2/packages/runs/src/state.mjs +@@ -60,10 +60,11 @@ function logEntry(line) { + return entry; + } + +-// The activation log as { entries, malformed }, oldest first. Lines that +-// aren't a well-formed entry are counted in malformed and skipped, the way +-// release.sh rollback skips them. A missing log is empty. With last, only +-// the newest last well-formed entries are returned. ++// 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)})`); +diff --git tree/packages/runs/tests/runs.test.mjs r2/packages/runs/tests/runs.test.mjs +index 85838e5..a652f62 100644 +--- tree/packages/runs/tests/runs.test.mjs ++++ r2/packages/runs/tests/runs.test.mjs +@@ -216,6 +216,15 @@ test("listRunIds sorts whatever order the directory returns", (t) => { + 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"); +diff --git tree/packages/runs/tests/state.test.mjs r2/packages/runs/tests/state.test.mjs +index 80ab5ea..4b126f3 100644 +--- tree/packages/runs/tests/state.test.mjs ++++ r2/packages/runs/tests/state.test.mjs +@@ -72,6 +72,13 @@ test("a state directory linked out of the data root refuses", (t) => { + 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 }); +@@ -95,11 +102,13 @@ test("the log reads oldest first and counts malformed lines", (t) => { + { ...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: 5 }); +- assert.deepEqual(readActivationLog(dataRoot, { last: 2 }), { entries: good.slice(2), malformed: 5 }); ++ 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); + }); + +diff --git tree/packages/runs/tests/task-cli.test.mjs r2/packages/runs/tests/task-cli.test.mjs +index b5fafd4..ed13ecd 100644 +--- tree/packages/runs/tests/task-cli.test.mjs ++++ r2/packages/runs/tests/task-cli.test.mjs +@@ -92,3 +92,20 @@ test("show refuses a run linked out of the data root; list reports it unknown", + 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-)\n'); ++}); diff --git a/agents/rocko/work/queue-56/out/runs-tests.log b/agents/rocko/work/queue-56/out/runs-tests.log new file mode 100644 index 00000000..092e6c56 --- /dev/null +++ b/agents/rocko/work/queue-56/out/runs-tests.log @@ -0,0 +1,50 @@ +✔ isRunId accepts the run id shape and nothing else (1.673322ms) +✔ a missing data root or runs directory lists nothing (2.484645ms) +✔ runs as a regular file lists nothing, as before (0.904005ms) +✔ listRunIds keeps r- names only, sorted oldest first (1.157425ms) +✔ listRunRecords returns each result, or null for an incomplete or unreadable one (2.412977ms) +✔ readRunRecord returns documents and artifacts in directory order (1.628667ms) +✔ readRunRecord is null for a missing run, a dangling link or a file (1.084271ms) +✔ readRunRecord and readRunDocument refuse an invalid run id before touching the disk (1.044359ms) +✔ readRunDocument reads only the three run documents (0.837895ms) +✔ a link inside the data root is followed (1.571328ms) +✔ a data root that is itself a link is trusted as configured (0.9115ms) +✔ a run directory linked out of the data root is never read (1.032763ms) +✔ a document linked out of the data root reads as null (1.651968ms) +✔ a runs directory linked out of the data root refuses (0.9743ms) +✔ a relative link that climbs out of the data root refuses (0.753436ms) +✔ a sibling whose name starts with the data root's name is outside it (0.869964ms) +✔ a link loop refuses instead of reading as missing (0.998801ms) +✔ an unreadable runs directory refuses instead of listing nothing (0.706462ms) +✔ an unreadable run directory refuses instead of reading as missing (0.727334ms) +✔ a link to the data root's parent is outside it (0.619388ms) +✔ listRunIds sorts whatever order the directory returns (1.204458ms) +✔ listRunRecords lists an r- name that isn't a valid run id, as before (0.759264ms) +✔ the readers write nothing (3.124064ms) +✔ no pointer is null (2.06006ms) +✔ the pointer release.sh writes reads back (1.253317ms) +✔ a pointer that isn't the version 1 shape refuses with exit 2 (2.510411ms) +✔ a pointer that is a directory refuses with exit 4 (1.188753ms) +✔ a state file linked out of the data root refuses (1.074534ms) +✔ a state directory linked out of the data root refuses (0.628466ms) +✔ a state directory linked out to nothing reads as no release and an empty log (0.568961ms) +✔ a missing log is empty (0.501754ms) +✔ the log reads oldest first and counts malformed lines (1.269789ms) +✔ last must be a positive integer (0.577624ms) +✔ the state readers write nothing (0.939778ms) +✔ list prints each run in the established format (109.147436ms) +✔ show prints the run in the established format (100.576139ms) +✔ show of a missing run and an invalid id exit 4 as before (145.04362ms) +✔ list and show refuse a runs directory linked out of the data root (172.596296ms) +✔ show refuses a run linked out of the data root; list reports it unknown (169.239482ms) +✔ list shows an r- name that isn't a valid run id, as before (86.181593ms) +✔ show refuses an invalid id before reading the config (58.252566ms) +ℹ tests 41 +ℹ suites 0 +ℹ pass 41 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 911.848651 +rc=0 diff --git a/agents/rocko/work/queue-56/packet-manifest.sha256 b/agents/rocko/work/queue-56/packet-manifest.sha256 new file mode 100644 index 00000000..348c2451 --- /dev/null +++ b/agents/rocko/work/queue-56/packet-manifest.sha256 @@ -0,0 +1,42 @@ +6b8358f91b7512a5514d21e32020d7bb51a526c37c6989be93f9e3d608781189 ./base.txt +ad2ee26c16bd13b5d78a5bcedff53b1e5c5e4e11560270cb23d99bc5ca23e289 ./BUILD.md +d2fa5fb7f19ab328b51bf0e8a42beb419914ec6f99cb022cc46e88666a0cdca7 ./build.patch +1afe07062349b097b955f0391bffb839b083492d02644421def6c39cc19ff4a4 ./byte-check.sh +2727198f7e50b935205546a60ea97d8d4f20d87cad2a88e10477148c8d3dc42e ./candidate-manifest.sha256 +09788e902a54207ef5f94de1c8b7f2f31c049fb028fa68847115da83060d90f9 ./delta-check.sh +9bbbf163e4625fc966b4f8368b7a578c752f320a0cf9cb82dc63b49c8a055630 ./files.txt +ffeb69dfd69070bc54ac31543737d0f669b5f44f1546919d47370a61f52e2bb9 ./out/apply-75ab1646.txt +0bf93f746d1c2fa0cc9fd38ac63ade144d946f0588e9a74add9dc4ac19a1aae4 ./out/byte-check.txt +9fe4d344ec31b3a0e7f138f7e1cca4a4d01c7c7b7693386fb87b2573acb29a34 ./out/delta-check.txt +422d525e9cdcdfe26156b6c4f9ae8b3bcee0715b2c618b8de459ec634750f87d ./out/gate/test-task.log +e00191a5e3c4998ef63061a02f46b3eea8c1e086f1c309a2aaa95191bd7fae1e ./out/mutants.sh +486c2f735ceb41ab070d778d742662e7a25a6a7b325a57fbc1ce9aedef0f2e5c ./out/mutants.txt +afe2b2f318df2962e739f72ee191e8d214c4f4d5f17d474fce0ae34d0ca306ad ./out/round1/apply-923957e2.txt +10c6593fe07f295f7bbe209e608664d8308fbe663a6160353b130d888d39e000 ./out/round1/apply-dc96f87b.txt +0bf93f746d1c2fa0cc9fd38ac63ade144d946f0588e9a74add9dc4ac19a1aae4 ./out/round1/byte-check.txt +7b1dca80e97d7b0660a38d3daf5f4454380c730a397da652ac9af23dfb3eadb6 ./out/round1/delta-check.txt +d81f2ce71f7c9bc3be43f38bb50d4fcea40bc99d099a629c07e6f449ea0e362f ./out/round1/gate/run.sh +1f6649145018f4f9b298a98c10b3c0b471214d9cbd0c19a41ce5046a47f41800 ./out/round1/gate/runs.log +7278e43ff0a3d60f45c2ed211c25729c105dbf7a9df76eeebd388ea4d73615b6 ./out/round1/gate/summary.txt +f91fc95a173339137f981e24fbf630acfde957e32c3cd71e28304532083c6a77 ./out/round1/gate/test-auth.log +56c85ba7965e49a10acc80f934874d4be14ae80af81efa72561a293850a2eae2 ./out/round1/gate/test-conductor.log +52d211444489ea729dafc3c9745d4474d9dfd50f41055cbad792424197f04073 ./out/round1/gate/test-config.log +c2cdde34b9447351324f2e3e9c160db0c3ff6249057099214218e7737aa7ceb8 ./out/round1/gate/test-discord.log +7d5016ded1994048642f8b9ae845e52e8af4a069ba05c056c8f580a4589bacbe ./out/round1/gate/test-discord-r2.log +ca044dc45b79206743aba8e22d84d9eecba146c518458dc97a3325cf93eba493 ./out/round1/gate/test-extension-package.log +83dfaa21c4e4ad941088efab3f69e87ebd2f6bd3cc72ede938e21dc06da8a34f ./out/round1/gate/test-foundation.log +d86110a3cbaf73c6f8032ae9ff1b737ca8b405281f17673de5b0b8b33a558a14 ./out/round1/gate/test-queue.log +fa832794ee35025fb4559d58d01a270f6bff2eb65e863e9075b797f47fa68545 ./out/round1/gate/test-release.log +ccea37f4ffde1f160dcd1a1864988083a645d974e892065c956b338d0f8c853a ./out/round1/gate/test-task.log +faf3049f543179face1645e81ad64c7172b4d77b40dad0cbc6211bff1630619d ./out/round1/mutants.sh +e4bdafcbe4ef48ae1676c5573d340c46b072e1ae285fc917efd03768bd784192 ./out/round1/mutants.txt +010e2694eb37248355025ee1c6b7295b184a302991862722713a43f45e31b26c ./out/round1/runs-tests-final.log +6462e1c49fa3bde202f453d17ffa9dc32fd2a8d73fb8c83b66e1855d69c4afed ./out/round2-delta.diff +9ac76c6c297b07f2e88d330e948eb9a42a19d3103c53c6fe6e95402a35bcea83 ./out/runs-tests.log +7a4dae1dff789670061c23adc072504a84db9c8d45847cfa54a6c45501354031 ./r1/base.txt +696923cc43617d89c5501ee72961390fed0f8bb6fcef843970b736be3d049758 ./r1/BUILD.md +dd7b469b5aed4ae0e7eed39495e5a1677c36391e9fc0d254a49272db5168d8f0 ./r1/build.patch +ed3c5392ae43e1c3541c6bab6a2f25826514124f5f68536e5cc330a39cc5d7a3 ./r1/candidate-manifest.sha256 +9bbbf163e4625fc966b4f8368b7a578c752f320a0cf9cb82dc63b49c8a055630 ./r1/files.txt +e0497ad1bf92c41c8eec719e655f0d19840550ca5f0c9271b9c76f81d68ed1c3 ./r1/packet-manifest.sha256 +15d575cc45313906114f7edda9379b66af7624d9e894bb98812deb7acf5d87ba ./seed.sh diff --git a/agents/rocko/work/queue-56/r1/BUILD.md b/agents/rocko/work/queue-56/r1/BUILD.md new file mode 100644 index 00000000..84927825 --- /dev/null +++ b/agents/rocko/work/queue-56/r1/BUILD.md @@ -0,0 +1,186 @@ +# Row 56 (#1545): runs and releases reader module, candidate packet, round 1 + +Author: Rocko. Reviewers: Darkwing and Filbert. Brief: +`docs/plans/2026-10-10_design-implementation.md`, section "Runs and releases +reader module". Base: `dc96f87b` (`base.txt`). `build.patch` also applies +cleanly to `923957e2` (origin/refactor at packet time). No file in this +candidate changed between the two (`out/apply-*.txt`). The packet is +uncommitted. I made no commits, pushes or Gitea calls, and read no token or +private binding. + +## Files (`files.txt`, 15) + +| File | Change | +|---|---| +| `packages/runs/package.json` | new: `@mosaic/runs`, private, no dependencies | +| `packages/runs/src/errors.mjs` | new: `RunsError(message, exitCode = 4)` | +| `packages/runs/src/paths.mjs` | new: `resolveInside` containment, `readJsonObject` | +| `packages/runs/src/runs.mjs` | new: `isRunId`, `listRunIds`, `listRunRecords`, `readRunDocument`, `readRunRecord` | +| `packages/runs/src/state.mjs` | new: `readActivePointer`, `readActivationLog` | +| `packages/runs/src/index.mjs` | new: re-exports | +| `packages/runs/README.md` | new: what it reads, the limits, the deliberate deltas | +| `packages/runs/tests/*.mjs` | new: 37 tests (runs 22, state 10, task-cli 5) plus helpers | +| `scripts/mosaic-task.mjs` | `list` and `show` read through the module, +18/−27 | +| `agents/rocko/work/queue-56/{seed,byte-check,delta-check}.sh` | the byte-identity and delta checks | + +`build.patch` is `git diff --cached --binary dc96f87b` over those files: ++1021/−27. `candidate-manifest.sha256` hashes the 15 files. + +Q14 freeze: nothing under `packages/bus`, `packages/tasks`, `packages/cli`, +`scripts/bus-service.sh` or `scripts/mosaic` changes. Apart from the import, +`scripts/mosaic-task.mjs` is the only file under `scripts/` that changes. The +module covers the runs view's needs, but the view itself, `release.sh` and +pruning are untouched. + +## Design + +- **Containment.** `resolveInside` runs `realpathSync` on the data root and + the target, then applies a `path.relative` check: + - `..`, `../…` or an absolute relative path counts as outside; + - the configured data root is trusted even when it is itself a link; + - links that stay inside it are followed. +- **Outside paths.** + - `runs/`, `state/`, a run directory read by `readRunRecord`, `active.json` + and the activation log refuse when they resolve outside. + - A run document that resolves outside reads as `null`, the way an + unreadable one always has. +- **Missing versus broken.** ENOENT and ENOTDIR read as missing. Anything + else (ELOOP, EACCES) refuses with the error code instead of reading as + empty. +- **Documents.** A run document is a JSON object or `null`. Run records are + not validated further, because older records carry older shapes. +- **Release pointer.** `active.json` is strict: exactly the version 1 shape + `release.sh` writes. Bad data exits 2, and a read error exits 4. +- **Activation log.** The log is lenient per line, like `release.sh + rollback`. It returns `{ entries, malformed }`, and `last` keeps the newest + entries. +- **Errors.** Every refusal is a `RunsError` with exit code 2 or 4. + `mosaic-task.mjs` maps it to `fail(exitCode, message)` through + `readRuns`. +- **Read-only.** No function writes. The "readers write nothing" test and + byte-check's before/after snapshot of the data root both check this. + +## Gate (`out/gate/`, sequential, scratch worktree of the candidate) + +| Suite | Result | +|---|---| +| packages/runs tests | 34/34 at gate time; 37/37 after the mutant round (`out/runs-tests-final.log`) | +| test-auth | 15 passed, 0 failed | +| test-conductor | 17 passed, 0 failed | +| test-config | 24 passed, 0 failed | +| test-discord | first run 57 passed, 1 failed; rerun 66 passed, 0 failed (below) | +| test-extension-package | 18 passed, 0 failed | +| test-foundation | 44 passed, 0 failed | +| test-queue | 27 passed, 0 failed | +| test-release | 14 passed, 0 failed | +| test-task | 98 passed, 0 failed | + +**test-discord's first-run failure was environmental.** The failing case was +"pi binary present at node_modules/.bin/pi for the extension checks". The +scratch worktree has no `node_modules`, and the base worktree lacks it too. +I symlinked the checkout's `node_modules` into the scratch tree and reran +the suite: 66 passed, 0 failed. The extra 8 cases are the extension checks +that the missing binary had skipped. Then I removed the symlink +(`out/gate/test-discord-r2.log`). Nothing in this candidate touches +packages/discord. Sage's rerun in a tree that has `node_modules` should see +66. + +After the gate, the only changes were tests, the README table row and the +check scripts. No `src/` or `scripts/mosaic-task.mjs` change came after it. + +## Byte identity (`out/byte-check.txt`) + +`byte-check.sh BASE CAND WORK` seeds a data root with `seed.sh`. The seed +holds: + +- runs a–f; +- a run that is a file; +- an internal link and a dangling link; +- `r-zz.weird_name-1`; +- a non-`r-` name, `.pruned.log` and a stray file. + +It runs 16 cases from both trees: `list` on seeded, empty and absent roots, +and `show` on each run, a missing run, `../escape`, no argument and an +absent root. It diffs stdout, stderr and exit codes. + +``` +byte-check: 16 cases, stdout, stderr and exit identical +byte-check: data root unchanged +``` + +**Correction to my own harness.** My first rerun in this round passed a +relative WORK_DIR. Every redirect inside the `cd` subshell failed, both trees +recorded exit 1, and the diff still reported "identical": a false pass. +Earlier runs used absolute paths and were not affected. Both scripts now +make WORK_DIR absolute before doing anything else. `byte-check.sh` also +exits 4 if any case leaves no stdout or stderr file. The output above +comes from the fixed script. + +## Deliberate deltas (`out/delta-check.txt`) + +These cases fall outside the byte-identity domain: a link out of the data +root, a document that is JSON but not an object, a run that is a file, a +link loop, and an unreadable directory. Each one changes on purpose. The +README table lists them, and `delta-check.sh` prints base against candidate: + +- run directory linked out: `list` shows `unknown`; `show` refuses, exit 4. +- `result.json` linked out: `list` shows `unknown`; `show` prints + `(missing or unreadable)`. +- `runs/` linked out: `list` and `show` refuse, exit 4. +- `result.json` is `5`: base `list` crashed (exit 1); base `show` printed + `undefined` fields. The candidate gives `unknown` and + `(missing or unreadable)`. +- `task.json` is `[]`: base printed the snapshot line; the candidate + omits it. +- run is a regular file: base `show` crashed in `readdirSync`; the + candidate gives `run not found`, exit 4. +- link loop: base said `run not found`; the candidate refuses with ELOOP, + exit 4. +- `runs/` unreadable: base `list` printed nothing, exit 0; the candidate + refuses with EACCES, exit 4. + +## Mutants (`out/mutants.sh`, `out/mutants.txt`) + +I ran 32 single-line mutants of `src/` and `scripts/mosaic-task.mjs` +against the packages/runs tests. + +First round: 26 killed, 4 survived and 2 didn't apply. Three of the +survivors were real gaps, and I added a test for each: + +- **M02** dropped the exact `..` check. A link to the data root's parent got + through. New test: "a link to the data root's parent is outside it". +- **M09** dropped the sort. Node returned this directory already sorted, + so no fixture could catch it. New test: it mocks `fs.readdirSync` to + return the names reversed. +- **M15** read every readdir error on a run directory as missing. New + test: "an unreadable run directory refuses instead of reading as + missing". + +Final round: 30 killed, 2 survived. Both survivors are equivalent: + +- **M04** dropped `!path.isAbsolute(relative)`. On POSIX, `path.relative` + between two absolute paths is never absolute, so the branch is + unreachable on Linux. I kept it as a guard for other platforms. +- **M31** dropped the CLI's `isRunId` pre-check in `show`. `readRunRecord` + throws the same message with exit 4, and `readRuns` maps it to the same + `fail`. The pre-check stays so that an invalid id refuses before the + config loads, as it did before. + +## Follow-up (not fixed, out of scope) + +This defect predates the candidate and is unchanged: `list` crashes with +a `padEnd` TypeError when `result.json` is an object without a string +`status` or `taskId`. Base and candidate behave the same here, so +byte-identity holds. A fix would change `list` output and belongs in its +own row. + +## Reproduce + +```sh +git worktree add --detach /tmp/r56-base dc96f87b +git worktree add --detach /tmp/r56-cand dc96f87b +(cd /tmp/r56-cand && git apply --index /build.patch && sha256sum -c /candidate-manifest.sha256) +env -u NODE_TEST_CONTEXT node --test '/tmp/r56-cand/packages/runs/tests/*.test.mjs' +bash /tmp/r56-cand/agents/rocko/work/queue-56/byte-check.sh /tmp/r56-base /tmp/r56-cand /tmp/r56-bc +bash /tmp/r56-cand/agents/rocko/work/queue-56/delta-check.sh /tmp/r56-base /tmp/r56-cand /tmp/r56-dc +``` diff --git a/agents/rocko/work/queue-56/r1/base.txt b/agents/rocko/work/queue-56/r1/base.txt new file mode 100644 index 00000000..c2ff941e --- /dev/null +++ b/agents/rocko/work/queue-56/r1/base.txt @@ -0,0 +1 @@ +dc96f87b1776cc74d0ea5ba7701db418cc21e1b8 diff --git a/agents/rocko/work/queue-56/r1/build.patch b/agents/rocko/work/queue-56/r1/build.patch new file mode 100644 index 00000000..2d9610fe --- /dev/null +++ b/agents/rocko/work/queue-56/r1/build.patch @@ -0,0 +1,1178 @@ +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..69224ead +--- /dev/null ++++ b/agents/rocko/work/queue-56/delta-check.sh +@@ -0,0 +1,62 @@ ++#!/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" ++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..84230a17 +--- /dev/null ++++ b/packages/runs/README.md +@@ -0,0 +1,84 @@ ++# Runs ++ ++Read-only readers for the run records under `/runs/` and the ++release state under `/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. ++ ++ 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 lenient per line.** An entry needs string `at`, ++ `event`, `release` and `imageTag`, an optional string `note`, and no ++ other keys. Other lines are counted in `malformed` and skipped, the way ++ `release.sh rollback` skips them. 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 | ++| 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 /runs/ and the release pointer and activation log under /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 /runs/ ++// and the release pointer and activation log under /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 / 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 /, 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-)`); ++} ++ ++// Every name under /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..a92c8713 +--- /dev/null ++++ b/packages/runs/src/state.mjs +@@ -0,0 +1,82 @@ ++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. Lines that ++// aren't a well-formed entry are counted in malformed and skipped, the way ++// release.sh rollback skips them. 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..85838e5b +--- /dev/null ++++ b/packages/runs/tests/runs.test.mjs +@@ -0,0 +1,234 @@ ++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]); ++}); ++ ++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..80ab5ea5 +--- /dev/null ++++ b/packages/runs/tests/state.test.mjs +@@ -0,0 +1,122 @@ ++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 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 }, ++ good[2], ++ good[3], ++ ]); ++ assert.deepEqual(readActivationLog(dataRoot), { entries: good, malformed: 5 }); ++ assert.deepEqual(readActivationLog(dataRoot, { last: 2 }), { entries: good.slice(2), malformed: 5 }); ++ 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..b5fafd40 +--- /dev/null ++++ b/packages/runs/tests/task-cli.test.mjs +@@ -0,0 +1,94 @@ ++// 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-)\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`); ++}); +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-)`); + } + 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); + } + diff --git a/agents/rocko/work/queue-56/r1/candidate-manifest.sha256 b/agents/rocko/work/queue-56/r1/candidate-manifest.sha256 new file mode 100644 index 00000000..0aee8828 --- /dev/null +++ b/agents/rocko/work/queue-56/r1/candidate-manifest.sha256 @@ -0,0 +1,15 @@ +1afe07062349b097b955f0391bffb839b083492d02644421def6c39cc19ff4a4 agents/rocko/work/queue-56/byte-check.sh +96416ea2eb84490b79e98b9aa0f95673e76070f15ba2876f83dbf1bdaa919f97 agents/rocko/work/queue-56/delta-check.sh +15d575cc45313906114f7edda9379b66af7624d9e894bb98812deb7acf5d87ba agents/rocko/work/queue-56/seed.sh +146b236a37bf665979e28f1794c4c4437b6e85feb43b892e8d9d697506fc453f packages/runs/package.json +594eb42518f73c94608320ce381868026aca69d8f5104711c9685ebdd8df41c9 packages/runs/README.md +f686212fa10e8541a1d8055fe30d65558a516245276aa53ecefd97bd7ca0a20c packages/runs/src/errors.mjs +eb7063a8ee0417699b45f4bfec69c3ffcde8b3b276956ca4fea266ec0abd6b23 packages/runs/src/index.mjs +3f9d025f0615a08edd440bb2aa6cea48f21cafa1f680332fffca3c7ee586cc65 packages/runs/src/paths.mjs +ba64e50c3a63e693fc313278ff6cf440234649a6719dd9229bce20272a518ec1 packages/runs/src/runs.mjs +c013ed1912bb97cbfb5209170741d9a20c2955a9acbd1fff9e397fac1fab742a packages/runs/src/state.mjs +7c941596020b14d8627ccb09b66e0e51195318e86add60eba9d2c4409e6f8dd6 packages/runs/tests/helpers.mjs +4125ffc8787f3cd9441bc2a557f42fde6948b884b8b870b503acef6eee6b3913 packages/runs/tests/runs.test.mjs +7857d2c8c94011df328dbf5013626dbcdd577945781d104dff533f08b1859130 packages/runs/tests/state.test.mjs +6bf3e758c191e23fddec6bcf30d881cdda1bb37d4e3ed1cd4ef47be79fb36bd8 packages/runs/tests/task-cli.test.mjs +4873187609897c643e62acf7f1d074fa3c03369fe6702b9b0c7776138e776401 scripts/mosaic-task.mjs diff --git a/agents/rocko/work/queue-56/r1/files.txt b/agents/rocko/work/queue-56/r1/files.txt new file mode 100644 index 00000000..3b9e6697 --- /dev/null +++ b/agents/rocko/work/queue-56/r1/files.txt @@ -0,0 +1,15 @@ +agents/rocko/work/queue-56/byte-check.sh +agents/rocko/work/queue-56/delta-check.sh +agents/rocko/work/queue-56/seed.sh +packages/runs/package.json +packages/runs/README.md +packages/runs/src/errors.mjs +packages/runs/src/index.mjs +packages/runs/src/paths.mjs +packages/runs/src/runs.mjs +packages/runs/src/state.mjs +packages/runs/tests/helpers.mjs +packages/runs/tests/runs.test.mjs +packages/runs/tests/state.test.mjs +packages/runs/tests/task-cli.test.mjs +scripts/mosaic-task.mjs diff --git a/agents/rocko/work/queue-56/r1/packet-manifest.sha256 b/agents/rocko/work/queue-56/r1/packet-manifest.sha256 new file mode 100644 index 00000000..5081f9fa --- /dev/null +++ b/agents/rocko/work/queue-56/r1/packet-manifest.sha256 @@ -0,0 +1,28 @@ +7a4dae1dff789670061c23adc072504a84db9c8d45847cfa54a6c45501354031 ./base.txt +696923cc43617d89c5501ee72961390fed0f8bb6fcef843970b736be3d049758 ./BUILD.md +dd7b469b5aed4ae0e7eed39495e5a1677c36391e9fc0d254a49272db5168d8f0 ./build.patch +1afe07062349b097b955f0391bffb839b083492d02644421def6c39cc19ff4a4 ./byte-check.sh +ed3c5392ae43e1c3541c6bab6a2f25826514124f5f68536e5cc330a39cc5d7a3 ./candidate-manifest.sha256 +96416ea2eb84490b79e98b9aa0f95673e76070f15ba2876f83dbf1bdaa919f97 ./delta-check.sh +9bbbf163e4625fc966b4f8368b7a578c752f320a0cf9cb82dc63b49c8a055630 ./files.txt +afe2b2f318df2962e739f72ee191e8d214c4f4d5f17d474fce0ae34d0ca306ad ./out/apply-923957e2.txt +10c6593fe07f295f7bbe209e608664d8308fbe663a6160353b130d888d39e000 ./out/apply-dc96f87b.txt +0bf93f746d1c2fa0cc9fd38ac63ade144d946f0588e9a74add9dc4ac19a1aae4 ./out/byte-check.txt +7b1dca80e97d7b0660a38d3daf5f4454380c730a397da652ac9af23dfb3eadb6 ./out/delta-check.txt +d81f2ce71f7c9bc3be43f38bb50d4fcea40bc99d099a629c07e6f449ea0e362f ./out/gate/run.sh +1f6649145018f4f9b298a98c10b3c0b471214d9cbd0c19a41ce5046a47f41800 ./out/gate/runs.log +7278e43ff0a3d60f45c2ed211c25729c105dbf7a9df76eeebd388ea4d73615b6 ./out/gate/summary.txt +f91fc95a173339137f981e24fbf630acfde957e32c3cd71e28304532083c6a77 ./out/gate/test-auth.log +56c85ba7965e49a10acc80f934874d4be14ae80af81efa72561a293850a2eae2 ./out/gate/test-conductor.log +52d211444489ea729dafc3c9745d4474d9dfd50f41055cbad792424197f04073 ./out/gate/test-config.log +c2cdde34b9447351324f2e3e9c160db0c3ff6249057099214218e7737aa7ceb8 ./out/gate/test-discord.log +7d5016ded1994048642f8b9ae845e52e8af4a069ba05c056c8f580a4589bacbe ./out/gate/test-discord-r2.log +ca044dc45b79206743aba8e22d84d9eecba146c518458dc97a3325cf93eba493 ./out/gate/test-extension-package.log +83dfaa21c4e4ad941088efab3f69e87ebd2f6bd3cc72ede938e21dc06da8a34f ./out/gate/test-foundation.log +d86110a3cbaf73c6f8032ae9ff1b737ca8b405281f17673de5b0b8b33a558a14 ./out/gate/test-queue.log +fa832794ee35025fb4559d58d01a270f6bff2eb65e863e9075b797f47fa68545 ./out/gate/test-release.log +ccea37f4ffde1f160dcd1a1864988083a645d974e892065c956b338d0f8c853a ./out/gate/test-task.log +faf3049f543179face1645e81ad64c7172b4d77b40dad0cbc6211bff1630619d ./out/mutants.sh +e4bdafcbe4ef48ae1676c5573d340c46b072e1ae285fc917efd03768bd784192 ./out/mutants.txt +010e2694eb37248355025ee1c6b7295b184a302991862722713a43f45e31b26c ./out/runs-tests-final.log +15d575cc45313906114f7edda9379b66af7624d9e894bb98812deb7acf5d87ba ./seed.sh