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