import type { SiteSettings } from "./settings"; import type { AutoQueueKind, PipelineLane } from "./autoQueueTypes"; import { operationCatalog } from "./operations"; import { BACKFILL_QUEUE, DIGEST_LOCAL_QUEUE, DIGEST_REMOTE_QUEUE, } from "./queueKeys"; // A PAUSE GATE, DEFINED ONCE. // // Four lanes can be held. Their flag lived in four unrelated settings fields // read with three different polarities in nine places — `backfill.enabled` in // particular was INVERTED, held meant false, and every reader that forgot it // reported a held lane as idle. This file was the one place that polarity was // known; since slice 1.4 the gate is ONE KEY ON THE LANE, // `autoQueue[lane].held`, and since S0-pause the four fields are DELETED: they // are not in `SiteSettings`, no sanitizer keeps them, and `writeSettings` // drops them from the file. There is no second spelling of a pause left. // // A PAUSE IS A HOLD, NEVER A STOP. Every gate below is consulted at DISPATCH // time by a limit() that returns 0, which makes runPool idle-wait: the job stays // alive, keeps its place, and resumes within one poll with nothing re-derived. // Ending the job would be a different act with a different cost. // // THE TRANSCRIPTION ASYMMETRY, and it is the one thing to get right here. // `autoQueue.transcription.held` is not "is transcription paused now" — it is // "will the pool be paused after a restart". The live state is the worker pool's // own (`getWorkerPool().isPaused()`), which editor/instrumentation.ts re-applies // from this node at boot and which the pause action flips first. So: // // * the node value is read in exactly two places — the boot hook, and the // action's "did this change anything" check before it writes; // * NO UI SURFACE MAY READ THE STORED VALUE for "is it held". Every one of them // (the dashboard deck, /workers, /jobs, the widget, /api/pulse, // /api/worker/health) reads the pool. The e2e harness rewrites // test-settings.json wholesale between tests while the pool keeps its // pausedSnapshot, so a surface reading the flag would disagree with the // machine it is describing. // // The other three lanes have no live counterpart: their `held` IS the gate, read // at dispatch, which is why they need no boot hook. // A PAUSE LANE IS A LANE. This was a hand-written union of four names while // AutoQueueKind was a hand-written union of two; the four lanes in the model // closed that gap, so this is now an alias and the two id spaces cannot drift. // The name survives because thirteen call sites read as "which lane's gate", // and because a gate is what this file is about. // // WIDENED BY ONE in release 18: the publish lane is a PIPELINE lane // (autoQueueTypes.ts PIPELINE_LANES), not a LANES entry, and its gate is // `settings.publish.held` — the one gate that is not on an `autoQueue` policy. export type PauseLane = AutoQueueKind | PipelineLane; // WHICH LANE'S GATE HOLDS THIS OPERATION — the answer to "the operator is on // /operations/diarization and wants to hold it". // // A per-operation page is NOT a per-operation switch: three speaker operations // share the backfill queue and therefore share one gate, and this function is // how a page finds the gate it is really operating. // // `runner` IS ASKED FIRST, and that order is load-bearing. An external // operation may share a runner's queue and have no runner of its own (transcode // did, on TRANSCRIPTION_QUEUE, until 2026-08-30); a queue-key map alone would // hand it that runner's pause — a live Pause button over a lane that would // never dispatch it. Such an entry's own answer is null. // // Walks operationCatalog() — all seven ids, external ones included — not // OPERATION_BY_ID, which knows only the four registry entries. Sync is in that // walk and its answer is null: the scheduler's `enabled` is its own switch, not // a lane gate. export function pauseLaneFor(operationId: string): AutoQueueKind | null { const op = operationCatalog().find((o) => o.id === operationId); if (!op) return null; if (op.runner) return op.runner; const key = op.lane.queueKey; if (key === BACKFILL_QUEUE) return "backfill"; if (key === DIGEST_LOCAL_QUEUE || key === DIGEST_REMOTE_QUEUE) return "digest"; return null; } // Is this lane's gate shut? // // ONE KEY, ON THE LANE'S OWN POLICY: `autoQueue[lane].held`. A lane already // owns an `enabled`, a `maxWorkers`, an `order`, a `snoozeUntil` and a tree; // its pause was the one per-lane switch living somewhere else, in four fields // with three polarities. // // ONE FIELD ANSWERS, and that is now the whole rule. Slice 1.4 read an absent // key through the four retired pause fields and `getSettings` copied the answer // onto the lane; S0-pause deleted both, on the precondition that the live // settings.json already carried all four `held` keys (it did — the migration // had persisted them on its first write). A file that names no gate is not read // past in silence either: `sanitizePolicy` fills the key with `defaultHeldFor`, // which is the reading the retired fields gave such a file — free everywhere // except backfill, whose field was inverted and shipped held. So this is a // `=== true` on a key the sanitizer has already settled. // // `autoQueue` is read defensively: laneGuards.test.ts casts a partial object to // SiteSettings, and this must answer for it the way it always has. export function isGateHeld(settings: SiteSettings, lane: PauseLane): boolean { // The publish lane's gate is its own block's (release 18): there is no // `autoQueue.publish`. if (lane === "publish") return settings.publish?.held === true; return settings.autoQueue?.[lane]?.held === true; } // Set a lane's gate, returning a NEW settings object. Pure — no I/O; the caller // writes it. // // IT WRITES THE ONE KEY, and there is no longer a second field it could also // write: the four retired flags are deleted. The one other CONTROL over a // lane's gate — "Run the backfill lane", in operations/settingsActions.ts — // comes through here too, so the two controls cannot answer differently. // // SPREAD-AND-OVERRIDE, never a rebuilt literal: the policy also carries the // lane's TREE, its order and its snooze, and a literal here would drop an // operator's rules on a pause click. Same rule the block-shaped version had for // `backfill.concurrency`; the thing worth losing just got bigger. export function withGateHeld( settings: SiteSettings, lane: PauseLane, held: boolean, ): SiteSettings { if (lane === "publish") { return { ...settings, publish: { ...settings.publish, held } }; } return { ...settings, autoQueue: { ...settings.autoQueue, [lane]: { ...settings.autoQueue[lane], held }, }, }; }