Archilyzer · Source

archilyzer

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

commit 6d297261058db924487915d87acf11330b6c756c
parent a1e79c8287ed9d4a5c3116060a1e1318f6fef65b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 28 Aug 2026 12:47:39 -0400

editor: one action pair holds every lane, and the wire says held

Eight server actions in two files become two: pauseLaneAction(lane) and
resumeLaneAction(lane), in operations/actions.ts beside the snooze. They
are keyed BY LANE, not by operation — the gate is per lane, three speaker
operations share one, and a per-operation page is not a per-operation
switch.

Transcription keeps its asymmetry and now states it: the pool is flipped
first because the pool is the machine, then the flag is persisted for the
boot hook. A failed write is REPORTED rather than swallowed the way
persistTranscriptionsPaused did — the pause took effect, it will not
survive a restart, and that is worth a line under the button.

Gone: pause/resume Downloads, Digests, Backfill (jobs/actions.ts),
pauseAllWorkersAction / resumeAllWorkersAction and
persistTranscriptionsPaused (workers/actions.ts), and the dead
PauseDownloadsButton, which had no importers at all.

The readers lose their copies of the polarity. lanes.ts, buildActiveJobs,
buildWorkers, the two channel actions and instrumentation now ask
isGateHeld, and the "INVERTED" comments go with them.

The widget's own sync payload renames `digest.paused` and
`backfill.enabled` to `held`, computed by isGateHeld, so nothing
downstream inverts anything. A pinned widget tab that predates this reads
undefined — not held — until it is reloaded.

