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:
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user