commit e8065c6f51554d0729d2d4143f8f22d0becccf43
parent 1f7c28b9bc26ffa64201ad7c287f6ceb02790406
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 7 Sep 2026 20:24:36 -0400
editor: every lane's operation page gets its runner console
Slice 1.2, third commit. `/operations/<id>` chooses the runner console off the
operation's LANE (`pauseLaneFor`), not off `op.runner` — which is set on the two
external entries alone and `undefined` for sync and for every registry
operation, so reading it would have left the two lanes that just gained runners
without a console. `pauseLaneFor` is the rule that already answers "which lane
dispatches this", asking `runner` first so an external entry sharing a queue
with no runner of its own still gets null.
So digest, diarization and both attribution operations now draw
`RunnerOperationView` — the same section, the same Start/Drain/Stop, the same
claim ladder — BESIDE their sweep panel, for the length of this slice. Both
consoles, because an operator mid-migration has to be able to see which one is
driving; 1.3 deletes the sweep half.
**`data-lane` is the runner's, and the sweep panel becomes `data-sweep-lane`.**
Two sections answering one selector on one page fails every scoped lookup under
Playwright's strict mode, and `<section data-lane={kind}>` is the contract all
four lanes share — so the panel that is being retired is the one that moves.
Eight selectors across auto-queue, backfill and attribution specs follow it;
all eight are sweep-panel assertions, and they retire with the attribute.
`/api/auto-queue/control` accepts all four lanes, checked against LANES rather
than two names so it cannot be forgotten again — and a refusal to start now
carries WHY, since "policy disabled" and "the sweep is armed" want different
things from the operator.
The console's four lane-shaped details: `RUNNER_TITLE` names the heading (and
so the Start/Drain/Stop aria-labels) per lane; `LANE_ORDERS` offers "Shortest
first" on the digest lane alone, because it is the only lane whose runner can
price its candidates; the replace-auto-captions switch is drawn only for a lane
that HAS the opt-in bucket it names; and `orderTradeoff` says nothing on the
operation lanes, whose warning about retries losing their head start describes
buckets they do not have.
tsc clean in six packages, common 896, mcp 205.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
8 files changed, 127 insertions(+), 34 deletions(-)
diff --git a/editor/app/api/auto-queue/control/route.ts b/editor/app/api/auto-queue/control/route.ts
@@ -2,8 +2,10 @@ import { NextResponse } from "next/server";
import {
drainAutoRunner,
startAutoRunner,
+ startAutoRunnerBlockedReason,
stopAutoRunner,
} from "yt-dlp-transcript-common/controller/autoRunner";
+import { LANES } from "yt-dlp-transcript-common/lib/autoQueueTypes";
import type { AutoQueueKind } from "yt-dlp-transcript-common/jobs/autoQueueState";
export const dynamic = "force-dynamic";
@@ -14,8 +16,13 @@ export const dynamic = "force-dynamic";
// flipping the enable toggle. The editor admin surface is otherwise
// unauthenticated (trusted self-host), consistent with the rest of the app.
//
-// Body: { kind: "transcription" | "download", action: "start" | "stop" | "drain" }.
+// Body: { kind: <one of LANES>, action: "start" | "stop" | "drain" }.
// "stop" hard-cancels (aborts in-flight); "drain" lets in-flight units finish.
+//
+// ALL FOUR LANES SINCE SLICE 1.2. It refused digest and backfill while they had
+// no executor — a Start button for a lane that would pick a video and not know
+// what to do with it is worse than no button. They have one now, and the check
+// is against LANES rather than two names so it cannot be forgotten again.
export async function POST(req: Request) {
let body: { kind?: unknown; action?: unknown };
try {
@@ -24,16 +31,26 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: false, error: "invalid JSON" }, { status: 400 });
}
const kind = body.kind;
- if (kind !== "transcription" && kind !== "download") {
+ if (typeof kind !== "string" || !LANES.includes(kind as AutoQueueKind)) {
return NextResponse.json(
- { ok: false, error: "kind must be 'transcription' or 'download'" },
+ { ok: false, error: `kind must be one of ${LANES.join(", ")}` },
{ status: 400 },
);
}
const action = body.action;
if (action === "start") {
+ // WHY IT DID NOT START, when it did not. `startAutoRunner` returns null for
+ // a disabled policy AND for a lane whose sweep is armed, and those want
+ // different answers from the operator — so the reason travels with the
+ // refusal rather than leaving a silent no-op on the page.
+ const blocked = startAutoRunnerBlockedReason(kind as AutoQueueKind);
const jobId = await startAutoRunner(kind as AutoQueueKind);
- return NextResponse.json({ ok: true, started: jobId !== null, jobId });
+ return NextResponse.json({
+ ok: true,
+ started: jobId !== null,
+ jobId,
+ ...(jobId === null && blocked ? { blocked } : {}),
+ });
}
if (action === "stop") {
const stopped = stopAutoRunner(kind as AutoQueueKind);
diff --git a/editor/app/operations/[id]/page.tsx b/editor/app/operations/[id]/page.tsx
@@ -5,6 +5,7 @@ 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 {
operationCatalog,
operationLabel,
@@ -152,11 +153,15 @@ export default async function OperationPage({
const op = descriptorFor(id);
if (!op) notFound();
- // OFF THE DESCRIPTOR, never off the id. `dispatch` alone cannot answer this —
- // download and transcription are `external` WITH a runner, and an external
- // operation sharing a runner's queue with none of its own is a legal entry —
- // so the registry states it and this reads it.
- const runnerKind: AutoQueueKind | null = op.runner ?? null;
+ // THE OPERATION'S LANE, never its id, and since slice 1.2 never `op.runner`
+ // either. `runner` is set only on the two EXTERNAL entries (download and
+ // transcription) and is `undefined` for sync and for every registry
+ // operation — so reading it would have left the digest and backfill lanes,
+ // which now have runners, without a console. `pauseLaneFor` is the one rule
+ // that already answers "which lane dispatches this": it asks `runner` first
+ // (so an external operation sharing a queue with no runner of its own still
+ // gets null) and falls back to the declared queue key.
+ const runnerKind: AutoQueueKind | null = pauseLaneFor(op.id);
// Likewise for the sweep lane. Null for an operation neither sweep dispatches;
// OperationDetail renders that as "no console here" rather than borrowing one.
const laneId = sweepLaneIdFor(op);
@@ -232,8 +237,10 @@ export default async function OperationPage({
<SyncConsole key="sync" initial={await schedulerPayload} />
) : null;
- const laneJobKinds =
- runnerKind || !laneId ? [] : JOB_KINDS_BY_LANE[laneId];
+ // The sweep panel's own in-flight list. Keyed off the SWEEP lane, not off the
+ // runner: since 1.2 a digest page has both consoles, and the runner lists its
+ // own units in flight while these are the per-channel jobs the sweep starts.
+ const laneJobKinds = laneId ? JOB_KINDS_BY_LANE[laneId] : [];
// Through the one builder, so this list has the same progress bars /jobs does
// (it used to drop `progress`, `tasks`, `drainable` and the reorder bounds).
const activeJobs =
diff --git a/editor/app/operations/components/OperationDetail.tsx b/editor/app/operations/components/OperationDetail.tsx
@@ -12,7 +12,7 @@ import { RunnerOperationView } from "./RunnerOperationView";
import { SweepLane } from "./SweepLane";
import { railStates } from "./railStates";
import { useHydrated, useOperationsStatus } from "./useOperationsStatus";
-import { type Channel } from "./dispatch";
+import { RUNNER_TITLE, type Channel } from "./dispatch";
import { StateBand } from "../../components/pipelines/StateBand";
import { RunningJobsList } from "../../jobs/components/RunningJobsList";
import type { JobRowView } from "../../jobs/jobRowView";
@@ -107,12 +107,19 @@ export function OperationDetail({
sync={sync}
/>
- {runnerKind ? (
+ {/* THE RUNNER CONSOLE AND THE SWEEP PANEL, BOTH, for the length of this
+ slice. Every lane has a runner now; two of them still have a sweep
+ beside it, and an operator mid-migration needs to see which one is
+ driving. 1.3 deletes the sweep half. They are distinguishable by
+ selector on purpose — the runner section carries `data-lane`, which is
+ the contract all four lanes share, and the sweep panel carries
+ `data-sweep-lane`, which retires with it. */}
+ {runnerKind && (
<>
<HowPriorityWorks />
<RunnerOperationView
kind={runnerKind}
- title={runnerKind === "transcription" ? "Auto-transcribe" : "Auto-download"}
+ title={RUNNER_TITLE[runnerKind]}
status={data[runnerKind]}
channels={channels}
platforms={platforms}
@@ -121,7 +128,9 @@ export function OperationDetail({
onRefresh={refresh}
/>
</>
- ) : laneId ? (
+ )}
+
+ {laneId && (
<SweepOperationView
id={id}
laneId={laneId}
@@ -131,7 +140,9 @@ export function OperationDetail({
operationSettings={operationSettings}
laneSettings={laneSettings}
/>
- ) : cadenceConsole ? (
+ )}
+
+ {!runnerKind && !laneId && cadenceConsole && (
// The settings slot is drawn here rather than inside the console: the
// console is a moved component with its own contract, and this keeps
// "console, then this operation's settings" the same order the sweep
@@ -140,7 +151,9 @@ export function OperationDetail({
{cadenceConsole}
{operationSettings}
</>
- ) : (
+ )}
+
+ {!runnerKind && !laneId && !cadenceConsole && (
// STILL THE FALLTHROUGH, for a registered operation nothing drives.
// A cadence console is a fourth arm, not a replacement for this one.
<NoConsoleView id={id} data={data} dependsOn={dependsOn} />
@@ -190,13 +203,20 @@ function SweepOperationView({
return (
<section
- // TWO HOOKS, because there are two facts. data-lane names the LANE this
- // panel controls — what the e2e suite scopes the sweep and pause buttons
- // by, and deliberately the SAME value on every speaker operation's page,
- // since they are one lane. data-operation names which operation the page
- // is about, which the lane cannot say. data-hydrated is the same testing
- // affordance the runner sections carry; see RunnerOperationView.
- data-lane={laneId}
+ // TWO HOOKS, because there are two facts. data-sweep-lane names the LANE
+ // this panel controls — what the e2e suite scopes the sweep and pause
+ // buttons by, and deliberately the SAME value on every speaker
+ // operation's page, since they are one lane. data-operation names which
+ // operation the page is about, which the lane cannot say. data-hydrated is
+ // the same testing affordance the runner sections carry; see
+ // RunnerOperationView.
+ //
+ // IT WAS `data-lane` UNTIL SLICE 1.2, and it had to move: the runner
+ // console now renders beside this panel on the same page, `data-lane` is
+ // the contract THAT section carries on all four lanes, and two sections
+ // answering one selector fails every scoped lookup on Playwright's strict
+ // mode. This attribute is deleted with the panel in 1.3.
+ data-sweep-lane={laneId}
data-operation={id}
data-hydrated={hydrated ? "true" : undefined}
className="flex flex-col gap-4"
diff --git a/editor/app/operations/components/PolicyTreeEditor.tsx b/editor/app/operations/components/PolicyTreeEditor.tsx
@@ -17,6 +17,7 @@ import { OrderReach } from "./OrderReach";
import { SaveBar } from "./SaveBar";
import {
type Channel,
+ LANE_ORDERS,
NUM_CLASS,
leafOrder,
orderTradeoff,
@@ -223,7 +224,10 @@ export function PolicyTreeEditor({
}
};
- const kindWord = kind === "transcription" ? "transcribe" : "download";
+ // "auto-transcribe" / "auto-download" for the two bucket lanes; the operation
+ // lanes' runners are named for the lane ("auto-digest"), which is the same
+ // word their job kind and their console heading use.
+ const kindWord = kind === "transcription" ? "transcribe" : kind;
const tradeoff = orderTradeoff(kind, form.order);
// What the claim rail's fill is a fraction OF: the runner's own ceiling when
// it has one, else however much it is currently carrying (so a rung holding
@@ -286,7 +290,14 @@ export function PolicyTreeEditor({
{/* Lowest-priority lane, off by default. Appending the opt-in bucket to
the TAIL of the default union is what makes it lowest priority: pending
- work is claimed bucket-by-bucket in list order. */}
+ work is claimed bucket-by-bucket in list order.
+
+ DRAWN ONLY FOR A LANE WITH BUCKETS. `replaceAutoSubs` names an opt-in
+ BUCKET, and the operation lanes have none — the switch would be a
+ control that writes a field nothing reads. `buckets` is
+ selectableBucketsForKind for this lane, so this asks the same source
+ the runner's projection does. */}
+ {buckets.length > 0 && (
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
@@ -310,6 +321,7 @@ export function PolicyTreeEditor({
</span>
</span>
</label>
+ )}
{/* ORDER AT THE FOOT OF THE LANE, in the same block the sweep lanes use,
so one control is learned once for all four pipelines. It stays part
@@ -327,6 +339,10 @@ export function PolicyTreeEditor({
tradeoff={tradeoff}
busy={saving}
orderAriaLabel={`video order for auto-${kindWord}`}
+ // WHAT THIS LANE'S RUNNER CAN ACTUALLY ORDER BY. The digest lane is the
+ // only one that can price its candidates, so it is the only one offered
+ // "Shortest first".
+ orders={LANE_ORDERS[kind]}
onChange={(next) => setForm((f) => ({ ...f, order: next.order }))}
/>
diff --git a/editor/app/operations/components/dispatch.ts b/editor/app/operations/components/dispatch.ts
@@ -88,6 +88,35 @@ export const RECENCY_ORDERS: ReadonlyArray<AutoQueueOrder> = [
"oldest",
];
+// WHICH ORDERS EACH LANE OFFERS. A console must never show an option whose
+// comparator does not exist — that is a control that silently does nothing —
+// so this is keyed off what the runner can actually answer.
+//
+// Only the digest lane can price its work: every one of its candidates has a
+// normalized transcript (that is what makes it reachable), and the duration is
+// in that file's first 8 KB. A download candidate has nothing on disk at all,
+// so `cheapest` on any other lane falls back to "listed" in the runner and is
+// not offered here.
+export const LANE_ORDERS: Record<
+ AutoQueueKind,
+ ReadonlyArray<AutoQueueOrder>
+> = {
+ transcription: RECENCY_ORDERS,
+ download: RECENCY_ORDERS,
+ digest: [...RECENCY_ORDERS, "cheapest"],
+ backfill: RECENCY_ORDERS,
+};
+
+// The runner console's heading, per lane. PINNED: the e2e suite scopes whole
+// tests on the <h2> text and on the "Start <title>" / "Drain <title>" /
+// "Stop <title>" aria-labels built from it.
+export const RUNNER_TITLE: Record<AutoQueueKind, string> = {
+ transcription: "Auto-transcribe",
+ download: "Auto-download",
+ digest: "Auto-digest",
+ backfill: "Auto-backfill",
+};
+
export function orderTradeoff(
kind: AutoQueueKind,
order: AutoQueueOrder,
@@ -95,6 +124,10 @@ export function orderTradeoff(
// Neither of these reorders by upload date, so neither costs a retry or a
// partial download its head start.
if (order === "listed" || order === "cheapest") return null;
+ // The trade-off named below is a DOWNLOAD/TRANSCRIPTION one — a retry or a
+ // partial losing its head start. The operation lanes have no retry bucket and
+ // no partial state to lose, so there is nothing to warn about.
+ if (kind === "digest" || kind === "backfill") return null;
return kind === "download"
? "Partial downloads lose their head start, so a half-finished download can wait behind fresh work."
: "Retries lose their head start, so a failed video can wait behind fresh work.";
diff --git a/editor/e2e/attribution.spec.ts b/editor/e2e/attribution.spec.ts
@@ -452,12 +452,12 @@ test("the stage card and the operation pages show attribution beside diarization
// not re-asserted — it still lives on the channel page's speakers stage,
// asserted a few lines above.
await page.goto("/operations/attribution-diarized");
- const diarized = page.locator('section[data-lane="backfill"]');
+ const diarized = page.locator('section[data-sweep-lane="backfill"]');
await expect(diarized).toContainText(CHANNEL);
await expect(diarized.getByText("1 reachable now")).toBeVisible();
await expect(diarized.getByText("1 blocked upstream")).toBeVisible();
await page.goto("/operations/attribution-text");
- const text = page.locator('section[data-lane="backfill"]');
+ const text = page.locator('section[data-sweep-lane="backfill"]');
await expect(text.getByText("2 reachable now")).toBeVisible();
});
diff --git a/editor/e2e/auto-queue.spec.ts b/editor/e2e/auto-queue.spec.ts
@@ -1207,7 +1207,7 @@ test("UI: each operation page carries its own lane, and the rail as context", as
// 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"]');
+ const digest = page.locator('section[data-sweep-lane="digest"]');
await awaitHydration(digest);
await expect(digest.getByRole("heading", { name: "Digest" })).toBeVisible();
await expect(
@@ -1235,12 +1235,12 @@ test("UI: Reach is disabled until an order is chosen, and it persists", async ({
});
await page.goto("/operations/digest");
- await awaitHydration(page.locator('section[data-lane="digest"]'));
+ await awaitHydration(page.locator('section[data-sweep-lane="digest"]'));
// Scoped to the lane. One lane per page now, but the scoping stays: Order and
// Reach are a pair every lane carries, and an unscoped getByLabel would be
// ambiguous the moment a second one shares a page again.
- const digest = page.locator('section[data-lane="digest"]');
+ const digest = page.locator('section[data-sweep-lane="digest"]');
const order = digest.getByLabel("Order", { exact: true });
const reach = digest.getByLabel("Reach", { exact: true });
// Reach is meaningless without an order, so it is disabled rather than
diff --git a/editor/e2e/backfill.spec.ts b/editor/e2e/backfill.spec.ts
@@ -474,7 +474,7 @@ test("a sweep can be scoped to one operation from the console, and the scope is
// change, no error, no clue. There is no <select> to lose a change on now, so
// the wait is the ordinary one: React is live on the section.
await page.goto("/operations/diarization");
- const lane = page.locator('section[data-lane="backfill"]');
+ const lane = page.locator('section[data-sweep-lane="backfill"]');
await expect(lane).toHaveAttribute("data-hydrated", "true", {
timeout: 30_000,
});
@@ -769,7 +769,7 @@ test("the diarization page shows the channel and both populations", async ({
await generateReport(page, SLUG);
await page.goto("/operations/diarization");
- const lane = page.locator('section[data-lane="backfill"]');
+ const lane = page.locator('section[data-sweep-lane="backfill"]');
await expect(lane).toBeVisible();
// The channel is a row in the plan (the fixture has a snapshot, so it lands
// in `working` rather than in the unknown-channel count).
@@ -849,7 +849,7 @@ test("an operation page holds the same lane the dashboard does", async ({
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 lane = page.locator('section[data-sweep-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.