commit fc3ab071d74fbf9629e002d487b1e278f94766d8
parent 6d297261058db924487915d87acf11330b6c756c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 28 Aug 2026 12:52:29 -0400
lanes: one pause control, drawn wherever a lane is
components/lanes/pauseControl.tsx is now the only place a pause button is
described: `pauseLaneControl` for a LaneCard's controls array,
`PauseLaneButton` for a surface that is not a card. Every label, glyph,
title and aria-label in it is byte-identical to what the dashboard band
already shipped, because those names are the contract six e2e specs
select by.
It imports PauseLane as a TYPE only — a value import from pauseGates.ts
would drag operations.ts, settings.ts and `fs` into the client graph.
On it now: LaneDeck's four inline descriptors, /workers, /jobs/active,
and both sweep panels. PauseTranscriptionsButton is deleted; it was a
wrapper around exactly this.
Three things change on the operation pages' hold button, and the file
says why. Its aria-label is the canonical "pause backfill" / "pause
digests" rather than a composed "Pause Backfill" — Playwright's name
match is a case-insensitive substring, so every existing lookup still
finds it. Its emphasis is the dashboard's: warning-filled when the lane
is HELD, where it used to tint itself while the lane was running fine. It
no longer shares a pending flag with the sweep button, so arming a sweep
does not disable the hold mid-flight.
And a resume is always reachable. `disabled` now applies to the pause
side only — the sweep panel used to disable both, which on an unavailable
lane meant a held lane could not be released from that page at all.
backfill.spec gains "an operation page holds the same lane the dashboard
does": hold from /operations/diarization, assert the field on disk and
that the sweep flag did not move, then release it from the dashboard.
What that pins is the thing two components could silently disagree
about — which lane each surface is holding.
Nothing under transcripts/ was read or written for this commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
10 files changed, 239 insertions(+), 177 deletions(-)
diff --git a/common/controller/backfillBatch.ts b/common/controller/backfillBatch.ts
@@ -734,8 +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;
- // The lane's gate is `enabled`, INVERTED — asked through isGateHeld so
- // this file does not carry a second opinion about the polarity.
+ // The lane's gate is `enabled`, whose polarity is the opposite of every
+ // other gate's — asked through isGateHeld so this file does not carry a
+ // second opinion about it.
if (isGateHeld(liveSettings, "backfill")) {
if (!yielding) {
yielding = true;
diff --git a/editor/app/api/widget/sync/route.ts b/editor/app/api/widget/sync/route.ts
@@ -96,8 +96,8 @@ export type WidgetSyncPayload = {
needsMedia: number; // missing-input: needs an opt-in re-download first
videos: number; // videos in the corpus (the denominator)
// held — computed by isGateHeld; nothing downstream inverts anything. The
- // field on disk is `backfill.enabled` and it is INVERTED; that inversion
- // lives in pauseGates.ts and reaches the wire already resolved. It was
+ // field on disk is `backfill.enabled`, whose polarity is the opposite one;
+ // that lives in pauseGates.ts and reaches the wire already resolved. It was
// `enabled` here until slice 7, and a pinned widget tab that predates the
// rename reads `undefined ?? false` — not held — until it is reloaded.
held: boolean;
diff --git a/editor/app/components/lanes/LaneDeck.tsx b/editor/app/components/lanes/LaneDeck.tsx
@@ -9,7 +9,7 @@ import { formatBytes } from "yt-dlp-transcript-common/lib/format";
import { useSectionFrame } from "../../widget/components/WidgetSection";
import { LaneCard, type LaneControl } from "./LaneCard";
import { deriveLaneState, formatCount } from "./laneState";
-import { pauseLaneAction, resumeLaneAction } from "../../operations/actions";
+import { pauseLaneControl } from "./pauseControl";
import {
startBackfillSweepAction,
startDigestSweepAction,
@@ -114,28 +114,14 @@ export function LaneDeck({
)}
</>
}
+ // `held` is the LIVE pool, off the workers payload — never
+ // settings.transcriptionsPaused, which only says what a restart would do.
controls={[
- paused
- ? {
- key: "transcription",
- label: "Resume Transcriptions",
- glyph: "▶",
- ariaLabel: "Resume Transcriptions",
- variant: "active",
- action: () => resumeLaneAction("transcription"),
- onChange: onWorkersChange,
- }
- : {
- key: "transcription",
- label: "Pause Transcriptions",
- glyph: "❙❙",
- ariaLabel: "Pause Transcriptions",
- title:
- "Stop taking new work; in-flight parakeet jobs finish the current segment, cache it, and resume on the next run",
- variant: "idle",
- action: () => pauseLaneAction("transcription"),
- onChange: onWorkersChange,
- },
+ pauseLaneControl({
+ lane: "transcription",
+ held: paused,
+ onChange: onWorkersChange,
+ }),
]}
/>
);
@@ -205,27 +191,11 @@ export function LaneDeck({
</>
}
controls={[
- downloadsPaused
- ? {
- key: "downloads",
- label: "Resume Downloads",
- glyph: "▶",
- ariaLabel: "Resume Downloads",
- variant: "active",
- action: () => resumeLaneAction("download"),
- onChange: onWorkersChange,
- }
- : {
- key: "downloads",
- label: "Pause Downloads",
- glyph: "❙❙",
- ariaLabel: "Pause Downloads",
- title:
- "Stop new downloads: the auto-download runner idles and manual sync/download actions are blocked until resumed. Enumeration (store-playlist) still runs.",
- variant: "idle",
- action: () => pauseLaneAction("download"),
- onChange: onWorkersChange,
- },
+ pauseLaneControl({
+ lane: "download",
+ held: downloadsPaused,
+ onChange: onWorkersChange,
+ }),
]}
/>
);
@@ -273,29 +243,11 @@ export function LaneDeck({
// already holding, which is the state you have to be able to get out of.
if (digestSweeping || digestPaused) {
digestControls.push(
- digestPaused
- ? {
- key: "digest-gate",
- label: "Resume the lane",
- glyph: "▶",
- ariaLabel: "resume digests",
- title:
- "Resume digest generation. The running job picks up where it left off — it was holding, not stopped.",
- variant: "active",
- action: () => resumeLaneAction("digest"),
- onChange: onSynced,
- }
- : {
- key: "digest-gate",
- label: "Hold the lane",
- glyph: "❙❙",
- ariaLabel: "pause digests",
- title:
- "Hold digest generation without ending the sweep. The running job idles at zero and resumes instantly.",
- variant: "idle",
- action: () => pauseLaneAction("digest"),
- onChange: onSynced,
- },
+ pauseLaneControl({
+ lane: "digest",
+ held: digestPaused,
+ onChange: onSynced,
+ }),
);
}
@@ -425,29 +377,11 @@ export function LaneDeck({
action: () => startBackfillSweepAction(),
onChange: onSynced,
},
- !backfillHeld
- ? {
- key: "backfill-gate",
- label: "Hold the lane",
- glyph: "❙❙",
- ariaLabel: "pause backfill",
- title:
- "Hold the backfill lane without ending anything. A running job idles at zero and keeps its place; nothing is re-derived on resume. Survives a restart.",
- variant: "idle",
- action: () => pauseLaneAction("backfill"),
- onChange: onSynced,
- }
- : {
- key: "backfill-gate",
- label: "Resume the lane",
- glyph: "▶",
- ariaLabel: "resume backfill",
- title:
- "Resume the backfill lane. A held job picks up within a few seconds — it was holding, not stopped.",
- variant: "active",
- action: () => resumeLaneAction("backfill"),
- onChange: onSynced,
- },
+ pauseLaneControl({
+ lane: "backfill",
+ held: backfillHeld,
+ onChange: onSynced,
+ }),
]
: [];
diff --git a/editor/app/components/lanes/pauseControl.tsx b/editor/app/components/lanes/pauseControl.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import type { PauseLane } from "yt-dlp-transcript-common/lib/pauseGates";
+import { LaneActionButton } from "./LaneActionButton";
+import type { LaneControl } from "./LaneCard";
+import { pauseLaneAction, resumeLaneAction } from "../../operations/actions";
+
+// ONE PAUSE CONTROL, drawn wherever a lane is: the dashboard band, the monitor
+// widget's rail, /workers, /jobs/active, both sweep panels and the runner pages.
+// There were three copies of this button and four inline descriptors, and they
+// had already drifted — one disabled the RESUME side, one used the opposite
+// emphasis, one had no importers at all.
+//
+// A TYPE-ONLY IMPORT OF PauseLane, deliberately. This is a client module; a
+// value import from pauseGates.ts would drag operations.ts and settings.ts —
+// and with them `fs` — into the client graph.
+//
+// THE LABELS BELOW ARE THE ARIA CONTRACT and are byte-identical to what the
+// dashboard shipped before this file existed. backfill.spec, widget.spec,
+// dashboard.spec, auto-queue.spec, workers.spec and parakeet-partial.spec select
+// these buttons by accessible name, and Playwright's name match is a
+// case-insensitive SUBSTRING unless `exact` is passed (none of them pass it),
+// which is why a spec asking for "Pause Digest" finds "pause digests".
+//
+// THE RUNNER LANES SAY *PAUSE*, THE SWEEP LANES SAY *HOLD*, on purpose. The feed
+// SWEEPS and the gate HOLDS: on a lane that also has a sweep button, "Start
+// sweep" and "Pause backfill" are the same shape of phrase for two acts whose
+// costs to undo differ by a week of GPU time, and different verbs are the
+// cheapest thing that keeps them apart. A lane with no sweep has nothing to be
+// confused with, so it keeps the plain word.
+
+type PauseControlInput = {
+ lane: PauseLane;
+ held: boolean;
+ onChange?: () => void | Promise<void>;
+ // Applies to the PAUSE side only — see below.
+ disabled?: boolean;
+};
+
+type PauseCopy = {
+ label: string;
+ ariaLabel: string;
+ title?: string;
+};
+
+const PAUSE_COPY: Record<PauseLane, { held: PauseCopy; free: PauseCopy }> = {
+ transcription: {
+ free: {
+ label: "Pause Transcriptions",
+ ariaLabel: "Pause Transcriptions",
+ title:
+ "Stop taking new work; in-flight parakeet jobs finish the current segment, cache it, and resume on the next run",
+ },
+ held: { label: "Resume Transcriptions", ariaLabel: "Resume Transcriptions" },
+ },
+ download: {
+ free: {
+ label: "Pause Downloads",
+ ariaLabel: "Pause Downloads",
+ title:
+ "Stop new downloads: the auto-download runner idles and manual sync/download actions are blocked until resumed. Enumeration (store-playlist) still runs.",
+ },
+ held: { label: "Resume Downloads", ariaLabel: "Resume Downloads" },
+ },
+ digest: {
+ free: {
+ label: "Hold the lane",
+ ariaLabel: "pause digests",
+ title:
+ "Hold digest generation without ending the sweep. The running job idles at zero and resumes instantly.",
+ },
+ held: {
+ label: "Resume the lane",
+ ariaLabel: "resume digests",
+ title:
+ "Resume digest generation. The running job picks up where it left off — it was holding, not stopped.",
+ },
+ },
+ backfill: {
+ free: {
+ label: "Hold the lane",
+ ariaLabel: "pause backfill",
+ title:
+ "Hold the backfill lane without ending anything. A running job idles at zero and keeps its place; nothing is re-derived on resume. Survives a restart.",
+ },
+ held: {
+ label: "Resume the lane",
+ ariaLabel: "resume backfill",
+ title:
+ "Resume the backfill lane. A held job picks up within a few seconds — it was holding, not stopped.",
+ },
+ },
+};
+
+// The descriptor, for a LaneCard's `controls` array.
+export function pauseLaneControl({
+ lane,
+ held,
+ onChange,
+ disabled,
+}: PauseControlInput): LaneControl {
+ const copy = held ? PAUSE_COPY[lane].held : PAUSE_COPY[lane].free;
+ return {
+ key: `${lane}-gate`,
+ label: copy.label,
+ glyph: held ? "▶" : "❙❙",
+ ariaLabel: copy.ariaLabel,
+ title: copy.title,
+ // `active` is warning-filled, so a HELD lane is visible from across the
+ // room. The sweep panels used to do the opposite — tint the button while the
+ // lane was running fine — and now match the dashboard.
+ variant: held ? "active" : "idle",
+ // A RESUME IS ALWAYS REACHABLE. The caller's `disabled` is a statement about
+ // the pause ("there are no workers to pause", "no operation on this lane is
+ // switched on"); applying it to the resume side would make a paused pool
+ // with zero enabled workers impossible to release.
+ disabled: held ? undefined : disabled,
+ action: () => (held ? resumeLaneAction(lane) : pauseLaneAction(lane)),
+ onChange,
+ };
+}
+
+// The same control as a standalone button, for a surface that is not a LaneCard.
+// LaneCard supplies `dense` itself from useSectionFrame; here it is a prop.
+export function PauseLaneButton({
+ dense,
+ ...props
+}: PauseControlInput & { dense?: boolean }) {
+ const { key: _key, ...control } = pauseLaneControl(props);
+ return <LaneActionButton {...control} dense={dense} />;
+}
diff --git a/editor/app/jobs/active/page.tsx b/editor/app/jobs/active/page.tsx
@@ -5,7 +5,7 @@ import { buildActiveJobsPayload } from "./buildActiveJobs";
import { buildQueueView } from "../queue/buildQueueView";
import { ActiveJobsLive } from "../components/ActiveJobsLive";
import { DrainAllButton } from "../components/DrainAllButton";
-import { PauseTranscriptionsButton } from "../components/PauseTranscriptionsButton";
+import { PauseLaneButton } from "../../components/lanes/pauseControl";
export const dynamic = "force-dynamic";
@@ -34,7 +34,9 @@ export default async function ActiveJobsPage() {
</span>
)}
</Link>
- <PauseTranscriptionsButton paused={paused} />
+ {/* No onChange, so the button falls back to router.refresh() — this
+ page is force-dynamic, so that re-reads the live pool. */}
+ <PauseLaneButton lane="transcription" held={paused} />
<DrainAllButton />
</div>
</div>
diff --git a/editor/app/jobs/components/PauseTranscriptionsButton.tsx b/editor/app/jobs/components/PauseTranscriptionsButton.tsx
@@ -1,49 +0,0 @@
-"use client";
-
-import { LaneActionButton } from "../../components/lanes/LaneActionButton";
-import { pauseLaneAction, resumeLaneAction } from "../../operations/actions";
-
-// Global pause/resume for transcription workers. Pausing stops handing out new
-// work AND gracefully stops in-flight parakeet jobs after the current segment
-// (cached for resume); other engines run their in-flight file to completion.
-//
-// A THIN WRAPPER over the shared lane button. The Workers page (live `paused`
-// from polling, refetch on toggle) and the Active Jobs page (server-read
-// `paused`, router.refresh on toggle) call it in this prop shape and stay
-// untouched; the dashboard and the widget reach the same button through
-// LaneDeck. One implementation, so the hydration guard and the error handling
-// cannot go missing from one of them.
-export function PauseTranscriptionsButton({
- paused,
- disabled,
- onChange,
-}: {
- paused: boolean;
- disabled?: boolean;
- onChange?: () => void | Promise<void>;
-}) {
- return paused ? (
- <LaneActionButton
- label="Resume Transcriptions"
- glyph="▶"
- ariaLabel="Resume Transcriptions"
- variant="active"
- action={() => resumeLaneAction("transcription")}
- onChange={onChange}
- />
- ) : (
- <LaneActionButton
- label="Pause Transcriptions"
- glyph="❙❙"
- ariaLabel="Pause Transcriptions"
- title="Stop taking new work; in-flight parakeet jobs finish the current segment, cache it, and resume on the next run"
- variant="idle"
- // Only the pause takes the caller's disable (no workers to pause). A
- // resume must always be reachable, or a paused pool with zero enabled
- // workers could never be released.
- disabled={disabled}
- action={() => pauseLaneAction("transcription")}
- onChange={onChange}
- />
- );
-}
diff --git a/editor/app/operations/actions.ts b/editor/app/operations/actions.ts
@@ -170,11 +170,11 @@ async function setLaneHeld(
await writeSettings(withGateHeld(cur, lane, held));
}
} catch (e) {
- // REPORTED, not swallowed. persistTranscriptionsPaused used to console.warn
- // this and return ok. For transcription the pool has already flipped by the
- // time we get here, so the label follows the pool and the button shows the
- // error inline — which says exactly the true thing: the pause took effect,
- // it will not survive a restart.
+ // REPORTED, not swallowed. The workers page's old best-effort persist
+ // console.warn'd this and returned ok. For transcription the pool has
+ // already flipped by the time we get here, so the label follows the pool and
+ // the button shows the error inline — which says exactly the true thing: the
+ // pause took effect, it will not survive a restart.
return { ok: false, error: (e as Error).message };
}
// NOT "/" — the dashboard reads its pauses from its own polls
diff --git a/editor/app/operations/components/SweepLane.tsx b/editor/app/operations/components/SweepLane.tsx
@@ -15,7 +15,8 @@ import {
stopBackfillSweepAction,
stopDigestSweepAction,
} from "../../jobs/actions";
-import { pauseLaneAction, resumeLaneAction, saveLaneOrderAction } from "../actions";
+import { saveLaneOrderAction } from "../actions";
+import { PauseLaneButton } from "../../components/lanes/pauseControl";
import type { SweepLaneStatus } from "../lanes";
import type { OperationBand } from "../../components/pipelines/band";
import { OrderReach } from "./OrderReach";
@@ -177,30 +178,25 @@ export function SweepLane({
? `Sweep ${channelScope.size.toLocaleString()} channel${channelScope.size === 1 ? "" : "s"}`
: "Sweep every channel"}
</Button>
- {/* THE GATE. Backfill's is inverted on the wire (`enabled`); it is
- normalised in lanes.ts so this button means the same thing on both
- lanes. */}
- <Button
- type="button"
- size="sm"
- variant="outline"
- disabled={working || !lane.available}
- aria-label={`${lane.gateHeld ? "Resume" : "Pause"} ${lane.label}`}
- onClick={() =>
- run(() =>
- lane.gateHeld
- ? resumeLaneAction(lane.id)
- : pauseLaneAction(lane.id),
- )
- }
- className={
- lane.gateHeld
- ? ""
- : "border-warning/30 text-warning hover:bg-warning-soft hover:text-warning"
- }
- >
- {lane.gateHeld ? "Resume the lane" : "Hold the lane"}
- </Button>
+ {/* THE GATE — the same control the dashboard and the widget draw, so
+ there is one definition of what holding a lane looks like.
+
+ THREE THINGS CHANGED WHEN IT MOVED HERE, and each is on purpose:
+ the aria-label is now the canonical "pause backfill" / "pause
+ digests" rather than a composed "Pause Backfill" (Playwright's
+ name match is a case-insensitive substring, so every existing
+ lookup still finds it); the emphasis is the dashboard's — warning-
+ filled when the lane is HELD, where this button used to tint
+ itself while the lane was running fine; and the pause no longer
+ shares `working` with the sweep button, so starting a sweep does
+ not disable the hold mid-flight. `disabled` reaches the pause side
+ only: a resume must always be clickable. */}
+ <PauseLaneButton
+ lane={lane.id}
+ held={lane.gateHeld}
+ disabled={!lane.available}
+ onChange={onRefresh}
+ />
</span>
</div>
diff --git a/editor/app/workers/components/WorkersView.tsx b/editor/app/workers/components/WorkersView.tsx
@@ -11,7 +11,7 @@ import {
setDefaultWorkersAction,
stopWorkerPartialAction,
} from "../actions";
-import { PauseTranscriptionsButton } from "../../jobs/components/PauseTranscriptionsButton";
+import { PauseLaneButton } from "../../components/lanes/pauseControl";
export type WorkerTask = {
id: string;
@@ -122,8 +122,9 @@ export function WorkersView({ initial }: { initial: WorkersPayload }) {
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
- <PauseTranscriptionsButton
- paused={paused}
+ <PauseLaneButton
+ lane="transcription"
+ held={paused}
disabled={workers.length === 0}
onChange={refetch}
/>
diff --git a/editor/e2e/backfill.spec.ts b/editor/e2e/backfill.spec.ts
@@ -823,6 +823,52 @@ test("the dashboard pauses and resumes the backfill lane", async ({ page }) => {
await expect.poll(laneEnabled, { timeout: 30_000 }).toBe(true);
});
+// ONE GATE, TWO SURFACES. /operations/diarization and the dashboard draw the
+// SAME control over the SAME lane — three speaker operations share one backfill
+// queue and therefore one pause, so an operation page is not a per-operation
+// switch. Before slice 7 these were two components with different aria-labels
+// and opposite emphasis, and only one of them was tested.
+//
+// The assertion is deliberately the FIELD ON DISK plus the other surface's
+// label, not two clicks on one page: what would break silently is the two
+// surfaces disagreeing about which lane they hold.
+test("an operation page holds the same lane the dashboard does", async ({
+ page,
+}) => {
+ test.setTimeout(SLOW);
+ await resetData("one-transcribe-channel-with-audio");
+ await writeSettings(backfillSettings());
+
+ const laneEnabled = async () =>
+ (
+ await readJson<{ backfill?: { enabled?: boolean } }>(
+ "test-settings.json",
+ ).catch(() => ({}) as { backfill?: { enabled?: boolean } })
+ ).backfill?.enabled ?? false;
+
+ await page.goto("/operations/diarization");
+ // Scoped to the runner-less lane section so this cannot accidentally match a
+ // control the rail draws elsewhere on the page.
+ const lane = page.locator('section[data-lane="backfill"]');
+ const hold = lane.getByRole("button", { name: "pause backfill" });
+ // The hydration wait: LaneActionButton is disabled until React attaches, and
+ // a click before that fires nothing at all.
+ await expect(hold).toBeEnabled({ timeout: 30_000 });
+ await hold.click();
+ await expect.poll(laneEnabled, { timeout: 30_000 }).toBe(false);
+ // A hold is not a stop: the sweep flag is untouched.
+ expect(await sweepFlag()).toBe(false);
+
+ // The dashboard shows the same lane held, and releases it.
+ await page.goto("/");
+ const resumeOnDashboard = page.getByRole("button", {
+ name: "resume backfill",
+ });
+ await expect(resumeOnDashboard).toBeEnabled({ timeout: 30_000 });
+ await resumeOnDashboard.click();
+ await expect.poll(laneEnabled, { timeout: 30_000 }).toBe(true);
+});
+
// (11) THE RE-DOWNLOAD ON A SUBTITLE CHANNEL: audio, not captions.
//
// A `handling: "youtube"` channel downloads with --skip-download --write-subs