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/candidate-manifest.sha256 b/agents/rocko/work/queue-56/candidate-manifest.sha256 new file mode 100644 index 00000000..f90942be --- /dev/null +++ b/agents/rocko/work/queue-56/candidate-manifest.sha256 @@ -0,0 +1,15 @@ +1afe07062349b097b955f0391bffb839b083492d02644421def6c39cc19ff4a4 agents/rocko/work/queue-56/byte-check.sh +09788e902a54207ef5f94de1c8b7f2f31c049fb028fa68847115da83060d90f9 agents/rocko/work/queue-56/delta-check.sh +15d575cc45313906114f7edda9379b66af7624d9e894bb98812deb7acf5d87ba agents/rocko/work/queue-56/seed.sh +146b236a37bf665979e28f1794c4c4437b6e85feb43b892e8d9d697506fc453f packages/runs/package.json +895dd393a43221ffb12a5dc1b76ab61118b5562a5d7acbc50450ebb3ea6388d3 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 +ae546df81c6da7ef51680e23a12c01f08c5a6dc1b68cdf26c8d4210772149940 packages/runs/src/state.mjs +7c941596020b14d8627ccb09b66e0e51195318e86add60eba9d2c4409e6f8dd6 packages/runs/tests/helpers.mjs +fbaa092b5c8a1b54bc52c090da71f0da4313b4a8221fd7a2e89404a2a1b04633 packages/runs/tests/runs.test.mjs +d0f33cb945cb5960847cd9e9af05f5a85731cb68bc5131efb084a7ecea81591f packages/runs/tests/state.test.mjs +be5d1bdf95340e6c4a697531a225f08b40901ad1b570da7371efe7994e3e1413 packages/runs/tests/task-cli.test.mjs +4873187609897c643e62acf7f1d074fa3c03369fe6702b9b0c7776138e776401 scripts/mosaic-task.mjs 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 readRuns(read) { + try { + return read(); + } catch (error) { + if (error instanceof RunsError) fail(error.exitCode, error.message); + throw error; + } +} + function listRuns() { const resolved = loadConfig(); - const root = runsRoot(resolved); - let entries = []; - try { - entries = fs.readdirSync(root).filter((name) => name.startsWith("r-")).sort(); - } catch { - // No runs yet. - } - for (const runId of entries) { + 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); }