Archilyzer · Source

archilyzer

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

commit 1e0c7914d32935bf67c69a67121b711f554d0ee5
parent fc3ab071d74fbf9629e002d487b1e278f94766d8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 28 Aug 2026 12:55:21 -0400

operations: a runner page has its pause beside Start, Drain and Stop

/operations/transcription and /operations/download had three controls,
all of them about the RUNNER. The lane's gate — which outlives any
runner, and which a stopped runner can still be holding — was only
reachable from the dashboard. It is the fourth button now, the same one
every other lane surface draws.

The status payload carries `held`, and the two kinds answer it from
different places on purpose: transcription's hold is the live worker
pool, download's is the settings flag its dispatch reads. The comment
says which and why.

railStates stops hard-coding `gateHeld: false`, so the operations rail
can show a runner lane Holding for the first time.

And the transcription page stops lying. pauseAll() disables every worker,
so the runner reported `no-workers` and the page said "no enabled worker
to run it" directly beside its own Resume Transcriptions button. There is
a `workers-paused` idle reason now, asked before the no-workers branch,
reading "transcriptions are paused" on the operation page and
"transcriptions paused globally" on Active jobs — mirroring
downloads-paused, which had this right already.

auto-queue.spec's per-operation-lane test gains the runner pause: hold
from /operations/download, assert settings.downloadsPaused on disk, then
release it so the fixture is left as it was found.

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

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

Diffstat:
Mcommon/controller/autoRunner.ts | 17++++++++++++++++-
Meditor/app/jobs/active/buildActiveJobs.ts | 2++
Meditor/app/operations/components/LaneHeader.tsx | 20++++++++++++++++++++
Meditor/app/operations/components/RunnerOperationView.tsx | 1+
Meditor/app/operations/components/dispatch.ts | 2++
Meditor/app/operations/components/railStates.ts | 2+-
Meditor/app/operations/status.ts | 15+++++++++++++++
Meditor/e2e/auto-queue.spec.ts | 24++++++++++++++++++++++++
8 files changed, 81 insertions(+), 2 deletions(-)

diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts @@ -130,6 +130,10 @@ export type AutoRunnerIdleReason = | "cooldown" // No enabled, non-degraded worker slot exists. Transcription only. | "no-workers" + // The worker pool is pause-all'd. Transcription only, and DISTINCT from + // no-workers: pauseAll disables every worker, so by slot count the two look + // identical, and only one of them is fixed by enabling a worker. + | "workers-paused" // There is genuinely nothing to do. | "no-pending" // Work exists but every path to it is at a maxWorkers ceiling (a node cap, or @@ -596,7 +600,18 @@ async function runLoop( // A zero limit means runPool never calls next(), so this is the only place // "every worker is off or degraded" can be observed — without it the page // would show a running runner with no explanation at all. - if (slots === 0 && live.inFlight.size === 0) live.idleReason = "no-workers"; + // + // THE PAUSE IS ASKED FIRST, and it has to be. pauseAll() DISABLES every + // worker (remembering each one's state for the resume), so a paused pool is + // indistinguishable from an empty one by slot count alone — and + // /operations/transcription said "no enabled worker to run it" next to its + // own Resume Transcriptions button. Same shape as the download lane's + // downloads-paused: an idle reason, not a stop. + if (slots === 0 && live.inFlight.size === 0) { + live.idleReason = getWorkerPool().isPaused() + ? "workers-paused" + : "no-workers"; + } return Math.min(slots, policy.maxWorkers ?? Number.POSITIVE_INFINITY); }; diff --git a/editor/app/jobs/active/buildActiveJobs.ts b/editor/app/jobs/active/buildActiveJobs.ts @@ -393,6 +393,8 @@ function autoIdleNote( return "every pending platform is in a rate-limit cooldown"; case "no-workers": return "no enabled worker"; + case "workers-paused": + return "transcriptions paused globally"; case "disk-gate": return "disk gate closed"; case "downloads-paused": diff --git a/editor/app/operations/components/LaneHeader.tsx b/editor/app/operations/components/LaneHeader.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { Button } from "yt-dlp-transcript-common/components/ui/button"; import type { AutoQueueKindStatus } from "../status"; +import { PauseLaneButton } from "../../components/lanes/pauseControl"; import { formatElapsed, idleReasonText } from "./dispatch"; // The lane's identity line: the <h2> every e2e lookup keys off, the state pill, @@ -21,12 +22,17 @@ export function LaneHeader({ now, busy, onControl, + onChange, }: { title: string; status: AutoQueueKindStatus; now: number | null; busy: boolean; onControl: (action: "start" | "stop" | "drain") => void; + // Re-poll after the lane's gate moves. The pause is not one of the three + // runner controls — it holds the LANE, which outlives any runner — so it does + // not go through onControl. + onChange: () => void | Promise<void>; }) { const running = status.runner.running; const inFlight = status.runner.inFlight.length; @@ -81,6 +87,20 @@ export function LaneHeader({ > Stop </Button> + {/* THE FOURTH CONTROL, and the one that was missing. Start, Drain and + Stop all act on the RUNNER; this holds the LANE, which is a + different thing with a different lifetime — a held lane stays held + across a runner restart, and a stopped runner can still be holding. + The same button the dashboard, the widget and both sweep panels + draw, so there is one idea of what holding looks like. + + A plain <button> plus a role="alert" span on error only: no nested + <section>, and role="status" stays reserved for "Saved." */} + <PauseLaneButton + lane={status.kind} + held={status.held} + onChange={onChange} + /> </span> </div> diff --git a/editor/app/operations/components/RunnerOperationView.tsx b/editor/app/operations/components/RunnerOperationView.tsx @@ -88,6 +88,7 @@ export function RunnerOperationView({ now={now} busy={busy} onControl={control} + onChange={onRefresh} /> {kind === "transcription" && ( diff --git a/editor/app/operations/components/dispatch.ts b/editor/app/operations/components/dispatch.ts @@ -103,6 +103,8 @@ export function idleReasonText( return "every pending platform is in a rate-limit cooldown"; case "no-workers": return "no enabled worker to run it"; + case "workers-paused": + return "transcriptions are paused"; case "disk-gate": return "disk gate closed — not enough free space"; case "downloads-paused": diff --git a/editor/app/operations/components/railStates.ts b/editor/app/operations/components/railStates.ts @@ -33,7 +33,7 @@ export function railStates( // it will never pick anything up, which "Idle" does not say. state: !running ? "unavailable" - : deriveLaneState({ gateHeld: false, activeCount: inFlight }), + : deriveLaneState({ gateHeld: status.held, activeCount: inFlight }), note: running ? idleReasonText(status.runner.idleReason, status.kind) : null, }; } diff --git a/editor/app/operations/status.ts b/editor/app/operations/status.ts @@ -13,6 +13,8 @@ import { readAutoQueueState, } from "yt-dlp-transcript-common/jobs/autoQueueState"; import type { AutoQueuePolicy } from "yt-dlp-transcript-common/jobs/autoQueuePolicy"; +import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; +import { isGateHeld } from "yt-dlp-transcript-common/lib/pauseGates"; import { buildAutoQueueLanes, type AutoQueueLanesPayload } from "./lanes"; // Read-only payload for the Auto-Queue panel: per-kind runner status (running?, @@ -50,6 +52,15 @@ export type AutoQueueKindStatus = { picks: AutoQueuePick[]; // Platforms paused by a rate-limit/network backoff (download kind only). cooldowns: PlatformCooldownView[]; + // This runner's lane gate — what the page's pause button draws, and what lets + // the operations rail show a runner HOLDING for the first time. + // + // THE TWO KINDS ANSWER IT FROM DIFFERENT PLACES, on purpose. Transcription's + // hold is LIVE, on the worker pool; settings.transcriptionsPaused is only what + // the boot hook re-applies after a restart, and the e2e harness rewrites + // settings wholesale between tests while the pool keeps its pausedSnapshot. + // Download has no live counterpart: its flag IS the gate, read at dispatch. + held: boolean; }; export type AutoQueueStatusPayload = { @@ -89,6 +100,10 @@ async function buildKind(kind: AutoQueueKind): Promise<AutoQueueKindStatus> { nextUp: pending.nextUp, picks: state[kind].picks, cooldowns, + held: + kind === "transcription" + ? getWorkerPool().isPaused() + : isGateHeld(getSettings(), "download"), }; } diff --git a/editor/e2e/auto-queue.spec.ts b/editor/e2e/auto-queue.spec.ts @@ -1172,6 +1172,30 @@ test("UI: each operation page carries its own lane, and the rail as context", as }), ).toBeVisible(); + // A RUNNER PAGE HAS ITS PAUSE TOO, beside Start/Drain/Stop. Those three act on + // the runner; this holds the LANE, which outlives it — and until slice 7 the + // only way to hold a runner lane was the dashboard. + // + // It writes the same settings field the dashboard's button does, so the field + // is what is asserted, not just the label. The pause is released again at the + // end so this test leaves the fixture as it found it. + const download = page.locator('section[data-lane="download"]'); + await awaitHydration(download); + const downloadsPaused = async () => + ( + await readJson<{ downloadsPaused?: boolean }>("test-settings.json").catch( + () => ({}) as { downloadsPaused?: boolean }, + ) + ).downloadsPaused ?? false; + const pause = download.getByRole("button", { name: "Pause Downloads" }); + await expect(pause).toBeVisible(); + await pause.click(); + await expect.poll(downloadsPaused, { timeout: 20_000 }).toBe(true); + const resume = download.getByRole("button", { name: "Resume Downloads" }); + await expect(resume).toBeVisible(); + await resume.click(); + await expect.poll(downloadsPaused, { timeout: 20_000 }).toBe(false); + // The digest lane is a page of its own, with both of its controls. await page.goto("/operations/digest"); const digest = page.locator('section[data-lane="digest"]');