/** * Provenance for stop/restart requests (#378): who asked, from which project, * for which daemon, naming every affected project and its live-run count. * * Shared by the `stop` and `restart` verbs so a second convention cannot * drift beside the first. Explicit facts only — caller pid/uid and session * role, scope, control path, timestamp, reason — and specifically never raw * argv, environment values or credentials: the incident this exists for was * a stop nobody could attribute, and a "just serialise the command line" * implementation would leak tokens into the audit trail. */ import { loadConfig } from "./config.ts"; import { openStore, dbPath } from "./store.ts"; import { SESSION_ROLE_ENV, type DaemonStopDraft } from "./types.ts"; export interface StopProvenanceSpec { /** The operator-visible control path: "cli stop", "cli restart", … */ controlPath: string; /** Non-secret reason; a generated default is fine, never credentials. */ reason: string; /** "global" when the request covered every project ("--all"); otherwise "project". */ scope: "global" | "project"; /** The originating project, when project-scoped. */ project?: string; } /** * Build the provenance draft for one stop/restart request. * * `affected` is every configured project with its live-run count at request * time, not just the targeted one: the shared daemon serves all of them, and * a project-scoped stop still stops the siblings — the record names them * instead of silently interrupting them. * * Pure: nothing is written. The command decides when to persist (at request * entry for drain-style restarts whose signal may never arrive, and at the * lifecycle chokepoint immediately before signalling for every mediated stop). */ /** * One configured project's live workload at a stop/restart refusal window. */ export interface LiveWorkload { project: string; /** Live (claimed/running) run count for this project. */ live: number; /** The issues currently live, so a refusal can name exactly what would be orphaned. */ issues: number[]; } /** * Every configured project's live workload. The refusal half of #545: the * shared daemon serves all projects, so a stop/restart must account for every * live run — not just the one the request happens to name — and it has to name * the issues, not just a count, so an operator can see what `--force` would * orphan. Read-only over the store; never writes. */ export function liveWorkload( store: { liveRuns(project: string): readonly { issue: number }[] }, ): LiveWorkload[] { const cfg = loadConfig(); return cfg.projects.map((project) => { const runs = store.liveRuns(project.name); return { project: project.name, live: runs.length, issues: runs.map((r) => r.issue) }; }); } /** * The refusal gate for a stop/restart of the shared daemon (#545). * * Refuses while ANY configured project — the named one and its siblings alike * — has a live run, since the process is global and stopping it orphans every * project's workers. Throws with the live project(s) and issue numbers so the * operator sees the whole blast radius, never a project-local count. * * `override` (an explicit `--force`) is the escape hatch that keeps a wedged * daemon stoppable: the caller skips this gate on `--force` and records the * override in the stop provenance rather than silencing it. */ export function refuseBusyDaemon( workload: LiveWorkload[], o: { override: boolean; verb: "stop" | "restart" }, ): void { const busy = workload.filter((w) => w.live > 0); if (busy.length === 0 || o.override) return; const live = busy.map((w) => `project ${w.project}: #${w.issues.join(", #")}`).join("; "); throw new Error( `refusing to ${o.verb} the shared daemon while work is live — ${live}. ` + `Re-run with --force to ${o.verb} anyway (the override is recorded in the daemon-stop provenance).`, ); } export function buildStopProvenance(spec: StopProvenanceSpec): DaemonStopDraft { const cfg = loadConfig(); const store = openStore(dbPath()); try { const affected = cfg.projects.map((project) => ({ project: project.name, live: store.liveRuns(project.name).length, })); return { controlPath: spec.controlPath, callerPid: process.pid, ...(typeof process.getuid === "function" ? { callerUid: process.getuid() } : {}), // The daemon stamps SESSION_ROLE_ENV on every session it spawns, so a // stop that a worker session's tools trigger names itself as a worker; // everything else — the operator's shell, a systemd unit, a script — is // the orchestrator surface (same convention as the reporting CLI). role: process.env[SESSION_ROLE_ENV] === "worker" ? "worker" : "orchestrator", scope: spec.scope, ...(spec.project === undefined ? {} : { project: spec.project }), affected, reason: spec.reason, unattributed: false, }; } finally { store.close(); } }