Archilyzer · Source

archilyzer

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

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:
Meditor/app/api/auto-queue/control/route.ts | 25+++++++++++++++++++++----
Meditor/app/operations/[id]/page.tsx | 21++++++++++++++-------
Meditor/app/operations/components/OperationDetail.tsx | 46+++++++++++++++++++++++++++++++++-------------
Meditor/app/operations/components/PolicyTreeEditor.tsx | 20++++++++++++++++++--
Meditor/app/operations/components/dispatch.ts | 33+++++++++++++++++++++++++++++++++
Meditor/e2e/attribution.spec.ts | 4++--
Meditor/e2e/auto-queue.spec.ts | 6+++---
Meditor/e2e/backfill.spec.ts | 6+++---
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.