Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit a1e79c8287ed9d4a5c3116060a1e1318f6fef65b
parent b8e9d75ef86fa6eec76ba731b31c63f991a5311a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 28 Aug 2026 12:42:34 -0400

common: a pause gate has one definition

Four lanes can be held, their flags live in four settings fields, and
three of those fields were read with three different polarities in nine
places. `backfill.enabled` is inverted — held means false — and every
reader that forgot it reported a held lane as idle.

lib/pauseGates.ts is now the only place that polarity is known:
isGateHeld / withGateHeld for the four lanes, and pauseLaneFor to answer
"which gate holds this operation" for a per-operation page. That last one
asks `runner` BEFORE the queue key, because transcode shares
TRANSCRIPTION_QUEUE and has no runner — a queue-key map alone would hand
it the transcription pause. It walks operationCatalog(), all seven ids,
not the four registry entries.

The header states the transcription asymmetry, which is the thing to get
right here: transcriptionsPaused is "will the pool be paused after a
restart", not "is it paused now". The live answer is the worker pool's,
and no UI surface may read the flag for it.

The three dispatch holds now read it — the download runner, the backfill
batch's limit(), and the digest gate. Behaviour is identical; the digest
gate keeps working on the {digest}-only object laneGuards.test.ts casts,
because isGateHeld touches only the named lane's field.

Nothing under transcripts/ was read or written for this commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Diffstat:
Mcommon/controller/autoRunner.ts | 4+++-
Mcommon/controller/backfillBatch.ts | 5++++-
Mcommon/controller/laneGuards.ts | 6++++--
Acommon/lib/pauseGates.test.ts | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/pauseGates.ts | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 246 insertions(+), 4 deletions(-)

diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts @@ -54,6 +54,7 @@ import { isAutoSubsOnly } from "../lib/subtitleProvenance"; import { readVideoFiles } from "../lib/videoStatus"; import { type DownloadOutcomeStatus } from "../lib/downloadOutcome"; import { downloadQueueKey } from "../lib/queueKeys"; +import { isGateHeld } from "../lib/pauseGates"; import { listChannelConfigs, readChannelConfig, @@ -637,7 +638,8 @@ async function runLoop( // Global downloads pause: like the `enabled` flag, this is re-read each // iteration. Rather than stop the runner, idle it (return null) so it // resumes dispatching on the next tick once unpaused — no restart needed. - if (kind === "download" && settings.downloadsPaused) { + // The gate itself is defined once, in lib/pauseGates.ts. + if (kind === "download" && isGateHeld(settings, "download")) { live.idleReason = "downloads-paused"; return null; } diff --git a/common/controller/backfillBatch.ts b/common/controller/backfillBatch.ts @@ -34,6 +34,7 @@ import path from "node:path"; import { readFile, readdir } from "node:fs/promises"; import type { Paths } from "../lib/paths"; import { getSettings } from "../lib/settings"; +import { isGateHeld } from "../lib/pauseGates"; import { runPool } from "../jobs/concurrentRunner"; import type { TaskTracker } from "../jobs/taskHooks"; import type { JobProgress } from "../jobs/registry"; @@ -733,7 +734,9 @@ export async function runBackfillBatch( // from next() would END the batch, which is not the same thing. const liveSettings = getSettings(); const live = liveSettings.backfill; - if (!live.enabled) { + // The lane's gate is `enabled`, INVERTED — asked through isGateHeld so + // this file does not carry a second opinion about the polarity. + if (isGateHeld(liveSettings, "backfill")) { if (!yielding) { yielding = true; log("Backfill lane disabled in settings — holding."); diff --git a/common/controller/laneGuards.ts b/common/controller/laneGuards.ts @@ -5,6 +5,7 @@ import { type Lane, } from "../lib/operations"; import type { DigestLane } from "../lib/digest"; +import { isGateHeld } from "../lib/pauseGates"; import { transcriptionActivity } from "./digestYield"; // THE LANE'S RULES, declared once, in the two shapes a dispatcher can act on. @@ -125,8 +126,9 @@ export function digestGate(input: DigestGateInput): LaneGate { const digest = input.settings.digest; // Re-read at DISPATCH time so a pause takes effect within one poll and - // survives a restart with no boot hook — the downloadsPaused pattern. - if (digest.digestsPaused) { + // survives a restart with no boot hook — the downloadsPaused pattern. The + // gate itself is defined once, in lib/pauseGates.ts. + if (isGateHeld(input.settings, "digest")) { return { hold: true, reason: "paused", diff --git a/common/lib/pauseGates.test.ts b/common/lib/pauseGates.test.ts @@ -0,0 +1,110 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + defaultDigest, + defaultSiteSettings, + type SiteSettings, +} from "./settings"; +import { operationCatalog } from "./operations"; +import { + isGateHeld, + pauseLaneFor, + withGateHeld, + type PauseLane, +} from "./pauseGates"; + +// Run with: node_modules/.bin/tsx --test common/lib/pauseGates.test.ts +// +// What these pin is the thing the file exists to stop: a second opinion about +// polarity. `backfill.enabled` is inverted and three readers used to spell that +// inversion for themselves, so the round trip and the explicit backfill case +// below are the contract, not decoration. + +const LANES: PauseLane[] = [ + "transcription", + "download", + "digest", + "backfill", +]; + +test("held round-trips through withGateHeld on every lane", () => { + for (const lane of LANES) { + const base = defaultSiteSettings(); + const held = withGateHeld(base, lane, true); + assert.equal(isGateHeld(held, lane), true, `${lane} should read held`); + const released = withGateHeld(held, lane, false); + assert.equal(isGateHeld(released, lane), false, `${lane} should read free`); + // No lane's write reaches another lane's gate. + for (const other of LANES) { + if (other === lane) continue; + assert.equal( + isGateHeld(held, other), + isGateHeld(base, other), + `holding ${lane} moved ${other}`, + ); + } + } +}); + +test("backfill's polarity is pinned: enabled false IS held", () => { + const settings = defaultSiteSettings(); + assert.equal(settings.backfill.enabled, false); + assert.equal(isGateHeld(settings, "backfill"), true); + const running = withGateHeld(settings, "backfill", false); + assert.equal(running.backfill.enabled, true); + assert.equal(isGateHeld(running, "backfill"), false); +}); + +test("holding the backfill lane leaves the sweep's scope alone", () => { + // The bug this forbids: a rebuilt literal instead of a spread would disarm a + // multi-week sweep, or drop its scope, on a pause click. + const base: SiteSettings = { + ...defaultSiteSettings(), + backfill: { + ...defaultSiteSettings().backfill, + enabled: true, + sweepEnabled: true, + sweepKinds: ["diarization"], + sweepChannels: ["a-channel"], + allowRedownload: true, + }, + }; + const held = withGateHeld(base, "backfill", true); + assert.equal(held.backfill.enabled, false); + assert.equal(held.backfill.sweepEnabled, true); + assert.deepEqual(held.backfill.sweepKinds, ["diarization"]); + assert.deepEqual(held.backfill.sweepChannels, ["a-channel"]); + assert.equal(held.backfill.allowRedownload, true); +}); + +test("isGateHeld touches only its own lane's field", () => { + // laneGuards.test.ts casts a {digest}-only object to SiteSettings, so a + // reader that reached for settings.backfill on the way past would throw. + const partial = { digest: { ...defaultDigest(), digestsPaused: true } } as SiteSettings; + assert.equal(isGateHeld(partial, "digest"), true); +}); + +test("pauseLaneFor answers for every catalog id, and transcode is null", () => { + const expected: Record<string, PauseLane | null> = { + download: "download", + // No runner, and it shares TRANSCRIPTION_QUEUE — which is exactly why the + // runner is asked before the queue key. A queue-key map alone would hand + // transcode the transcription pause. + transcode: null, + transcription: "transcription", + diarization: "backfill", + "attribution-diarized": "backfill", + "attribution-text": "backfill", + digest: "digest", + }; + const ids = operationCatalog().map((o) => o.id); + assert.equal(ids.length, 7); + for (const id of ids) { + assert.ok(id in expected, `catalog gained ${id} with no expected lane`); + assert.equal(pauseLaneFor(id), expected[id], `pauseLaneFor(${id})`); + } +}); + +test("an id the catalog does not know has no lane", () => { + assert.equal(pauseLaneFor("not-an-operation"), null); +}); diff --git a/common/lib/pauseGates.ts b/common/lib/pauseGates.ts @@ -0,0 +1,125 @@ +import type { SiteSettings } from "./settings"; +import type { AutoQueueKind } from "../jobs/autoQueueState"; +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 flags live in four settings fields, and until +// this file three of those fields were read with three different polarities in +// nine places. `backfill.enabled` in particular is INVERTED — held means false — +// and every reader that forgot it reported a held lane as idle. This is the only +// place that polarity is known. +// +// 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. +// `settings.transcriptionsPaused` 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 flag at boot and which the pause action flips first. So: +// +// * the flag 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 FLAG for "is it held". Every one of them +// (the dashboard deck, /workers, /jobs/active, the widget, the queue view, +// /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 flag IS the gate, read +// at dispatch, which is why they need no boot hook. + +export type PauseLane = "transcription" | "download" | "digest" | "backfill"; + +// The union of the two id spaces that already exist: AutoQueueKind (the runners) +// and the editor's SweepLaneId (the sweeps). Asserted here for the first; +// operations/lanes.ts asserts the second, where SweepLaneId lives. +type _RunnerLanesArePauseLanes = AutoQueueKind extends PauseLane ? true : never; + +// 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. transcode shares +// TRANSCRIPTION_QUEUE and has no runner at all, so a queue-key map alone would +// hand it the transcription pause — a live Pause button over a lane that would +// never dispatch it. Its own answer is null. +// +// Walks operationCatalog() — all seven ids, external ones included — not +// OPERATION_BY_ID, which knows only the four registry entries. +export function pauseLaneFor(operationId: string): PauseLane | 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? +// +// THE ONLY PLACE POLARITY IS KNOWN: backfill's field is `enabled`, so held is +// !enabled. Touches only the named lane's field — laneGuards.test.ts casts a +// `{ digest }`-only object to SiteSettings, and a function that reached for +// `settings.backfill` on the way past would throw on it. +export function isGateHeld(settings: SiteSettings, lane: PauseLane): boolean { + switch (lane) { + case "transcription": + // The PERSISTED intent, not the live pool. See the header. + return settings.transcriptionsPaused === true; + case "download": + return settings.downloadsPaused === true; + case "digest": + return settings.digest.digestsPaused === true; + case "backfill": + // INVERTED, and this line is why the file exists. + return settings.backfill.enabled === false; + } +} + +// Set a lane's gate, returning a NEW settings object. Pure — no I/O; the caller +// writes it. +// +// The only writer AMONG THE PAUSE CONTROLS. The Speaker lane's "Run the backfill +// lane" checkbox (operations/settingsActions.ts) deliberately writes +// `backfill.enabled` too: one field, two places to set it, and they cannot +// drift. +// +// SPREAD-AND-OVERRIDE, never a rebuilt literal: `backfill` also carries +// sweepEnabled, sweepKinds, sweepChannels, weight and allowRedownload, and a +// literal here would disarm a multi-week sweep on a pause click. +export function withGateHeld( + settings: SiteSettings, + lane: PauseLane, + held: boolean, +): SiteSettings { + switch (lane) { + case "transcription": + return { ...settings, transcriptionsPaused: held }; + case "download": + return { ...settings, downloadsPaused: held }; + case "digest": + return { + ...settings, + digest: { ...settings.digest, digestsPaused: held }, + }; + case "backfill": + return { + ...settings, + backfill: { ...settings.backfill, enabled: !held }, + }; + } +}