Two notes against the census this was planned from: MonitorWidget's
backfill strip DID read `backfill.enabled` (the non-dense branch, "lane
off" / "sweeping"), so it is rewired here too; and the revalidation list
is the three paths the plan names, not revalidateOperations(), because
"/" stays out of it for the reason workers/actions.ts recorded.

No aria-label, button name or settings field changed in this commit.

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

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

Diffstat:
Mcommon/lib/settings.ts | 2+-
Meditor/app/api/widget/sync/route.ts | 21++++++++++++++-------
Meditor/app/channels/[slug]/pipelineActions.ts | 3++-
Meditor/app/channels/groupActions.ts | 3++-
Meditor/app/components/CommandPalette.tsx | 9+++------
Meditor/app/components/lanes/LaneDeck.tsx | 47+++++++++++++++++++----------------------------
Meditor/app/components/lanes/laneState.ts | 9+++++----
Meditor/app/jobs/actions.ts | 121+------------------------------------------------------------------------------
Meditor/app/jobs/active/buildActiveJobs.ts | 7+++----
Deditor/app/jobs/components/PauseDownloadsButton.tsx | 44--------------------------------------------
Meditor/app/jobs/components/PauseTranscriptionsButton.tsx | 9+++------
Meditor/app/operations/actions.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/operations/components/SweepLane.tsx | 16++++------------
Meditor/app/operations/lanes.ts | 14++++++++++----
Meditor/app/widget/components/MonitorWidget.tsx | 4++--
Meditor/app/workers/actions.ts | 45++++-----------------------------------------
Meditor/app/workers/buildWorkers.ts | 3++-
Meditor/instrumentation.ts | 8+++++++-
18 files changed, 147 insertions(+), 283 deletions(-)

diff --git a/common/lib/settings.ts b/common/lib/settings.ts @@ -148,7 +148,7 @@ export type SiteSettings = { // Global, restart-surviving pause for transcription workers. When true, the // worker pool is pause-all'd at boot (editor/instrumentation.ts) so no new // transcriptions start until resumed. The Workers-page Pause button and the - // dashboard toggle both persist this. Default false. See pauseAllWorkersAction. + // dashboard toggle both persist this. Default false. See pauseLaneAction. transcriptionsPaused: boolean; // Global, restart-surviving pause for downloads. When true, the auto-download // runner skips dispatch (checked each loop iteration, like `enabled`) and diff --git a/editor/app/api/widget/sync/route.ts b/editor/app/api/widget/sync/route.ts @@ -4,6 +4,7 @@ import { digestCountOf } from "yt-dlp-transcript-common/controller/channels"; import { digestWorkOf } from "yt-dlp-transcript-common/controller/channelSnapshot"; import { getChannelBriefs } from "../../../lib/requestCache"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { isGateHeld } from "yt-dlp-transcript-common/lib/pauseGates"; import { backfillLaneOperations, backfillLaneOperationEntriesOf, @@ -69,7 +70,8 @@ export type WidgetSyncPayload = { // for transcription; run Normalize transcripts). blocked: number; deferred: number; - paused: boolean; // settings.digest.digestsPaused + // held — computed by isGateHeld; nothing downstream inverts anything. + held: boolean; sweeping: boolean; // a corpus-wide sweep is armed }; // Corpus-wide backfill state, and free for the same reason as `digest`: every @@ -93,11 +95,16 @@ export type WidgetSyncPayload = { reachable: number; // missing + stale: what the lane can do now needsMedia: number; // missing-input: needs an opt-in re-download first videos: number; // videos in the corpus (the denominator) - enabled: boolean; // settings.backfill.enabled + // 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 + // `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; sweeping: boolean; // a corpus-wide backfill sweep is armed - // Whether any backfill FEATURE is on. Distinct from `enabled`, which is the - // lane: with no feature on there is nothing to report at all, and that must - // not look like "all caught up". + // Whether any backfill FEATURE is on. Distinct from the gate: with no + // feature on there is nothing to report at all, and that must not look like + // "all caught up". anyKind: boolean; // The lane's name in the operator's terms, derived on the server from the // GROUP its enabled kinds declare — "Speakers" today. The client must not @@ -286,14 +293,14 @@ export async function buildWidgetSyncPayload(): Promise<WidgetSyncPayload> { channels: channels.length, blocked: digestBlocked, deferred: digestDeferred, - paused: settings.digest.digestsPaused, + held: isGateHeld(settings, "digest"), sweeping: settings.digest.sweepEnabled, }, backfill: { reachable: backfillReachable, needsMedia: backfillNeedsMedia, videos, - enabled: settings.backfill.enabled, + held: isGateHeld(settings, "backfill"), sweeping: settings.backfill.sweepEnabled, anyKind: backfillLaneOperations(settings).length > 0, groupLabel: operationsGroupLabel( diff --git a/editor/app/channels/[slug]/pipelineActions.ts b/editor/app/channels/[slug]/pipelineActions.ts @@ -25,6 +25,7 @@ import { extractVideoId, runYtdlp } from "yt-dlp-transcript-common/ytdlp/runYtdl import { mergeRosterFile } from "yt-dlp-transcript-common/controller/rosterStore"; import { downloadOneManaged } from "yt-dlp-transcript-common/ytdlp/downloadOneManaged"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { isGateHeld } from "yt-dlp-transcript-common/lib/pauseGates"; import { resolveCookiePolicy } from "yt-dlp-transcript-common/lib/cookiePolicy"; import { diskGate } from "yt-dlp-transcript-common/lib/diskSpace"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; @@ -105,7 +106,7 @@ async function runPipelineAction( // Global downloads pause: every mode that writes media is blocked with a // friendly notice (info, not an error). store-playlist only enumerates the // video list — no media is written — so it stays allowed even while paused. - if (mode !== "store-playlist" && getSettings().downloadsPaused) { + if (mode !== "store-playlist" && isGateHeld(getSettings(), "download")) { return { ok: false, info: true, diff --git a/editor/app/channels/groupActions.ts b/editor/app/channels/groupActions.ts @@ -7,6 +7,7 @@ import { activeSlugsForKinds } from "yt-dlp-transcript-common/jobs/syncJobs"; import { isValidGroupId } from "yt-dlp-transcript-common/lib/channelGroups"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { isGateHeld } from "yt-dlp-transcript-common/lib/pauseGates"; import { getSite, isValidSiteId } from "yt-dlp-transcript-common/lib/site"; import { laneOffFor, @@ -113,7 +114,7 @@ async function runGroupStation( ], }; } - if (station === "download" && settings.downloadsPaused) { + if (station === "download" && isGateHeld(settings, "download")) { return { group, queued: [], diff --git a/editor/app/components/CommandPalette.tsx b/editor/app/components/CommandPalette.tsx @@ -29,10 +29,7 @@ import { } from "../channels/actions"; import { runDuplicateDetectionAction } from "../review/actions"; import { retryAllFailedAction, drainAllAction } from "../jobs/actions"; -import { - pauseAllWorkersAction, - resumeAllWorkersAction, -} from "../workers/actions"; +import { pauseLaneAction, resumeLaneAction } from "../operations/actions"; // The editor's command-first spine. Rebuilt on cmdk (shadcn Command) over the // shared NAV_GROUPS (so it never drifts from the sidebar) plus a set of global @@ -113,7 +110,7 @@ const GLOBAL_ACTIONS: RunAction[] = [ icon: PauseOctagon, keywords: "stop transcription halt", run: async () => { - const r = await pauseAllWorkersAction(); + const r = await pauseLaneAction("transcription"); if (!r.ok) throw new Error(r.error ?? "Failed to pause workers"); return "Paused all workers"; }, @@ -124,7 +121,7 @@ const GLOBAL_ACTIONS: RunAction[] = [ icon: Play, keywords: "start transcription continue", run: async () => { - const r = await resumeAllWorkersAction(); + const r = await resumeLaneAction("transcription"); if (!r.ok) throw new Error(r.error ?? "Failed to resume workers"); return "Resumed all workers"; }, diff --git a/editor/app/components/lanes/LaneDeck.tsx b/editor/app/components/lanes/LaneDeck.tsx @@ -9,17 +9,8 @@ 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 { - pauseAllWorkersAction, - resumeAllWorkersAction, -} from "../../workers/actions"; -import { - pauseBackfillAction, - pauseDigestsAction, - pauseDownloadsAction, - resumeBackfillAction, - resumeDigestsAction, - resumeDownloadsAction, startBackfillSweepAction, startDigestSweepAction, stopBackfillSweepAction, @@ -32,11 +23,11 @@ import { // a lane can do (the widget could not start or stop a sweep at all, and had no // digest control whatsoever). // -// Each lane arrives on the wire in its own shape — transcription and downloads -// have a pause and no sweep, digest has `sweeping` + `paused`, backfill has -// `sweeping` + `enabled` (an inverted pause) — and this is where all four are -// normalised onto { gateHeld, feedRunning }. See laneState.ts for why those two -// are the axes. +// Every lane's gate reaches the wire as `held`, computed once by isGateHeld; no +// reader inverts. What still differs between lanes is their FEED — transcription +// and downloads have none (work arrives from jobs), digest and backfill each +// have a sweep — so this is where `held` and `sweeping` are folded onto +// { gateHeld, feedRunning }. See laneState.ts for why those two are the axes. export function LaneDeck({ workers, @@ -131,7 +122,7 @@ export function LaneDeck({ glyph: "▶", ariaLabel: "Resume Transcriptions", variant: "active", - action: resumeAllWorkersAction, + action: () => resumeLaneAction("transcription"), onChange: onWorkersChange, } : { @@ -142,7 +133,7 @@ export function LaneDeck({ title: "Stop taking new work; in-flight parakeet jobs finish the current segment, cache it, and resume on the next run", variant: "idle", - action: pauseAllWorkersAction, + action: () => pauseLaneAction("transcription"), onChange: onWorkersChange, }, ]} @@ -221,7 +212,7 @@ export function LaneDeck({ glyph: "▶", ariaLabel: "Resume Downloads", variant: "active", - action: resumeDownloadsAction, + action: () => resumeLaneAction("download"), onChange: onWorkersChange, } : { @@ -232,7 +223,7 @@ export function LaneDeck({ 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: pauseDownloadsAction, + action: () => pauseLaneAction("download"), onChange: onWorkersChange, }, ]} @@ -252,7 +243,7 @@ export function LaneDeck({ // Stopping the sweep drains rather than cancels: the channel in flight // finishes instead of losing a part-generated video. const digestSweeping = digest?.sweeping ?? false; - const digestPaused = digest?.paused ?? false; + const digestPaused = digest?.held ?? false; const digestControls: LaneControl[] = [ digestSweeping ? { @@ -291,7 +282,7 @@ export function LaneDeck({ title: "Resume digest generation. The running job picks up where it left off — it was holding, not stopped.", variant: "active", - action: resumeDigestsAction, + action: () => resumeLaneAction("digest"), onChange: onSynced, } : { @@ -302,7 +293,7 @@ export function LaneDeck({ title: "Hold digest generation without ending the sweep. The running job idles at zero and resumes instantly.", variant: "idle", - action: pauseDigestsAction, + action: () => pauseLaneAction("digest"), onChange: onSynced, }, ); @@ -400,7 +391,7 @@ export function LaneDeck({ // aria-labels are untouched — they are internal addressing that confuses // nobody, and the e2e suite finds these buttons by them. const backfillSweeping = backfill?.sweeping ?? false; - const laneEnabled = backfill?.enabled ?? false; + const backfillHeld = backfill?.held ?? false; const backfillAvailable = backfill?.anyKind ?? false; // Empty when the payload has not arrived, or when it predates `kinds` — which // drops the breakdown rather than rendering a row of zeros. @@ -434,7 +425,7 @@ export function LaneDeck({ action: () => startBackfillSweepAction(), onChange: onSynced, }, - laneEnabled + !backfillHeld ? { key: "backfill-gate", label: "Hold the lane", @@ -443,7 +434,7 @@ export function LaneDeck({ 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: pauseBackfillAction, + action: () => pauseLaneAction("backfill"), onChange: onSynced, } : { @@ -454,7 +445,7 @@ export function LaneDeck({ title: "Resume the backfill lane. A held job picks up within a few seconds — it was holding, not stopped.", variant: "active", - action: resumeBackfillAction, + action: () => resumeLaneAction("backfill"), onChange: onSynced, }, ] @@ -468,7 +459,7 @@ export function LaneDeck({ // feature is switched off must not read as "all caught up". state={deriveLaneState({ available: backfillAvailable, - gateHeld: !laneEnabled, + gateHeld: backfillHeld, feedRunning: backfillSweeping, })} figure={ @@ -548,7 +539,7 @@ export function LaneDeck({ pause: {laneOperations.map((k) => k.label).join(", ")}. </span> )} - {backfillSweeping && !laneEnabled && ( + {backfillSweeping && backfillHeld && ( // Kept in words as well as in the rail. A sweep armed with the lane // off holds at a zero limit rather than doing work, and "wedged" is // what that looks like to anyone who does not already know. diff --git a/editor/app/components/lanes/laneState.ts b/editor/app/components/lanes/laneState.ts @@ -13,10 +13,11 @@ import { // not stopped and it is not working. That is `holding`, and it is the whole // reason this is four states rather than a boolean. // -// The four lanes model themselves four different ways on the wire (transcription -// and downloads have a pause and no sweep; digest has `sweeping` + `paused`; -// backfill has `sweeping` + `enabled`, an INVERTED pause). Normalising happens -// once, in LaneDeck, and everything downstream reads only this. +// Every lane's gate reaches the wire as `held`, computed once by isGateHeld +// (common/lib/pauseGates.ts); no reader inverts. What still differs is the FEED: +// transcription and downloads have none — work arrives from jobs — while digest +// and backfill each have a sweep. Folding those two facts into one state happens +// in LaneDeck, and everything downstream reads only this. export type LaneState = "running" | "holding" | "idle" | "unavailable"; export function deriveLaneState({ diff --git a/editor/app/jobs/actions.ts b/editor/app/jobs/actions.ts @@ -2,7 +2,7 @@ import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { getSettings, writeSettings } from "yt-dlp-transcript-common/lib/settings"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { backfillLaneOperations } from "yt-dlp-transcript-common/lib/operations"; import { pruneJobLogs } from "yt-dlp-transcript-common/jobs/listJobs"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; @@ -147,73 +147,6 @@ export async function retryAllFailedAction(): Promise<{ count: number }> { return { count }; } -// Global, restart-surviving downloads pause. Toggling `downloadsPaused` in -// settings.json gates the auto-download runner (which re-reads getSettings each -// loop iteration, so it stops dispatching on the next tick) and every manual -// download-bearing pipeline action (which returns a "Downloads are paused" -// result). Enumeration/store-playlist stay allowed — they write no media. -export type DownloadsPauseResult = { ok: boolean; error?: string }; - -async function setDownloadsPaused( - paused: boolean, -): Promise<DownloadsPauseResult> { - try { - const current = getSettings(); - if (current.downloadsPaused !== paused) { - await writeSettings({ ...current, downloadsPaused: paused }); - } - } catch (e) { - return { ok: false, error: (e as Error).message }; - } - revalidatePath("/jobs"); - return { ok: true }; -} - -export async function pauseDownloadsAction(): Promise<DownloadsPauseResult> { - return setDownloadsPaused(true); -} - -export async function resumeDownloadsAction(): Promise<DownloadsPauseResult> { - return setDownloadsPaused(false); -} - -// Global, restart-surviving digest pause — the counterpart of the downloads one -// and, until now, a flag with no writer. `digestBatch`'s limit() has always -// re-read `digestsPaused` at dispatch time and returned 0 to make the pool -// idle-wait (a real pause: the job HOLDS rather than ending, so nothing has to -// be re-derived on resume), and settings/actions.ts passed the field through -// untouched with a comment saying "the dashboard/channel controls own the -// pause". Those controls did not exist, so the pause could not be set from -// anywhere. This is them. -// -// It needs no boot hook, for the same reason downloadsPaused doesn't: the flag -// is consulted at dispatch, not applied to a live pool. -export type DigestPauseResult = { ok: boolean; error?: string }; - -async function setDigestsPaused(paused: boolean): Promise<DigestPauseResult> { - try { - const current = getSettings(); - if (current.digest.digestsPaused !== paused) { - await writeSettings({ - ...current, - digest: { ...current.digest, digestsPaused: paused }, - }); - } - } catch (e) { - return { ok: false, error: (e as Error).message }; - } - revalidatePath("/jobs"); - return { ok: true }; -} - -export async function pauseDigestsAction(): Promise<DigestPauseResult> { - return setDigestsPaused(true); -} - -export async function resumeDigestsAction(): Promise<DigestPauseResult> { - return setDigestsPaused(false); -} - // Arm / disarm the corpus-wide sweep. Distinct from the pause: a pause holds a // running sweep at zero throughput, this decides whether there is a sweep at // all — and it persists, so the boot hook resumes it. @@ -326,58 +259,6 @@ export async function stopBackfillSweepAction(): Promise<BackfillSweepResult> { } } -// PAUSE / RESUME the backfill lane. -// -// WHY THIS TOGGLES `backfill.enabled` RATHER THAN A NEW `backfillPaused` FIELD. -// The digest lane has both a sweep flag and a separate `digestsPaused`; the -// backfill lane has only the one switch, and that switch already behaves -// exactly like a pause — `backfillBatch`'s limit() re-reads it at DISPATCH time -// and returns 0, which makes the pool idle-WAIT rather than finish. The job -// stays alive, logs "Backfill lane disabled in settings — holding", and resumes -// within one 3s poll with nothing re-derived. Adding a second field would give -// the same hold two writers that could disagree, and would silently divorce -// this button from the "Run the backfill lane" checkbox on the speaker -// operations' pages. -// -// So: one field, two places to set it, and they cannot drift. -// -// This REVISES an earlier "one button, not two" decision. That reasoning was -// that idle-only weighting made a manual hold unnecessary — true for standing -// aside from transcription, but it gives an operator no way to stop a long -// CPU-bound backfill for reasons of their own (the machine is a desktop someone -// is using). See the backfill section of app/components/lanes/LaneDeck.tsx for -// the revised rationale. -// -// Needs no boot hook: the flag is consulted at dispatch, not applied to a live -// pool, and it is already persisted in settings.json. -export type BackfillPauseResult = { ok: boolean; error?: string }; - -async function setBackfillLaneEnabled( - enabled: boolean, -): Promise<BackfillPauseResult> { - try { - const current = getSettings(); - if (current.backfill.enabled !== enabled) { - await writeSettings({ - ...current, - backfill: { ...current.backfill, enabled }, - }); - } - } catch (e) { - return { ok: false, error: (e as Error).message }; - } - revalidatePath("/jobs"); - return { ok: true }; -} - -export async function pauseBackfillAction(): Promise<BackfillPauseResult> { - return setBackfillLaneEnabled(false); -} - -export async function resumeBackfillAction(): Promise<BackfillPauseResult> { - return setBackfillLaneEnabled(true); -} - // Retention scopes offered by the ClearLogsMenu. "all" clears every finished // job's log; the day-scopes clear anything older than that. Running/queued jobs // are never deleted (see pruneJobLogs). diff --git a/editor/app/jobs/active/buildActiveJobs.ts b/editor/app/jobs/active/buildActiveJobs.ts @@ -10,6 +10,7 @@ import { } from "yt-dlp-transcript-common/controller/channels"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { isGateHeld } from "yt-dlp-transcript-common/lib/pauseGates"; import { diskGate, type DiskGateReason, @@ -319,7 +320,7 @@ function buildLanes(jobs: RunningJobsListItem[]): ActiveLaneView[] { kind: "digest-sweep", label: "Digest", sweeping: settings.digest.sweepEnabled || getDigestSweepJobId() !== null, - gateHeld: settings.digest.digestsPaused, + gateHeld: isGateHeld(settings, "digest"), available: true, inFlight: digestInFlight, }), @@ -332,9 +333,7 @@ function buildLanes(jobs: RunningJobsListItem[]): ActiveLaneView[] { label: "Backfill", sweeping: settings.backfill.sweepEnabled || getBackfillSweepJobId() !== null, - // INVERTED on the wire: backfill's gate is `enabled` where digest's is - // `paused`. Normalised here so both lanes mean the same thing downstream. - gateHeld: !settings.backfill.enabled, + gateHeld: isGateHeld(settings, "backfill"), available: backfillLaneOperations(settings).length > 0, inFlight: backfillInFlight, }), diff --git a/editor/app/jobs/components/PauseDownloadsButton.tsx b/editor/app/jobs/components/PauseDownloadsButton.tsx @@ -1,44 +0,0 @@ -"use client"; - -import { LaneActionButton } from "../../components/lanes/LaneActionButton"; -import { pauseDownloadsAction, resumeDownloadsAction } from "../actions"; - -// Global pause/resume for downloads, persisted in settings.json (downloadsPaused -// — survives a restart). Pausing gates the auto-download runner on its next loop -// iteration and makes manual download-bearing pipeline actions return a -// "Downloads are paused" notice; store-playlist/enumeration stay allowed. -// -// A thin wrapper over the shared lane button, in the same prop shape as -// PauseTranscriptionsButton — see there for why. The dashboard and the widget -// render the downloads gate through LaneDeck instead. -export function PauseDownloadsButton({ - paused, - disabled, - onChange, -}: { - paused: boolean; - disabled?: boolean; - onChange?: () => void | Promise<void>; -}) { - return paused ? ( - <LaneActionButton - label="Resume Downloads" - glyph="▶" - ariaLabel="Resume Downloads" - variant="active" - action={resumeDownloadsAction} - onChange={onChange} - /> - ) : ( - <LaneActionButton - 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" - disabled={disabled} - action={pauseDownloadsAction} - onChange={onChange} - /> - ); -} diff --git a/editor/app/jobs/components/PauseTranscriptionsButton.tsx b/editor/app/jobs/components/PauseTranscriptionsButton.tsx @@ -1,10 +1,7 @@ "use client"; import { LaneActionButton } from "../../components/lanes/LaneActionButton"; -import { - pauseAllWorkersAction, - resumeAllWorkersAction, -} from "../../workers/actions"; +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 @@ -31,7 +28,7 @@ export function PauseTranscriptionsButton({ glyph="▶" ariaLabel="Resume Transcriptions" variant="active" - action={resumeAllWorkersAction} + action={() => resumeLaneAction("transcription")} onChange={onChange} /> ) : ( @@ -45,7 +42,7 @@ export function PauseTranscriptionsButton({ // resume must always be reachable, or a paused pool with zero enabled // workers could never be released. disabled={disabled} - action={pauseAllWorkersAction} + action={() => pauseLaneAction("transcription")} onChange={onChange} /> ); diff --git a/editor/app/operations/actions.ts b/editor/app/operations/actions.ts @@ -7,6 +7,12 @@ import { type SiteSettings, } from "yt-dlp-transcript-common/lib/settings"; import { + isGateHeld, + withGateHeld, + type PauseLane, +} from "yt-dlp-transcript-common/lib/pauseGates"; +import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; +import { startAutoRunner, stopAutoRunner, } from "yt-dlp-transcript-common/controller/autoRunner"; @@ -131,6 +137,65 @@ export async function snoozeAutoQueueAction( return { ok: true }; } +// ONE PAIR OF ACTIONS FOR EVERY LANE'S GATE — transcription, download, digest +// and the backfill lane. There used to be four pairs in two files, each with its +// own result type, its own polarity and its own revalidation list; the surfaces +// that drew a lane then had to know which pair belonged to which lane. +// +// KEYED BY LANE, NOT BY OPERATION, and that is the decision worth stating: the +// gate is per lane, three speaker operations share one, and a per-operation page +// is not a per-operation switch. `pauseLaneFor(operationId)` is how an operation +// page finds the lane it is really holding. +// +// A HOLD, NEVER A STOP. Every gate is consulted at dispatch by a limit() that +// returns 0, so the running job idle-waits, keeps its place, and resumes within +// one poll with nothing re-derived. +export type LanePauseResult = { ok: boolean; error?: string }; + +async function setLaneHeld( + lane: PauseLane, + held: boolean, +): Promise<LanePauseResult> { + // TRANSCRIPTION IS THE ONE LANE WITH A LIVE HOLD, and it goes first. The pool + // is the machine; settings.transcriptionsPaused is only what + // editor/instrumentation.ts re-applies at boot. Every UI surface reads the + // pool, so flipping it first is what makes the button feel immediate. + if (lane === "transcription") { + if (held) getWorkerPool().pauseAll(); + else getWorkerPool().resumeAll(); + } + try { + const cur = getSettings(); + if (isGateHeld(cur, lane) !== held) { + 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. + return { ok: false, error: (e as Error).message }; + } + // NOT "/" — the dashboard reads its pauses from its own polls + // (DashboardCockpit), so revalidating it changes nothing it displays and DOES + // wipe the client router cache app-wide (see experimental.staleTimes). + revalidatePath("/jobs"); + revalidatePath("/workers"); + revalidatePath("/operations/[id]", "page"); + return { ok: true }; +} + +export async function pauseLaneAction(lane: PauseLane): Promise<LanePauseResult> { + return setLaneHeld(lane, true); +} + +export async function resumeLaneAction( + lane: PauseLane, +): Promise<LanePauseResult> { + return setLaneHeld(lane, false); +} + // Recursively drop every leaf that matches this channel, so re-prioritizing the // same channel doesn't accumulate duplicate leaves (a group emptied of children // is kept — sanitizeAutoQueue tolerates it, and removing it could orphan a diff --git a/editor/app/operations/components/SweepLane.tsx b/editor/app/operations/components/SweepLane.tsx @@ -10,16 +10,12 @@ import { LANE_WORD, } from "../../components/lanes/laneState"; import { - pauseBackfillAction, - pauseDigestsAction, - resumeBackfillAction, - resumeDigestsAction, startBackfillSweepAction, startDigestSweepAction, stopBackfillSweepAction, stopDigestSweepAction, } from "../../jobs/actions"; -import { saveLaneOrderAction } from "../actions"; +import { pauseLaneAction, resumeLaneAction, saveLaneOrderAction } from "../actions"; import type { SweepLaneStatus } from "../lanes"; import type { OperationBand } from "../../components/pipelines/band"; import { OrderReach } from "./OrderReach"; @@ -191,14 +187,10 @@ export function SweepLane({ disabled={working || !lane.available} aria-label={`${lane.gateHeld ? "Resume" : "Pause"} ${lane.label}`} onClick={() => - run( + run(() => lane.gateHeld - ? digest - ? resumeDigestsAction - : resumeBackfillAction - : digest - ? pauseDigestsAction - : pauseBackfillAction, + ? resumeLaneAction(lane.id) + : pauseLaneAction(lane.id), ) } className={ diff --git a/editor/app/operations/lanes.ts b/editor/app/operations/lanes.ts @@ -1,5 +1,9 @@ import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { + isGateHeld, + type PauseLane, +} from "yt-dlp-transcript-common/lib/pauseGates"; import type { AutoQueueOrder, AutoQueueReach } from "yt-dlp-transcript-common/jobs/autoQueuePolicy"; import { allOperations, @@ -50,6 +54,10 @@ import { export type SweepLaneId = "digest" | "backfill"; +// The sweep lanes are pause lanes too — the other half of the assertion +// pauseGates.ts makes for AutoQueueKind. One gate definition, two id spaces. +type _SweepLanesArePauseLanes = SweepLaneId extends PauseLane ? true : never; + // The sweep lane that dispatches an operation — or NULL for one neither sweep // runs. Takes the DESCRIPTOR, and reads its queue key. // @@ -305,7 +313,7 @@ export async function buildAutoQueueLanes(): Promise<AutoQueueLanesPayload> { id: "digest", label: "Digest", sweeping: settings.digest.sweepEnabled, - gateHeld: settings.digest.digestsPaused, + gateHeld: isGateHeld(settings, "digest"), available: true, sweepJobId: getDigestSweepJobId(), inFlight: inFlightOn([DIGEST_LOCAL_QUEUE, DIGEST_REMOTE_QUEUE]), @@ -331,9 +339,7 @@ export async function buildAutoQueueLanes(): Promise<AutoQueueLanesPayload> { .map((k) => k.id), ), sweeping: settings.backfill.sweepEnabled, - // INVERTED: backfill's gate is `enabled`, where digest's is `paused`. - // Normalised here rather than at every reader, exactly as LaneDeck does. - gateHeld: !settings.backfill.enabled, + gateHeld: isGateHeld(settings, "backfill"), // With no backfill FEATURE switched on there is no lane — see `available`. // Digest is always available because the operation is always registered. available: kinds.some((k) => k.lane.queueKey === BACKFILL_QUEUE), diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx @@ -724,8 +724,8 @@ function BackfillStrip({ data }: { data: WidgetSyncPayload }) { <span className="tabular-nums"> {b.groupLabel} {b.reachable.toLocaleString()} reachable {b.needsMedia > 0 && <> · {b.needsMedia.toLocaleString()} need media</>} - {!b.enabled && " · lane off"} - {b.enabled && b.sweeping && " · sweeping"} + {b.held && " · lane off"} + {!b.held && b.sweeping && " · sweeping"} </span> </section> ); diff --git a/editor/app/workers/actions.ts b/editor/app/workers/actions.ts @@ -4,23 +4,6 @@ import { revalidatePath } from "next/cache"; import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { writeWorkerDefaults } from "yt-dlp-transcript-common/jobs/workerDefaults"; -import { getSettings, writeSettings } from "yt-dlp-transcript-common/lib/settings"; - -// Persist the transcription-pause flag to settings.json so a pause survives a -// server restart (the pool is re-paused at boot in editor/instrumentation.ts). -// Best-effort: the live pause already took effect, so a failed write only means -// the pause won't survive a restart — surface it in the log rather than throw. -async function persistTranscriptionsPaused(paused: boolean): Promise<void> { - try { - const current = getSettings(); - if (current.transcriptionsPaused === paused) return; - await writeSettings({ ...current, transcriptionsPaused: paused }); - } catch (e) { - console.warn( - `Failed to persist transcriptionsPaused=${paused}: ${(e as Error).message}`, - ); - } -} // Runtime worker controls for the Workers page. Most of these are transient // operator overrides on the live pool — they are NOT written to settings.json @@ -57,30 +40,10 @@ export async function drainWorkerAction(id: string): Promise<WorkerActionResult> return ok ? { ok: true } : { ok: false, error: `Unknown worker "${id}"` }; } -// NOTE — why there is no revalidatePath("/") in the pause/resume actions any -// more. The dashboard's paused state comes from its own polled /api/workers -// payload, not from a server render, so revalidating "/" changed nothing it -// displays. What it DID do is wipe the client router cache app-wide, which is -// what makes navigating back to a page you were just on instant (see -// experimental.staleTimes in next.config.ts). Same reasoning applies to the -// pause/sweep actions in ../jobs/actions.ts. - -// Temporary pause-all: disable every worker, remembering each one's current -// state. Running batches pause (they wait for a worker) rather than failing. -export async function pauseAllWorkersAction(): Promise<WorkerActionResult> { - getWorkerPool().pauseAll(); - await persistTranscriptionsPaused(true); - refresh(); - return { ok: true }; -} - -// Resume: restore every worker to the state it had when pauseAll ran. -export async function resumeAllWorkersAction(): Promise<WorkerActionResult> { - getWorkerPool().resumeAll(); - await persistTranscriptionsPaused(false); - refresh(); - return { ok: true }; -} +// THE TRANSCRIPTION PAUSE IS NOT HERE ANY MORE. It is one lane of one pair, +// ../operations/actions.ts's pauseLaneAction / resumeLaneAction, which flips the +// live pool and persists the flag in one place — see there for why the +// revalidation list leaves "/" alone. // Gracefully stop the worker's in-flight transcription and keep the partial // result (parakeet finishes the current window, stitches, and writes a partial). diff --git a/editor/app/workers/buildWorkers.ts b/editor/app/workers/buildWorkers.ts @@ -3,6 +3,7 @@ import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { readWorkerDefaults } from "yt-dlp-transcript-common/jobs/workerDefaults"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { isGateHeld } from "yt-dlp-transcript-common/lib/pauseGates"; import type { WorkersPayload, WorkerTask } from "./components/WorkersView"; // Builds the Workers screen payload: the pool's per-worker slot/state summary @@ -50,7 +51,7 @@ export function buildWorkersPayload(): WorkersPayload { paused: pool.isPaused(), // Persisted global downloads pause (settings.json). Surfaced here so the // dashboard/widget controls can read both pause states from one poll. - downloadsPaused: getSettings().downloadsPaused, + downloadsPaused: isGateHeld(getSettings(), "download"), workers, defaultEnabledIds, }; diff --git a/editor/instrumentation.ts b/editor/instrumentation.ts @@ -62,7 +62,13 @@ export async function register() { const { getSettings } = await import( "yt-dlp-transcript-common/lib/settings" ); - if (getSettings().transcriptionsPaused) { + const { isGateHeld } = await import( + "yt-dlp-transcript-common/lib/pauseGates" + ); + // One of exactly two places the PERSISTED transcription flag is read (the + // other is the action's "did this change anything" check). Everything that + // asks "is transcription held right now" reads the pool instead. + if (isGateHeld(getSettings(), "transcription")) { const { getWorkerPool } = await import( "yt-dlp-transcript-common/jobs/workerPool" );