commit b7fd46a8d2c66804cbec1839259d67cc1cfcfa20
parent 7f59a8f6fba08977a62f1d36a3eeb81e817a8a09
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 7 Sep 2026 23:57:49 -0400
editor: every control over a lane's gate writes the lane's key, and the policy save carries it
"Run the backfill lane" goes through `withGateHeld` and its checkbox reads
`isGateHeld`, so the form and the pause button beside it throw one switch;
`saveAutoQueueAction` carries `held` beside `snoozeUntil`, because the one
object in this repo that spells every policy field explicitly is the one place a
lane could be silently unheld by a rule reorder. The two unrelated saves that
merely PRESERVE a retired pause field keep preserving it — it is the migration's
input now, and rebuilding it would change how an unmigrated file reads.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
6 files changed, 72 insertions(+), 32 deletions(-)
diff --git a/editor/app/operations/[id]/page.tsx b/editor/app/operations/[id]/page.tsx
@@ -5,7 +5,10 @@ import { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels
import { PLATFORM_VALUES } from "yt-dlp-transcript-common/lib/platform";
import { selectableBucketsForKind } from "yt-dlp-transcript-common/jobs/autoQueuePolicy";
import { LANES } from "yt-dlp-transcript-common/lib/autoQueueTypes";
-import { pauseLaneFor } from "yt-dlp-transcript-common/lib/pauseGates";
+import {
+ isGateHeld,
+ pauseLaneFor,
+} from "yt-dlp-transcript-common/lib/pauseGates";
import {
operationCatalog,
operationLabel,
@@ -210,7 +213,11 @@ export default async function OperationPage({
// equivalent — its order lives on the lane policy the console already edits.
const laneSettings =
runnerKind === "backfill" ? (
- <LaneSettingsForm key="backfill-lane" initial={settings.backfill} />
+ <LaneSettingsForm
+ key="backfill-lane"
+ initial={settings.backfill}
+ held={isGateHeld(settings, "backfill")}
+ />
) : null;
// THE CADENCE CONSOLE, off the descriptor's trigger. A cadence-triggered
diff --git a/editor/app/operations/actions.ts b/editor/app/operations/actions.ts
@@ -59,9 +59,13 @@ export async function saveAutoQueueAction(
const current = getSettings();
// NOTE: this object lists every persisted policy field EXPLICITLY, so a field
// added to AutoQueuePolicy and forgotten here is silently dropped on every
- // save rather than failing loudly. `snoozeUntil` is deliberately carried over
- // from `current` instead of taken from the form: the snooze is set by its own
- // action, and a policy save (e.g. reordering rules) must not cancel it.
+ // save rather than failing loudly. `snoozeUntil` and `held` are deliberately
+ // carried over from `current` instead of taken from the form: each is set by
+ // its own action, and a policy save (e.g. reordering rules) must not cancel a
+ // snooze or UNHOLD A HELD LANE — which, since the gate moved onto the policy
+ // in slice 1.4, is exactly what "forgotten here" would now mean. `undefined`
+ // is carried as undefined on purpose: that is what keeps a lane that has
+ // never been written falling back to its retired field.
const next: SiteSettings = {
...current,
autoQueue: {
@@ -72,6 +76,7 @@ export async function saveAutoQueueAction(
replaceAutoSubs: input.replaceAutoSubs === true,
order: input.order,
snoozeUntil: current.autoQueue[kind].snoozeUntil ?? null,
+ held: current.autoQueue[kind].held,
root: input.root,
},
},
diff --git a/editor/app/operations/components/settings/LaneSettingsForm.tsx b/editor/app/operations/components/settings/LaneSettingsForm.tsx
@@ -15,8 +15,15 @@ import type { SaveResult } from "../../../settings/actions";
// That is why it takes no operation: there is nothing here that belongs to one.
export function LaneSettingsForm({
initial,
+ // THE LANE'S GATE, read by the caller through `isGateHeld`. Not
+ // `initial.enabled`: since slice 1.4 the gate is `autoQueue.backfill.held`,
+ // and `backfill.enabled` is the retired field it migrated FROM — a checkbox
+ // reading it would show the pre-migration answer on a file that has since
+ // been written, and would disagree with the pause button beside it.
+ held,
}: {
initial: SiteSettings["backfill"];
+ held: boolean;
}) {
const [state, formAction, pending] = useActionState<
SaveResult | undefined,
@@ -48,27 +55,27 @@ export function LaneSettingsForm({
</p>
<p className="text-xs text-muted-foreground">
<strong className="font-medium text-foreground">
- Which operations a corpus sweep runs, and over which channels, is
- not set here.
+ Which operations the lane runs, and over which channels, is not set
+ here.
</strong>{" "}
- That scope has to be written at the moment the sweep is armed — a
- scope saved separately would be resurrected as a corpus-wide run by
- the next restart — so it lives in the sweep controls directly
- above, beside the itinerary they draw, which is what lets you read the
- commitment before making it.
+ That scope is the lane's rule list, on the runner console above
+ — where it sits beside the work each rule is holding, which is
+ what lets you read the commitment before making it.
</p>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="backfillEnabled"
- defaultChecked={initial.enabled}
+ defaultChecked={!held}
className="mt-1"
/>
<span className="flex flex-col gap-1">
<span className="font-medium">Run the backfill lane</span>
<span className="text-xs text-muted-foreground">
Off means the lane still <em>reports</em> what is missing —
- that is what the indicators are for — but runs nothing.
+ that is what the indicators are for — but runs nothing. It is
+ the same gate the Hold button above throws: one switch, two places
+ to reach it.
</span>
</span>
</label>
diff --git a/editor/app/operations/settingsActions.ts b/editor/app/operations/settingsActions.ts
@@ -21,6 +21,7 @@ import {
isDigestSectionKind,
isDigestTimestampMode,
} from "yt-dlp-transcript-common/lib/digest";
+import { withGateHeld } from "yt-dlp-transcript-common/lib/pauseGates";
import type { SaveResult } from "../settings/actions";
// Same helper as operations/actions.ts, and for the same reason — a "use server"
@@ -98,7 +99,12 @@ export async function saveDigestSettingsAction(
// writeSettings runs sanitizeDigestApps over it. Narrowed at exactly
// this field so every OTHER field in the block stays type-checked.
apps: digestApps as SiteSettings["digest"]["apps"],
- // Not edited by this form — the dashboard/channel controls own the pause.
+ // Not edited by this form, and no longer the gate: since slice 1.4 the
+ // digest lane's pause is `autoQueue.digest.held`, which the `...current`
+ // spread above carries untouched. This field is the migration's INPUT and
+ // is preserved rather than rebuilt, because a save that reset it would
+ // change how a settings.json that has NOT yet been written through
+ // getSettings reads.
digestsPaused: dD.digestsPaused,
spendCapUsd: Number.parseFloat(
String(formData.get("digestSpendCapUsd") ?? "").trim(),
@@ -193,11 +199,16 @@ export async function saveDiarizationSettingsAction(
// form is drawn per LANE (beside the shared pause and sweep) rather than per
// operation.
//
-// The spread protects two things specifically: `allowRedownload`, which an
-// unrelated save must never flip ON (it writes media to a 97%-full disk), and
-// `sweepEnabled` + its scope, which this form does NOT render at all — those
-// are owned by the sweep controls above it on the same page, and reading them
-// from an absent form field would disarm a running multi-day sweep.
+// The spread protects `allowRedownload` specifically, which an unrelated save
+// must never flip ON (it writes media to a 97%-full disk).
+//
+// "RUN THE BACKFILL LANE" IS THE LANE'S GATE, and it goes through
+// `withGateHeld` — a deliberate SECOND WRITER of `autoQueue.backfill.held`,
+// beside the pause buttons. It has always been two controls over one switch
+// (the checkbox wrote `backfill.enabled`, which `isGateHeld` read inverted);
+// what slice 1.4 changed is that the switch moved, and a form still writing the
+// retired field would be silently ignored by every reader — the mirror image of
+// the bug `withGateHeld` writing only the new key avoids on the other side.
//
// LaneSettingsForm renders every field of this block and is the only form that
// posts here.
@@ -207,15 +218,18 @@ export async function saveBackfillLaneSettingsAction(
): Promise<SaveResult> {
const current = getSettings();
const dBack = current.backfill;
- const next: SiteSettings = {
- ...current,
- backfill: {
- ...dBack,
- enabled: formData.get("backfillEnabled") === "on",
- concurrency: num(formData, "backfillConcurrency", dBack.concurrency),
- allowRedownload: formData.get("backfillAllowRedownload") === "on",
+ const next: SiteSettings = withGateHeld(
+ {
+ ...current,
+ backfill: {
+ ...dBack,
+ concurrency: num(formData, "backfillConcurrency", dBack.concurrency),
+ allowRedownload: formData.get("backfillAllowRedownload") === "on",
+ },
},
- };
+ "backfill",
+ formData.get("backfillEnabled") !== "on",
+ );
try {
await writeSettings(next);
} catch (e) {
diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts
@@ -217,8 +217,12 @@ export async function saveSettingsAction(
parallelTranscriptions: PARALLEL_TRANSCRIPTIONS_DEFAULT,
inlineTranscribeOnFallback,
skipLiveDownloads,
- // Pause flags aren't edited by this form (the dashboard/widget/Workers
- // controls own them). Preserve the current values on an unrelated save.
+ // The two RETIRED pause flags. They are not this form's business and they
+ // are not the gate any more either — since slice 1.4 that is
+ // `autoQueue[lane].held`, preserved a few lines below with the rest of the
+ // policy. These are the migration's INPUT for a settings.json that has not
+ // yet been written through getSettings, so they are preserved rather than
+ // rebuilt: resetting them here would change how such a file reads.
transcriptionsPaused: getSettings().transcriptionsPaused,
downloadsPaused: getSettings().downloadsPaused,
verifyAvailabilityBeforeClean,
diff --git a/editor/instrumentation.ts b/editor/instrumentation.ts
@@ -65,9 +65,12 @@ export async function register() {
const { isGateHeld } = await import(
"yt-dlp-transcript-common/lib/pauseGates"
);
- // One of exactly two places the PERSISTED transcription flag is read (the
+ // One of exactly two places the PERSISTED transcription gate 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.
+ // asks "is transcription held right now" reads the pool instead. Since
+ // slice 1.4 the stored value is `autoQueue.transcription.held`; this line
+ // needs no edit for that, because isGateHeld is the one thing that knows
+ // where a lane's gate lives.
if (isGateHeld(getSettings(), "transcription")) {
const { getWorkerPool } = await import(
"yt-dlp-transcript-common/jobs/workerPool"