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:
2026-10-10 12:22:37 -05:00
co-authored by Claude Opus 5.5
parent 61ec09822f
commit 71d874764a
16 changed files with 1083 additions and 27 deletions
+9
View File
@@ -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;
}
}
+10
View File
@@ -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";
+49
View File
@@ -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;
}
}
+72
View File
@@ -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,
};
}
+83
View File
@@ -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 };
}