/** * `drain` — start, inspect, or cancel the project's self-expiring admission fence (#484). * * The bounded drain state machine landed in #776 (`createDrain`/`readDrain`/ * `cancelDrain` on daemon.ts, re-exported from fleet.ts); this verb is the * operator surface over it. A drain is the release-window sibling of `hold`: * the same admission boundary — settlement above it, nothing claimed below — * but recorded with an absolute deadline, so admission resumes on its own even * if the orchestrator dies. The only state it touches is the project's drain * record: never the pause sentinel, the arm marker, or any queue label. */ import type { CommandContext } from "./context.ts"; import { findProject, loadConfig } from "../config.ts"; import { statusSnapshot } from "../daemon.ts"; import { cancelDrain, createDrain, readDrain } from "../fleet.ts"; import { dim, ok } from "../ui/style.ts"; const DRAIN_USAGE = `omp-conductor drain — start, inspect, or cancel the project's admission drain. usage: omp-conductor drain start --until ISO|DURATION [--reason TEXT] [--project NAME] omp-conductor drain status [--project NAME] omp-conductor drain cancel [--project NAME] A drain is a durable, self-expiring admission fence: new claims pause while existing runs settle, an emptied fleet OPENS the release window rather than ending the drain (#1078), and admission resumes automatically at the absolute deadline — even if the orchestrator crashes. start replaces any prior drain of the project; --until takes an ISO instant or a relative duration (90s, 45m, 2h, 1d) that must be bounded and in the future. status reports the active drain's creation time, absolute expiry, reason, remaining active runs, and whether the window is still draining or already drained and holding. cancel removes the project's drain and is idempotent. A drain never touches the pause sentinel, the arm marker, or any queue label — it is the file record that expires on its own.`; /** The flags each drain subcommand accepts, after the subcommand itself. */ const DRAIN_FLAGS: Readonly> = { start: ["--project", "--until", "--reason"], status: ["--project"], cancel: ["--project"], }; /** Relative durations accepted by `drain start --until`. */ const DURATION_RE = /^(\d+)([smhd])$/; const DURATION_UNIT_MS: Readonly> = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000, }; /** * `--until` value → epoch-ms deadline, or undefined when the input is not a * bounded expiry. A duration is relative to now; an ISO instant is absolute. * Both forms must land on a finite safe-integer timestamp — the same bound * `createDrain` persists as an absolute ISO instant, so a value beyond the * Date range (e.g. 99999999999999999999d) is refused here instead of blowing * up inside the record write. The absolute form must look like a calendar * date: a bare number such as `3000` parses as a year in some engines, and an * operator typing that almost certainly meant a duration. */ function parseExpiry(raw: string, now: number): number | undefined { const duration = DURATION_RE.exec(raw); if (duration !== null) { const expiresAt = now + Number.parseInt(duration[1]!, 10) * DURATION_UNIT_MS[duration[2]!]!; return Number.isSafeInteger(expiresAt) ? expiresAt : undefined; } if (!/^\d{4}-\d{2}-\d{2}/.test(raw)) return undefined; const absolute = Date.parse(raw); return Number.isSafeInteger(absolute) ? absolute : undefined; } /** * Rejects trailing tokens a drain subcommand does not declare, so a typo'd * flag cannot be silently ignored — the same exit-2 scan `intake` and `stats` * run. Both `--flag VALUE` and `--flag=VALUE` are accepted, matching the * shared `flag()` parser. */ function assertKnownArgs(ctx: CommandContext, sub: string): void { const allowed = DRAIN_FLAGS[sub] ?? []; for (let i = 2; i < ctx.argv.length; i++) { const token = ctx.argv[i]; if (token === undefined) continue; const eq = token.startsWith("--") ? token.indexOf("=") : -1; const name = eq < 0 ? token : token.slice(0, eq); if (allowed.includes(name)) { if (eq < 0) i += 1; // consume the flag's value token continue; } process.stderr.write(`omp-conductor: drain ${sub}: unexpected argument "${token}"\n`); process.exit(2); } } export async function drainCommand(ctx: CommandContext): Promise { const sub = ctx.argv[1]; if (sub === "--help" || sub === "-h") { process.stdout.write(DRAIN_USAGE); return; } if (sub !== "start" && sub !== "status" && sub !== "cancel") { process.stderr.write("omp-conductor: drain needs start, status, or cancel\n"); process.exit(2); } assertKnownArgs(ctx, sub); const project = findProject(loadConfig(), ctx.projectFlag); if (sub === "start") { const rawUntil = ctx.flag("until"); if (rawUntil === undefined || rawUntil.length === 0) { process.stderr.write( "omp-conductor: drain start needs --until with an ISO instant or a duration (90s, 45m, 2h, 1d)\n", ); process.exit(2); } const now = Date.now(); const expiresAt = parseExpiry(rawUntil, now); if (expiresAt === undefined) { process.stderr.write( `omp-conductor: drain start: "${rawUntil}" is not a bounded expiry — ` + "use an ISO instant (e.g. 2026-08-20T10:00:00Z) or a duration (90s, 45m, 2h, 1d)\n", ); process.exit(2); } if (expiresAt <= now) { process.stderr.write(`omp-conductor: drain start: --until "${rawUntil}" must be in the future\n`); process.exit(2); } const rawReason = ctx.flag("reason")?.trim().replace(/\s+/g, " "); if (rawReason !== undefined && (rawReason === "" || rawReason.length > 500)) { process.stderr.write("omp-conductor: drain start --reason needs 1-500 characters\n"); process.exit(2); } createDrain(project.name, { expiresAt, ...(rawReason === undefined ? {} : { reason: rawReason }), }); process.stdout.write( ok( `drain started for ${project.name} — claiming paused until ${new Date(expiresAt).toISOString()}` + (rawReason === undefined ? "" : ` (${rawReason})`), ) + "\n", ); process.stdout.write( dim( "the drain is a durable record: it survives crashes, holds the window open once the fleet empties, and resumes admission at the deadline on its own", ) + "\n", ); return; } if (sub === "status") { const drain = statusSnapshot(project.name).drain; if (drain === undefined) { process.stdout.write(`drain: ${project.name} inactive — admission is open\n`); return; } const lines = [ `drain: ${project.name} active`, ` since ${new Date(drain.since).toISOString()}`, ` expires at ${new Date(drain.expiresAt).toISOString()}`, ...(drain.reason === undefined ? [] : [` reason ${drain.reason}`]), ` remaining ${drain.remainingRuns} active run${drain.remainingRuns === 1 ? "" : "s"}`, ...(drain.remainingRuns === 0 ? [ ` state drained and holding until ${new Date(drain.expiresAt).toISOString()} — admission resumes at the deadline`, ] : [" state draining — the window holds while runs settle"]), ]; process.stdout.write(`${lines.join("\n")}\n`); return; } const had = readDrain(project.name); cancelDrain(project.name); process.stdout.write( had.kind === "active" ? ok(`drain cancelled for ${project.name} — admission resumes on the next dispatch pass`) + "\n" : `drain: ${project.name} had no active drain — nothing to cancel\n`, ); }