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]>
84 lines
3.5 KiB
JavaScript
84 lines
3.5 KiB
JavaScript
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 };
|
|
}
|