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-)`); } // Every name under /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, }; }