/** * The dispatch pause sentinel: one file per project (plus the legacy bare one), * its provenance line, and the reads that judge it. * * A leaf module on purpose. `daemon.ts` owns dispatch and imports `doctor.ts`, * so `doctor.ts` cannot import `daemon.ts` back — yet a fence is exactly the * kind of state a health check has to read (#938). Both import this instead, * and `daemon.ts` re-exports every name so no existing call site moves. * * Nothing here decides policy: it reads and writes the file honestly, and an * unreadable or malformed sentinel is `undefined` so every caller keeps failing * closed rather than inferring "no pause". */ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { stateDir } from "./config.ts"; /** * The sentinel's provenance line, in one place. * * It was written twice — here and as a copy in the setup wizard's * `isValidPauseBytes`, whose comment claimed to be "the exact grammar * `pauseInstance` reads". Adding `owner=` (#938) proved the hazard rather than * describing it: the copy did not know the new field, so a setup fence read as * *malformed* to the transaction that had just written it, and the refusal * path then declined to remove its own hold. One export, no copies. */ const PROVENANCE_LINE = /^source=(\S+)(?: reason="([^"]*)")?(?: owner=(\d+))?$/; /** * Whether these bytes are a readable pause sentinel: a parseable ISO instant, * then a provenance line. Exported so no caller has to restate the grammar to * tell a hold it may restore from one it must never delete. */ export function pauseBytesValid(bytes: string): boolean { const [line1, line2] = bytes.split("\n"); if (!Number.isFinite(Date.parse(line1?.trim() ?? ""))) return false; if (line2 === undefined) return false; return PROVENANCE_LINE.test(line2.trim()); } /** * Pause is a file rather than process state on purpose: `omp-conductor hold` * runs in a different process from the daemon, and a flag * on disk needs no IPC and survives a restart. A daemon that crashed while * paused comes back paused. */ export function pausedPath(project?: string): string { return join(stateDir(), project === undefined ? "paused" : `paused-${project}`); } function activePausePaths(project?: string): string[] { const paths = project === undefined ? [pausedPath()] : [pausedPath(project), pausedPath()]; return paths.filter((path) => existsSync(path)); } export function isPaused(project?: string): boolean { return activePausePaths(project).length !== 0; } /** * The epoch-ms timestamp at which the current pause began. A project pause also * observes the legacy bare sentinel, which pauses every project. If any active * sentinel is unreadable or unparseable, the timestamp is unknown so callers * continue to fail closed. */ export function pausedAt(project?: string): number | undefined { const paths = activePausePaths(project); if (paths.length === 0) return undefined; const times: number[] = []; for (const path of paths) { try { const first = readFileSync(path, "utf8").split("\n")[0]?.trim(); if (first === undefined || first === "") return undefined; const time = Date.parse(first); if (Number.isNaN(time)) return undefined; times.push(time); } catch { return undefined; } } return Math.min(...times); } /** * Who paused the project and why. Per-project provenance wins when both its * sentinel and the legacy all-project sentinel are active. */ export function pauseProvenance( project?: string, ): { source: string; reason?: string; owner?: number } | undefined { const paths = project === undefined ? [pausedPath()] : [pausedPath(project), pausedPath()]; const path = paths.find((candidate) => existsSync(candidate)); if (path === undefined) return undefined; try { const second = readFileSync(path, "utf8").split("\n")[1]; if (second === undefined) return undefined; const match = PROVENANCE_LINE.exec(second.trim()); if (match === null) return undefined; const source = match[1]!; const reason = match[2]; const owner = match[3] === undefined ? undefined : Number(match[3]); return { source, ...(reason === undefined ? {} : { reason }), ...(owner === undefined ? {} : { owner }), }; } catch { return undefined; } } /** * One pause sentinel FILE read as an instance identity: who set it, why, and * the creation instant, all from that exact file. An unreadable or malformed * file is undefined — the caller may treat it as absence. */ export function pauseInstanceAt( path: string, ): { source: string; reason?: string; since: number; owner?: number } | undefined { try { const [line1, line2] = readFileSync(path, "utf8").split("\n"); const since = Date.parse(line1?.trim() ?? ""); if (!Number.isFinite(since)) return undefined; if (line2 === undefined) return undefined; const match = PROVENANCE_LINE.exec(line2.trim()); if (match === null) return undefined; const source = match[1]!; const reason = match[2]; const owner = match[3] === undefined ? undefined : Number(match[3]); return { source, since, ...(reason === undefined ? {} : { reason }), ...(owner === undefined ? {} : { owner }), }; } catch { return undefined; } } /** * One pause sentinel read as a single identity: who set it, why, and the * creation instant, all from the SAME file that was selected. Unlike pairing * {@link pauseProvenance} with {@link pausedAt} — which can describe * different files when a project pause coexists with the legacy global * sentinel, letting a stale global timestamp mask a recreated project pause — * this reads provenance and timestamp from one sentinel, so a caller can prove * "the pause I set still exists" instead of "some pause with the same labels * still exists" (#377). Per-project sentinel wins, like {@link pauseProvenance}. */ export function pauseInstance( project?: string, ): { source: string; reason?: string; since: number; owner?: number } | undefined { const paths = project === undefined ? [pausedPath()] : [pausedPath(project), pausedPath()]; const path = paths.find((candidate) => existsSync(candidate)); if (path === undefined) return undefined; return pauseInstanceAt(path); } /** * Compare-and-clear one pause sentinel (#780 review): remove `path` only while * it still holds exactly the `expected` instance — same source, same reason, * same creation instant — as read by {@link pauseInstance}. A hold or pause * that replaced or recreated the sentinel between the read and the clear is a * newer instance (writes always re-stamp `since`), so it is never destroyed: * returning false keeps the newer fence in force. Scoped strictly to one * caller-provided path, so auto-expiry can clear the per-project spend-cap * sentinel without ever touching the legacy global sentinel. */ export function clearPauseIfUnchanged( path: string, expected: { source: string; reason?: string; since: number }, ): boolean { const current = pauseInstanceAt(path); if (current === undefined) return false; if ( current.source !== expected.source || current.since !== expected.since || current.reason !== expected.reason ) { return false; } rmSync(path, { force: true }); return true; } /** * The pause sentinel's `source=` token, proven from a verb. The sentinel's * source line is read back as a single `\S+` token (see {@link pauseInstance} * and {@link pauseProvenance}), so a verb that contains a space (`setup host`) * is unrepresentable verbatim and must be encoded before it reaches disk — * otherwise the fence cannot prove its own pause and refuses forever (#552). * Spaces become `-`; the human-readable verb is preserved in the sentinel's * `reason=` instead. */ export function pauseSourceToken(verb: string): string { return verb.trim().replace(/\s+/g, "-"); } export function setPaused( v: boolean, why?: { source: string; reason?: string; owner?: number }, project?: string, ): void { const path = pausedPath(project); if (v) { mkdirSync(dirname(path), { recursive: true }); const line1 = `${new Date().toISOString()}\n`; if (why === undefined) { writeFileSync(path, line1); } else { const reason = why.reason === undefined ? "" : ` reason="${why.reason.replaceAll('"', "")}"`; // `owner=` is written ONLY by a fence whose lifetime is a process's // (#938): a `setup` apply holds dispatch across its own transaction, and // when that process is killed nothing runs to lift it. An operator // `hold` deliberately records none — it outlives every process by // design, and a pid there would invite exactly the wrong inference. const owner = why.owner === undefined ? "" : ` owner=${why.owner}`; writeFileSync(path, `${line1}source=${why.source}${reason}${owner}\n`); } } else { rmSync(path, { force: true }); if (project !== undefined) rmSync(pausedPath(), { force: true }); } }