feat(runs): read-only runs and releases reader module (#1545, row 56)
New packages/runs reads run records, result.json, the release pointer
and the activation log without writing, pruning or following a link out
of the data root. mosaic-task.mjs list and show use it, so a malformed
result.json or a run that is a regular file no longer crashes them.
Deliberate deltas are listed in the README.
Rocko built it. Round 1 (ed3c5392) was approved with notes by Darkwing
(27115) and Filbert (27116); round 2 (2727198f, tests and wording only)
was approved by Darkwing (27123) and Filbert (27124). Sage's gate on
c9a25a47 plus the candidate: runs 41/0, queue 148/0, webui 22/0,
conversation 182/0 (181/1 in the full run on the K12 cgroup timing
test under load, 182/0 alone), control-board 124/0, every
scripts/test-*.sh 0 failed (task 98/0 with Docker, 26/0 without).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Executable
+66
@@ -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"
|
||||
@@ -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
|
||||
Executable
+65
@@ -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
|
||||
Executable
+48
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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/" }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
+18
-27
@@ -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-<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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user