/** * Canonical reader for run-directory typed terminal artifacts. * Layout owner is settlement publish*Artifacts (report.json / error.json / * audit-incomplete.json under artifacts/, plus the same publisher's durable * failure fallbacks). This module only reads presence and structural * readability — it does not re-derive role outcomes or invent a second * candidate algorithm. */ import { readdir, readFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { roleRunArtifactsDirectory } from "./role-run-placement.ts"; export const RUN_TERMINAL_ARTIFACT_FILES = [ "report.json", "error.json", "audit-incomplete.json", ] as const; export type RunTerminalArtifactFile = (typeof RUN_TERMINAL_ARTIFACT_FILES)[number]; /** * Fixed durable failure paths publishFailureArtifacts may settle when the * conventional artifacts/error.json name cannot be written. Shared face so the * reader follows the publisher — not a parallel search algorithm. * Relative to the run directory. */ export const RUN_TERMINAL_ERROR_FALLBACK_RELATIVE_PATHS = [ "artifacts/error.settlement.json", "error.settlement.json", ] as const; /** Unique open-ended failure names: error..json (publisher stem + uuid). */ const UNIQUE_ERROR_FALLBACK_NAME = /^error\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/i; export type RunTerminalArtifactRead = | { readonly status: "absent" } | { readonly status: "present"; readonly file: RunTerminalArtifactFile; readonly path: string; readonly body: Record; } | { readonly status: "unreadable"; readonly file: RunTerminalArtifactFile; readonly path: string; readonly reason: string; }; function isMissingPathError(error: unknown): boolean { return ( error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR") ); } function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * Minimum producer-owned face shared by settlement terminal artifacts * (report / error / audit-incomplete). Consumer-driven: enough to identify a * usable typed terminal artifact; null, arrays, primitives, and role-less * objects are unreadable (ADR 0043). */ function readUsableTerminalArtifactBody( body: unknown, ): { readonly ok: true; readonly body: Record } | { readonly ok: false; readonly reason: string } { if (body === null) { return { ok: false, reason: "terminal artifact JSON value is null" }; } if (!isRecord(body)) { return { ok: false, reason: `terminal artifact JSON value is not a typed object (${Array.isArray(body) ? "array" : typeof body})`, }; } if (typeof body.role !== "string" || body.role.trim() === "") { return { ok: false, reason: "terminal artifact missing nonblank producer-owned role field", }; } return { ok: true, body }; } async function readTerminalArtifactAtPath( path: string, file: RunTerminalArtifactFile, ): Promise { let raw: string; try { raw = await readFile(path, "utf8"); } catch (error) { if (isMissingPathError(error)) return undefined; return { status: "unreadable", file, path, reason: errorText(error), }; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (error) { return { status: "unreadable", file, path, reason: error instanceof Error ? error.message : `terminal artifact JSON parse failed: ${String(error)}`, }; } const usable = readUsableTerminalArtifactBody(parsed); if (!usable.ok) { return { status: "unreadable", file, path, reason: usable.reason, }; } return { status: "present", file, path, body: usable.body }; } async function listUniqueErrorFallbackPaths( directories: readonly string[], ): Promise { const found: string[] = []; for (const dir of directories) { let names: string[]; try { names = await readdir(dir); } catch (error) { if (isMissingPathError(error)) continue; throw error; } for (const name of names.sort((a, b) => a.localeCompare(b))) { if (!UNIQUE_ERROR_FALLBACK_NAME.test(name)) continue; found.push(join(dir, name)); } } return found; } /** * Publisher run-directory face is `@`. Parent-directory unique * fallbacks are shared across sibling runs, so binding uses this runId only. * Sole authority for runDirectory → runId (last `@` split). */ export function runIdFromRunDirectory(runDirectory: string): string | undefined { const name = basename(runDirectory); const at = name.lastIndexOf("@"); if (at <= 0 || at === name.length - 1) return undefined; return name.slice(0, at); } /** * Shared parent-directory unique fallback may be adopted only when the * publisher-owned body.runId equals this run directory's runId. Same-run * artifactsDir / runDirectory candidates keep path ownership and skip this. * expectedRunId undefined (unparseable run dir) → never bound. */ function presentUniqueFallbackBoundToRun( body: Record, expectedRunId: string | undefined, ): boolean { if (expectedRunId === undefined) return false; return typeof body.runId === "string" && body.runId === expectedRunId; } /** * Sole authority for seam-owned unique error..json candidates. * Used by both clearOpposite (settlement) and readRunTerminalArtifact — do not * re-enumerate the same-run / parent unique set elsewhere. * Ownership: * - same-run dirs (artifacts/, runDir): path ownership — all unique names * - parent runs/: only body.runId-bound faces; unparseable runId or unreadable body → none */ export async function listSeamOwnedUniqueErrorFacePaths( runDirectory: string, ): Promise { const artifactsDir = roleRunArtifactsDirectory(runDirectory); const owned: string[] = await listUniqueErrorFallbackPaths([ artifactsDir, runDirectory, ]); const expectedRunId = runIdFromRunDirectory(runDirectory); for (const path of await listUniqueErrorFallbackPaths([dirname(runDirectory)])) { const read = await readTerminalArtifactAtPath(path, "error.json"); if (read === undefined || read.status !== "present") continue; if (!presentUniqueFallbackBoundToRun(read.body, expectedRunId)) continue; owned.push(path); } return owned; } type PresentOrUnreadable = Exclude; /** * Publish contract (#953): only failure publish continues after clearOpposite * failure, so a multi-class residue (residual report/audit beside a new error * face) means the current settlement is failure. Prefer failure-class faces * over success/audit — never filesystem mtime (copy/restore/utimes can lie). */ function failureClassRank(file: RunTerminalArtifactFile): number { return file === "error.json" ? 1 : 0; } /** * Read the current typed terminal artifact for a run directory. * * Candidate set (publisher-owned faces only): * 1) conventional artifacts/{report,error,audit-incomplete}.json * 2) publisher fixed failure fallbacks (error.settlement.json faces) * 3) seam-owned unique error..json via listSeamOwnedUniqueErrorFacePaths * (same-run path ownership + parent body.runId binding — shared with clear) * * Invariant: when more than one present face remains (e.g. clearOpposite failed * during failure publish and a fallback was settled beside a residual report), * adopt failure-class over success/audit by the publish contract — not mtime. * Same-class ties keep candidate enumeration order (conventional before * fallbacks before unique). * * Unreadable faces never outrank a present face. Parent unique unreadable * files never enter the shared enumerator (cannot prove run identity). * Absence of every known durable face is a valid no-receipt state. */ export async function readRunTerminalArtifact( runDirectory: string, ): Promise { const artifactsDir = roleRunArtifactsDirectory(runDirectory); const present: Array> = []; const unreadable: Array> = []; const consider = (read: RunTerminalArtifactRead | undefined): void => { if (read === undefined || read.status === "absent") return; if (read.status === "present") { present.push(read); return; } unreadable.push(read); }; for (const file of RUN_TERMINAL_ARTIFACT_FILES) { consider(await readTerminalArtifactAtPath(join(artifactsDir, file), file)); } for (const relative of RUN_TERMINAL_ERROR_FALLBACK_RELATIVE_PATHS) { consider( await readTerminalArtifactAtPath(join(runDirectory, relative), "error.json"), ); } // Unique same-run + parent-bound faces: one enumerator shared with clear. for (const path of await listSeamOwnedUniqueErrorFacePaths(runDirectory)) { consider(await readTerminalArtifactAtPath(path, "error.json")); } if (present.length > 0) { present.sort( (a, b) => failureClassRank(b.file) - failureClassRank(a.file), ); return present[0]!; } if (unreadable.length > 0) { return unreadable[0]!; } return { status: "absent" }; } /** Test/helper: basename face of a unique fallback path, if any. */ export function isUniqueErrorFallbackName(name: string): boolean { return UNIQUE_ERROR_FALLBACK_NAME.test(basename(name)); }