Archilyzer · Source

archilyzer

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

commit ac2e1e09a4c95e9466c5cdfc71fad1fec11e85bf
parent 30a5ac502d0f68b127873fae6f23c4b59c7ca8bd
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  7 Sep 2026 19:03:45 -0400

common: four lanes in the model, and one of them stops being a filter

Slice 1.1 of plans/one-core-phase-1.md. `AutoQueueKind` widens from the two
runner kinds to the four lanes `lib/pauseGates.ts` already named, exported as
`LANES`; `PauseLane` becomes an alias of it, so the two id spaces cannot drift.
The digest and backfill lanes get a policy, a tree, a status entry and a state
block — and no executor. Nothing dispatches off them, no live number moves.

`retainLeaves` is DELETED, and that is the shape of the change rather than a
tidy-up. It existed because one tree could hold leaves belonging to two
dispatchers, so each had to zero the other's after the draw — a second pass that
had to be remembered at three call sites, and handing a digest candidate to
whisper if it was not. Now the lane IS the dispatcher and the draw is
lane-scoped: `bucketsForKind` returns [] for the operation lanes, the new
`operationsForLane(lane, settings)` says what a lane may draw from
`snapshot.backfill[op].ids` (off the queue key, and off `laneFor` before `lane`,
so `digest.remoteEnabled` does not empty the digest lane), and `buildChannelWork`
projects only the intersection. A leaf asking for anything else finds no list
and comes back empty by construction.

The two new lanes default `enabled: false`, root `strict` with one `{type:"all"}`
leaf — a runner lane's absent root still means the empty tree it has always
meant. `AutoQueueOrder` gains "cheapest" and the digest lane defaults to it; the
comparator arrives with the runner in 1.2, and until then the order select
offers the three date-keyed orders it always has.

Editor: the status payload is keyed by LANES, the ladder's leaf gains an
operation select (drawn only for a lane carrying more than one) and hides the
bucket select for a lane carrying none. `startAutoRunnersIfEnabled` still names
the two lanes that have an executor.

Numbers: none move. `plans/tools/phase1-numbers.ts` over the live 68 snapshots
and settings.json diffs empty before and after.

common 888 tests, mcp 205, `next build` clean, tsc clean in six packages, editor e2e
511 passed / 0 failed / 24.4 min from a worktree of this commit.

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

Diffstat:
Mcommon/controller/arbiter.ts | 12++++++------
Mcommon/controller/autoRunner.ts | 72+++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcommon/jobs/autoQueuePolicy.test.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/jobs/autoQueuePolicy.ts | 170++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcommon/jobs/autoQueueState.test.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/jobs/autoQueueState.ts | 33++++++++++++++++-----------------
Mcommon/lib/autoQueueTypes.ts | 43+++++++++++++++++++++++++++++++++----------
Mcommon/lib/operations.test.ts | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/lib/operations.ts | 37+++++++++++++++++++++++++++++++++++++
Mcommon/lib/pauseGates.ts | 12++++++------
Meditor/app/operations/[id]/page.tsx | 31++++++++++++++++++++++++++-----
Meditor/app/operations/components/ClaimLadder.tsx | 3+++
Meditor/app/operations/components/LadderRung.tsx | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Meditor/app/operations/components/OperationDetail.tsx | 7+++++++
Meditor/app/operations/components/OrderReach.tsx | 10++++++++--
Meditor/app/operations/components/PolicyTreeEditor.tsx | 3+++
Meditor/app/operations/components/RunnerOperationView.tsx | 5+++++
Meditor/app/operations/components/dispatch.ts | 17++++++++++++++++-
Meditor/app/operations/lanes.ts | 10++++++----
Meditor/app/operations/status.ts | 41+++++++++++++++++++++++++++++------------
Mplans/one-core-phase-1.md | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/one-core.md | 79+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
22 files changed, 742 insertions(+), 211 deletions(-)

diff --git a/common/controller/arbiter.ts b/common/controller/arbiter.ts @@ -41,7 +41,6 @@ import { drainStream } from "../jobs/drainStream"; import { buildPendingByLeaf, flattenLeaves, - retainLeaves, type AutoQueueGroup, type ChannelWork, } from "../jobs/autoQueuePolicy"; @@ -129,11 +128,12 @@ export function planArbiterUnits( // only, and a bucket leaf with no explicit bucket would otherwise claim ids // out of a union it is about to be filtered out of anyway. Claiming still // dedups correctly because the claim key carries the operation. - const pending = retainLeaves( - buildPendingByLeaf(root, channels, []), - root, - "operations", - ); + // + // No second filtering pass: buildArbiterChannelWork projects `buckets: {}`, + // so a bucket leaf finds no list here whatever it names, and the loop below + // skips every leaf with no `match.operation` anyway. retainLeaves was doing + // both of those a third time. + const pending = buildPendingByLeaf(root, channels, []); for (const leaf of flattenLeaves(root)) { const operation = leaf.match.operation; if (!operation) continue; diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts @@ -22,11 +22,11 @@ import { type WorkPick, buildPendingByLeaf, flattenLeaves, - retainLeaves, selectNextWork, defaultBucketsForPolicy, selectableBucketsForKind, } from "../jobs/autoQueuePolicy"; +import { operationsForLane } from "../lib/operations"; import { type RecencyKey, buildRecencyKeys, @@ -274,6 +274,29 @@ export function operationsNamedBy(root: AutoQueueGroup): string[] { return [...out]; } +// WHAT THIS LANE MAY PROJECT: the operations its tree names, INTERSECTED with +// the operations the lane actually dispatches. +// +// The intersection is the safety rail retainLeaves used to be. A leaf naming +// `digest` inside the auto-transcribe tree is legal to write and always has +// been; before the lanes existed it was projected and then zeroed by a second +// pass, and forgetting that pass handed a digest candidate to whisper. Now it +// is never projected at all, so the leaf draws nothing and the wrong engine +// cannot be reached from the wrong tree. +// +// It stays an INTERSECTION rather than "everything the lane dispatches" because +// projection costs real memory: one channel's `digest.ids` is up to 11,329 +// strings, and this runs on a three-second status poll. Slice 1.2's `next()` +// widens it to the lane's default draw for the lanes that have an executor. +function laneOperationIds(kind: AutoQueueKind, root: AutoQueueGroup): string[] { + const named = operationsNamedBy(root); + if (named.length === 0) return []; + const dispatched = new Set( + operationsForLane(kind, getSettings()).map((op) => op.id), + ); + return named.filter((id) => dispatched.has(id)); +} + // The policy's `order`, defaulted for settings files written before the field // existed. One place, so the runner and the status page can never disagree about // what an absent field means. @@ -380,7 +403,7 @@ export async function computeLeafPending( paths, kind, meta, - operationsNamedBy(policy.root), + laneOperationIds(kind, policy.root), ); const { compare, keys } = await recencyOrdering( kind, @@ -389,19 +412,15 @@ export async function computeLeafPending( meta, channels, ); - // BUCKET LEAVES ONLY. A leaf naming an operation belongs to the arbiter, and - // counting its work here would report a digest backlog as a transcription - // one; DISPATCHING it would hand a digest candidate to whisper. See - // retainLeaves. - const pending = retainLeaves( - buildPendingByLeaf( - policy.root, - channels, - defaultBucketsForPolicy(kind, policy), - compare ? { compare } : undefined, - ), + // THE LANE'S OWN PROJECTION IS THE FILTER. `channels` above carries only the + // buckets and operations THIS lane dispatches (see laneOperationIds), so a + // leaf naming anything else finds no list and comes back empty — the zero + // retainLeaves used to apply afterwards, reached by construction. + const pending = buildPendingByLeaf( policy.root, - "buckets", + channels, + defaultBucketsForPolicy(kind, policy), + compare ? { compare } : undefined, ); // A video already in flight is not "next up" — drop the live runner's set // before asking the policy, exactly as next() does. @@ -699,7 +718,7 @@ async function runLoop( paths, kind, metaCache, - operationsNamedBy(policy.root), + laneOperationIds(kind, policy.root), ); // Re-derived each iteration from the freshly-read policy, like `enabled`, so // toggling the replace-auto-captions lane (or the ordering) takes effect @@ -712,18 +731,14 @@ async function runLoop( metaCache, channels, ); - // BUCKET LEAVES ONLY — see the note in computeLeafPending. This is the - // dispatch path, so it is the one where getting it wrong runs the wrong + // THE LANE'S OWN PROJECTION IS THE FILTER — see computeLeafPending. This is + // the dispatch path, so it is the one where getting it wrong runs the wrong // engine on the wrong video. - const pending = retainLeaves( - buildPendingByLeaf( - policy.root, - channels, - defaultBucketsForPolicy(kind, policy), - compare ? { compare } : undefined, - ), + const pending = buildPendingByLeaf( policy.root, - "buckets", + channels, + defaultBucketsForPolicy(kind, policy), + compare ? { compare } : undefined, ); const exclude = new Set<string>([...live.inFlight.keys(), ...completed]); removeIds(pending, exclude); @@ -1168,6 +1183,13 @@ export async function startAutoRunner( // Start every enabled runner. Called from the editor instrumentation hook at // server startup. +// +// NAMED, NOT ITERATED OVER LANES, and that is the whole safety story of slice +// 1.1. The digest and backfill lanes exist in the model — a policy, a tree, a +// gate, a status entry — and have no executor: `run()` below knows how to +// transcribe and how to download and nothing else. Iterating LANES here would +// start two runners that would pick a video and then not know what to do with +// it. They join this list in slice 1.2, when the executor does. export async function startAutoRunnersIfEnabled( paths: Paths = getPaths(), ): Promise<void> { diff --git a/common/jobs/autoQueuePolicy.test.ts b/common/jobs/autoQueuePolicy.test.ts @@ -2,17 +2,21 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { type AutoQueueGroup, + type AutoQueueLeaf, type AutoQueueRuntime, type ChannelWork, buildPendingByLeaf, bucketsForKind, defaultAutoQueue, defaultBucketsForPolicy, + isGroup, + optInBucketsForKind, selectableBucketsForKind, emptyAutoQueueRuntime, flattenLeaves, policyDrawsBucket, sanitizeAutoQueue, + sanitizeAutoQueueOrder, selectNextWork, } from "./autoQueuePolicy"; @@ -523,6 +527,81 @@ test("a leaf can target an opt-in bucket without the runner-wide switch", () => assert.deepEqual(pending.rest, ["c1", "h1"]); }); +// --- the four lanes --------------------------------------------------------- + +test("sanitizeAutoQueue: every lane is keyed, even ones no file names", () => { + // Every settings.json in existence carries exactly two lanes. + const s = sanitizeAutoQueue({ + transcription: { enabled: true }, + download: { enabled: true }, + }); + assert.deepEqual(Object.keys(s).sort(), [ + "backfill", + "digest", + "download", + "transcription", + ]); +}); + +test("sanitizeAutoQueue: the new lanes default off, strict, one catch-all leaf", () => { + for (const lane of ["digest", "backfill"] as const) { + const p = sanitizeAutoQueue({})[lane]; + assert.equal(p.enabled, false, `${lane} must default OFF`); + assert.equal(p.root.mode, "strict"); + assert.equal(p.root.children.length, 1); + const leaf = p.root.children[0]; + assert.ok(!isGroup(leaf)); + assert.deepEqual((leaf as AutoQueueLeaf).match, { type: "all" }); + } +}); + +test("sanitizeAutoQueue: the runner lanes keep their EMPTY default root", () => { + // A tree that dispatches nothing is what an absent runner policy has always + // meant, and the new lanes' catch-all leaf must not leak into it. + for (const lane of ["transcription", "download"] as const) { + assert.equal(sanitizeAutoQueue({})[lane].root.children.length, 0); + assert.equal(defaultAutoQueue()[lane].root.children.length, 0); + } +}); + +test("sanitizeAutoQueue: the digest lane defaults to cheapest, the rest to listed", () => { + const s = sanitizeAutoQueue({}); + assert.equal(s.digest.order, "cheapest"); + assert.equal(s.backfill.order, "listed"); + assert.equal(s.transcription.order, "listed"); + assert.equal(s.download.order, "listed"); + // An explicit stored order still wins over the lane default. + assert.equal(sanitizeAutoQueue({ digest: { order: "newest" } }).digest.order, "newest"); + // And a garbage one falls back to "listed", not to the lane default — + // sanitizeAutoQueueOrder is the one coercion and it does not know lanes. + assert.equal(sanitizeAutoQueue({ digest: { order: "soonest" } }).digest.order, "listed"); +}); + +test("sanitizeAutoQueueOrder accepts cheapest", () => { + assert.equal(sanitizeAutoQueueOrder("cheapest"), "cheapest"); + assert.equal(sanitizeAutoQueueOrder("cheap"), "listed"); +}); + +test("sanitizeAutoQueue: a stored root on a new lane replaces the default leaf", () => { + const s = sanitizeAutoQueue({ + digest: { enabled: true, root: { mode: "strict", children: [] } }, + }); + assert.equal(s.digest.enabled, true); + assert.equal(s.digest.root.children.length, 0); +}); + +test("bucketsForKind: the operation lanes have no buckets", () => { + for (const lane of ["digest", "backfill"] as const) { + assert.deepEqual([...bucketsForKind(lane)], []); + assert.deepEqual([...optInBucketsForKind(lane)], []); + assert.deepEqual([...selectableBucketsForKind(lane)], []); + assert.deepEqual( + [...defaultBucketsForPolicy(lane, { replaceAutoSubs: true })], + [], + ); + } +}); + test("sanitizeAutoQueue defaults replaceAutoSubs to false", () => { assert.equal(defaultAutoQueue().transcription.replaceAutoSubs, false); assert.equal(sanitizeAutoQueue({}).download.replaceAutoSubs, false); diff --git a/common/jobs/autoQueuePolicy.ts b/common/jobs/autoQueuePolicy.ts @@ -5,6 +5,7 @@ import type { Platform } from "../lib/platform"; // here so every existing `from "../jobs/autoQueuePolicy"` import still resolves. import type { AutoQueueGroup, + AutoQueueKind, AutoQueueLeaf, AutoQueueMatch, AutoQueueMatchType, @@ -15,9 +16,10 @@ import type { AutoQueueReach, AutoQueueSettings, } from "../lib/autoQueueTypes"; -import { isGroup } from "../lib/autoQueueTypes"; +import { LANES, isGroup } from "../lib/autoQueueTypes"; export type { AutoQueueGroup, + AutoQueueKind, AutoQueueLeaf, AutoQueueMatch, AutoQueueMatchType, @@ -28,7 +30,7 @@ export type { AutoQueueReach, AutoQueueSettings, }; -export { isGroup }; +export { LANES, isGroup }; // Pure, side-effect-free policy engine for the automatic priority queue. It @@ -56,6 +58,7 @@ export const AUTO_QUEUE_ORDERS: ReadonlyArray<AutoQueueOrder> = [ "listed", "newest", "oldest", + "cheapest", ]; @@ -75,7 +78,9 @@ export function sanitizeAutoQueueReach(value: unknown): AutoQueueReach { // backfill.order) and three copies of this line would eventually disagree about // what an absent field means. export function sanitizeAutoQueueOrder(value: unknown): AutoQueueOrder { - return value === "newest" || value === "oldest" ? value : "listed"; + return value === "newest" || value === "oldest" || value === "cheapest" + ? value + : "listed"; } @@ -99,27 +104,33 @@ export const DOWNLOAD_BUCKETS = ["partialDownloads", "undownloadedIds"] as const export const TRANSCRIBE_OPT_IN_BUCKETS = ["downloadedAutoSubsOnly"] as const; export const DOWNLOAD_OPT_IN_BUCKETS = ["autoSubsOnly"] as const; -// Local kind type — do NOT import AutoQueueKind from autoQueueState.ts, which -// already imports from this module (the reverse edge would be a cycle). -export function bucketsForKind( - kind: "transcription" | "download", -): readonly string[] { - return kind === "transcription" ? TRANSCRIBE_BUCKETS : DOWNLOAD_BUCKETS; +// NO BUCKETS IS A REAL ANSWER, not a gap. The digest and backfill lanes draw +// from operations, never from snapshot buckets — `snapshot.backfill[op].ids` is +// their work list — so an empty array here is what says "this lane has no +// bucket picker and no bucket projection", and every caller already handles an +// empty list the way it handles a leaf that matches nothing. +// +// (The type is AutoQueueKind now that it lives in lib/autoQueueTypes.ts, which +// this module already imports. The old note here warned against importing it +// from autoQueueState.ts, which imports FROM this module — that cycle is gone +// with the type.) +export function bucketsForKind(kind: AutoQueueKind): readonly string[] { + if (kind === "transcription") return TRANSCRIBE_BUCKETS; + if (kind === "download") return DOWNLOAD_BUCKETS; + return []; } -export function optInBucketsForKind( - kind: "transcription" | "download", -): readonly string[] { - return kind === "transcription" - ? TRANSCRIBE_OPT_IN_BUCKETS - : DOWNLOAD_OPT_IN_BUCKETS; +export function optInBucketsForKind(kind: AutoQueueKind): readonly string[] { + if (kind === "transcription") return TRANSCRIBE_OPT_IN_BUCKETS; + if (kind === "download") return DOWNLOAD_OPT_IN_BUCKETS; + return []; } // Everything a leaf may be pointed at for this kind: the default union plus the // opt-in buckets. Backs the editor's bucket dropdown AND the runner's snapshot // projection — a leaf can only find ids in a bucket the runner projected. export function selectableBucketsForKind( - kind: "transcription" | "download", + kind: AutoQueueKind, ): readonly string[] { return [...bucketsForKind(kind), ...optInBucketsForKind(kind)]; } @@ -128,7 +139,7 @@ export function selectableBucketsForKind( // bucketsForKind unless the policy opted into auto-caption replacement, which // appends the opt-in buckets at the tail so real work always drains first. export function defaultBucketsForPolicy( - kind: "transcription" | "download", + kind: AutoQueueKind, policy: Pick<AutoQueuePolicy, "replaceAutoSubs">, ): readonly string[] { return policy.replaceAutoSubs @@ -149,7 +160,7 @@ export function defaultBucketsForPolicy( // Pure, like everything else here: the caller supplies the channel's slug and // platform, never a snapshot. export function policyDrawsBucket( - kind: "transcription" | "download", + kind: AutoQueueKind, policy: Pick<AutoQueuePolicy, "replaceAutoSubs" | "root">, channel: Pick<ChannelWork, "slug" | "platform">, bucket: string, @@ -207,37 +218,20 @@ export type ChannelWork = { operations?: Record<string, string[]>; }; -// Split a pending map by WHO DISPATCHES IT. +// `retainLeaves(pending, root, "buckets" | "operations")` USED TO LIVE HERE, and +// its deletion is the point of the four-lane model rather than a tidy-up. // -// A leaf naming an operation is claimed by the arbiter; a leaf naming a bucket -// (or nothing) is claimed by the runner whose tree it sits in. They can share a -// tree — an operator may reasonably want "diarize cornbreadman before you -// transcribe anything else" expressed in one priority list — and without this -// they SHARE A DISPATCHER, which means the auto-transcribe runner picks a -// digest candidate off an operation leaf and hands it to whisper. +// It existed because one tree could hold leaves belonging to two dispatchers — +// a bucket leaf the runner owned and an operation leaf the arbiter owned — and +// each had to zero the other's before selecting, or auto-transcribe would hand +// a digest candidate to whisper. That was a filter applied AFTER the draw. // -// So each dispatcher zeroes the leaves that are not its own before selecting. -// Zeroed rather than removed: selectNextWork walks the TREE, and a leaf with an -// empty list is skipped by the same code path that skips a leaf with no work, -// so nothing new has to understand this. A leaf missing from the map entirely -// would instead read as `undefined` at `pending[node.id]?.length`, which is the -// same answer by luck rather than by construction. -export function retainLeaves( - pending: Record<string, string[]>, - root: AutoQueueNode, - want: "buckets" | "operations", -): Record<string, string[]> { - const out: Record<string, string[]> = {}; - const keep = new Set( - flattenLeaves(root) - .filter((l) => (want === "operations") === Boolean(l.match.operation)) - .map((l) => l.id), - ); - for (const [id, ids] of Object.entries(pending)) { - out[id] = keep.has(id) ? ids : []; - } - return out; -} +// Now the LANE is the dispatcher, and the draw itself is lane-scoped: a lane +// projects only the buckets bucketsForKind gives it and only the operations +// operationsForLane gives it (see controller/autoRunner.ts buildChannelWork). +// A leaf asking for anything else finds no list to draw from and comes back +// empty on its own — the same zero, reached by construction instead of by a +// second pass that had to be remembered at three call sites. // Pre-order (priority-order) flatten of every leaf in the tree. export function flattenLeaves(node: AutoQueueNode): AutoQueueLeaf[] { @@ -507,22 +501,60 @@ function sanitizeRoot(value: unknown, seen: Set<string>): AutoQueueGroup { return { id: node.id, mode: "strict", weight: 1, maxWorkers: null, children: [] }; } -export function defaultAutoQueuePolicy(): AutoQueuePolicy { +function emptyRoot(): AutoQueueGroup { + return { id: "root", mode: "strict", weight: 1, maxWorkers: null, children: [] }; +} + +// THE DEFAULT TREE FOR A LANE, and the two answers are different on purpose. +// +// The runner lanes default to an EMPTY root: they have shipped that way since +// the auto-queue existed, an empty tree dispatches nothing, and a settings file +// that omits a root must keep meaning exactly that. +// +// The digest and backfill lanes default to one catch-all leaf, because their +// work list is an operation's `ids` and a lane with no leaf at all could never +// draw it. The leaf is inert while `enabled` is false — which is how they +// default, and what keeps gate B (never enable the backfill lane against +// ~66,540 missingInput videos by accident) a decision an operator still has to +// take. +function defaultRootFor(lane: AutoQueueKind): AutoQueueGroup { + if (lane === "transcription" || lane === "download") return emptyRoot(); + return { + id: "root", + mode: "strict", + weight: 1, + maxWorkers: null, + children: [{ id: "all", match: { type: "all" }, weight: 1, maxWorkers: null }], + }; +} + +// The digest lane's historical ordering is SHORTEST-FIRST, and it is not +// cosmetic: a 12-minute video is one chunk and a four-hour stream is thirty, so +// draining the cheap end first is what makes a multi-week sweep show progress. +// Defaulting the lane to "cheapest" is how that survives the move from the +// sweep to the tree. The comparator arrives with the runner (slice 1.2); until +// then next() has none for this order and falls back to today's. +function defaultOrderFor(lane: AutoQueueKind): AutoQueueOrder { + return lane === "digest" ? "cheapest" : "listed"; +} + +export function defaultAutoQueuePolicy( + lane: AutoQueueKind = "transcription", +): AutoQueuePolicy { return { enabled: false, maxWorkers: null, replaceAutoSubs: false, - order: "listed", + order: defaultOrderFor(lane), snoozeUntil: null, - root: { id: "root", mode: "strict", weight: 1, maxWorkers: null, children: [] }, + root: defaultRootFor(lane), }; } export function defaultAutoQueue(): AutoQueueSettings { - return { - transcription: defaultAutoQueuePolicy(), - download: defaultAutoQueuePolicy(), - }; + return Object.fromEntries( + LANES.map((lane) => [lane, defaultAutoQueuePolicy(lane)]), + ) as AutoQueueSettings; } // A snooze that has already lapsed is not a snooze: normalizing it to null here @@ -535,7 +567,16 @@ function sanitizeSnooze(value: unknown): number | null { return at > Date.now() ? at : null; } -function sanitizePolicy(value: unknown): AutoQueuePolicy { +// A LANE THAT IS NOT IN THE FILE IS THE LANE'S DEFAULT, not an empty object. +// +// Every settings.json in existence carries exactly two lanes, so the digest and +// backfill blocks arrive `undefined` on every read until something writes them. +// Coercing that to `sanitizePolicy({})` would give them an empty root — a lane +// that can never draw anything even once an operator enables it — so the whole +// default policy is the fallback, and only the fields the file actually names +// override it. +function sanitizePolicy(value: unknown, lane: AutoQueueKind): AutoQueuePolicy { + if (value == null) return defaultAutoQueuePolicy(lane); const r = (value ?? {}) as Record<string, unknown>; const seen = new Set<string>(); return { @@ -544,18 +585,23 @@ function sanitizePolicy(value: unknown): AutoQueuePolicy { // Opt-in only: anything but an explicit `true` (including a missing field on // a pre-existing settings.json) leaves the lane off. replaceAutoSubs: r.replaceAutoSubs === true, - // Anything unrecognised (including a missing field) means today's behaviour. - order: sanitizeAutoQueueOrder(r.order), + // Anything unrecognised (including a missing field) means the lane's own + // default — "listed" for the runner lanes, "cheapest" for digest. + order: r.order === undefined + ? defaultOrderFor(lane) + : sanitizeAutoQueueOrder(r.order), snoozeUntil: sanitizeSnooze(r.snoozeUntil), - root: sanitizeRoot(r.root, seen), + root: + r.root === undefined + ? defaultRootFor(lane) + : sanitizeRoot(r.root, seen), }; } export function sanitizeAutoQueue(value: unknown): AutoQueueSettings { if (!value || typeof value !== "object") return defaultAutoQueue(); const r = value as Record<string, unknown>; - return { - transcription: sanitizePolicy(r.transcription), - download: sanitizePolicy(r.download), - }; + return Object.fromEntries( + LANES.map((lane) => [lane, sanitizePolicy(r[lane], lane)]), + ) as AutoQueueSettings; } diff --git a/common/jobs/autoQueueState.test.ts b/common/jobs/autoQueueState.test.ts @@ -69,3 +69,60 @@ test("corrupt platformBackoff field coerces to empty without throwing", async () assert.deepEqual(back.download.platformBackoff, {}); }); }); + +test("every lane is keyed, and a state file written before a lane existed coerces to empty", async () => { + await withPaths(async (paths) => { + // Exactly what `.auto-queue/state.json` holds in production today: the two + // runner lanes and nothing else. + // Write a valid state first so the .auto-queue dir exists. + await writeAutoQueueState(paths, emptyAutoQueueState()); + await writeFile( + paths.autoQueueStateFile, + JSON.stringify({ + transcription: { + runtime: { currentWeights: { "node-1": 3 } }, + picks: [], + platformBackoff: {}, + }, + download: { runtime: { currentWeights: {} }, picks: [], platformBackoff: {} }, + }), + ); + + const back = await readAutoQueueState(paths); + assert.deepEqual(Object.keys(back).sort(), [ + "backfill", + "digest", + "download", + "transcription", + ]); + // The lanes it does not name are EMPTY, not undefined: the status poll + // reads state[lane].picks for every lane the console draws. + assert.deepEqual(back.digest, { + runtime: { currentWeights: {} }, + picks: [], + platformBackoff: {}, + }); + assert.deepEqual(back.backfill.picks, []); + // And the lanes it does name are untouched. + assert.deepEqual(back.transcription.runtime.currentWeights, { "node-1": 3 }); + }); +}); + +test("a lane missing from an in-memory state object still writes", async () => { + await withPaths(async (paths) => { + // Defensive, like every coercion in this module: a hand-built state (or one + // deserialized from an older build) must not make the persist throw and + // lose the fairness memory of the lanes that ARE there. + const partial = { transcription: emptyAutoQueueState().transcription } as ReturnType< + typeof emptyAutoQueueState + >; + await writeAutoQueueState(paths, partial); + const back = await readAutoQueueState(paths); + assert.deepEqual(Object.keys(back).sort(), [ + "backfill", + "digest", + "download", + "transcription", + ]); + }); +}); diff --git a/common/jobs/autoQueueState.ts b/common/jobs/autoQueueState.ts @@ -23,7 +23,7 @@ import { // Defined in the model layer (lib/ may not import jobs/); re-exported here so // every existing `from "./autoQueueState"` import still resolves. export type { AutoQueueKind } from "../lib/autoQueueTypes"; -import type { AutoQueueKind } from "../lib/autoQueueTypes"; +import { LANES, type AutoQueueKind } from "../lib/autoQueueTypes"; // One recorded grant, newest-first, for the status panel. export type AutoQueuePick = { @@ -43,10 +43,12 @@ export type AutoQueueKindState = { platformBackoff: PlatformBackoffState; }; -export type AutoQueueState = { - transcription: AutoQueueKindState; - download: AutoQueueKindState; -}; +// KEYED BY EVERY LANE, including the two with no executor yet. A lane whose +// block is missing from the file coerces to empty state rather than to +// `undefined`: `state[kind].picks` is read on the status poll for every lane the +// console draws, and a lane added to LANES must not be able to make that throw +// against a state file written before it existed. +export type AutoQueueState = Record<AutoQueueKind, AutoQueueKindState>; export const AUTO_QUEUE_PICK_LOG_LIMIT = 50; @@ -55,10 +57,9 @@ export function emptyAutoQueueKindState(): AutoQueueKindState { } export function emptyAutoQueueState(): AutoQueueState { - return { - transcription: emptyAutoQueueKindState(), - download: emptyAutoQueueKindState(), - }; + return Object.fromEntries( + LANES.map((lane) => [lane, emptyAutoQueueKindState()]), + ) as AutoQueueState; } function coerceRuntime(value: unknown): AutoQueueRuntime { @@ -123,10 +124,9 @@ export async function readAutoQueueState(paths: Paths): Promise<AutoQueueState> } if (!raw || typeof raw !== "object") return emptyAutoQueueState(); const r = raw as Record<string, unknown>; - return { - transcription: coerceKindState(r.transcription), - download: coerceKindState(r.download), - }; + return Object.fromEntries( + LANES.map((lane) => [lane, coerceKindState(r[lane])]), + ) as AutoQueueState; } // Write the state atomically (tmp + rename), creating the .auto-queue dir on @@ -140,10 +140,9 @@ export async function writeAutoQueueState( picks: k.picks.slice(0, AUTO_QUEUE_PICK_LOG_LIMIT), platformBackoff: k.platformBackoff ?? {}, }); - const out: AutoQueueState = { - transcription: trim(state.transcription), - download: trim(state.download), - }; + const out = Object.fromEntries( + LANES.map((lane) => [lane, trim(state[lane] ?? emptyAutoQueueKindState())]), + ) as AutoQueueState; await mkdir(path.dirname(paths.autoQueueStateFile), { recursive: true }); const tmp = `${paths.autoQueueStateFile}.tmp-${process.pid}`; await writeFile(tmp, JSON.stringify(out, null, 2) + "\n"); diff --git a/common/lib/autoQueueTypes.ts b/common/lib/autoQueueTypes.ts @@ -25,7 +25,13 @@ export type AutoQueueMatchType = "channel" | "platform" | "all"; // // This deliberately does NOT reorder RULES: the tree is what expresses // priority. A newest-first archive is one catch-all rule with order "newest". -export type AutoQueueOrder = "listed" | "newest" | "oldest"; +// +// "cheapest" is the digest lane's historical shortest-first: the comparator is +// duration-keyed rather than date-keyed, and like the recency ones it is +// SUPPLIED BY THE RUNNER (the engine never reads a duration off disk). A lane +// whose runner supplies no comparator for it falls back to today's order — +// which is what makes it safe to name here before anything computes it. +export type AutoQueueOrder = "listed" | "newest" | "oldest" | "cheapest"; // How far an order REACHES. // @@ -125,14 +131,31 @@ export type AutoQueuePolicy = { root: AutoQueueGroup; }; -export type AutoQueueSettings = { - transcription: AutoQueuePolicy; - download: AutoQueuePolicy; -}; +export type AutoQueueSettings = Record<AutoQueueKind, AutoQueuePolicy>; + +// --- Lanes ------------------------------------------------------------------ -// --- Runner kinds ----------------------------------------------------------- +// THE FOUR LANES. A lane is the DISPATCH noun: a queue key, a policy tree, a +// runner, a pause gate and a console. The OPERATION is the work noun — a +// registry entry with a state per video — and a leaf in a lane's tree names +// channels and, where a lane carries more than one operation, an operation. +// +// It widened from the two runner kinds to the four names lib/pauseGates.ts +// already listed. The two id spaces were always the same space; they are now +// one type, and PauseLane is an alias. +// +// Here rather than in jobs/autoQueueState.ts (which persists their fairness +// memory) for the same reason as the tree above: pauseGates.ts and +// operations.ts name a lane without running one. +// +// ORDER IS LOAD-BEARING ONLY AS A DEFAULT: the two runner lanes come first so +// anything that iterates LANES for display keeps the order the console had +// before there were four of them. +export const LANES = [ + "transcription", + "download", + "digest", + "backfill", +] as const; -// The two auto-queue runners. Here rather than in jobs/autoQueueState.ts (which -// persists their fairness memory) for the same reason as the tree above: -// pauseGates.ts and operations.ts name a kind without running one. -export type AutoQueueKind = "transcription" | "download"; +export type AutoQueueKind = (typeof LANES)[number]; diff --git a/common/lib/operations.test.ts b/common/lib/operations.test.ts @@ -10,6 +10,7 @@ import { resolveBackfillLaneOperations, orderByDependencies, operationCatalog, + operationsForLane, operationCostBasis, operationGroup, operationLabel, @@ -37,7 +38,11 @@ import { import { CUES_FILE_VERSION } from "../controller/normalizeTranscript"; import { attributeOneVideo } from "../controller/attributeOne"; import { DIGEST_FILENAME, OLLAMA_DIGEST_APP_ID } from "./digest"; -import { BACKFILL_QUEUE, DIGEST_LOCAL_QUEUE } from "./queueKeys"; +import { + BACKFILL_QUEUE, + DIGEST_LOCAL_QUEUE, + DIGEST_REMOTE_QUEUE, +} from "./queueKeys"; import { ATTRIBUTION_FILENAME, ATTRIBUTION_PROMPT_VERSION, @@ -1585,3 +1590,60 @@ test("presentOperationWork says UNKNOWN rather than zero on an old snapshot", () 7, ); }); + +// --- operationsForLane ------------------------------------------------------ +// +// The registry's half of the four-lane model: which operations a LANE draws. +// Off the QUEUE KEY and off laneFor() before lane, because digest's key follows +// the engine it is configured with. + +function settingsWithBackfillLane(): SiteSettings { + const d = settingsWithDiarization(); + const a = settingsWithAttribution(); + return { ...d, attribution: a.attribution }; +} + +test("operationsForLane: the runner lanes draw no operations yet", () => { + // Not an opinion about the future: download and transcription are external + // entries with no state(), so there is no snapshot.backfill[op].ids for a + // leaf to draw. Slice 1.5 writes those entries. + const s = settingsWithBackfillLane(); + assert.deepEqual(operationsForLane("transcription", s), []); + assert.deepEqual(operationsForLane("download", s), []); +}); + +test("operationsForLane: the backfill lane is its three kinds, in dependency order", () => { + const s = settingsWithBackfillLane(); + assert.deepEqual( + operationsForLane("backfill", s).map((o) => o.id), + ["diarization", "attribution-diarized", "attribution-text"], + ); + // And it is EMPTY when the features are off — a lane with nothing enabled + // draws nothing rather than drawing a disabled kind's backlog. + assert.deepEqual(operationsForLane("backfill", defaultSiteSettings()), []); +}); + +test("operationsForLane: digest stays on the digest lane on BOTH its queues", () => { + // THE laneFor CASE. `digest.remoteEnabled` moves the operation from + // DIGEST_LOCAL_QUEUE to DIGEST_REMOTE_QUEUE. Both are the digest lane, so the + // answer must not change — a queue-key map naming only the local key would + // empty the lane the moment the metered engine was switched on. + const local = defaultSiteSettings(); + assert.equal(digestKind.laneFor!(local).queueKey, DIGEST_LOCAL_QUEUE); + assert.deepEqual( + operationsForLane("digest", local).map((o) => o.id), + ["digest"], + ); + + const remote: SiteSettings = { + ...local, + digest: { ...local.digest, remoteEnabled: true }, + }; + assert.equal(digestKind.laneFor!(remote).queueKey, DIGEST_REMOTE_QUEUE); + assert.deepEqual( + operationsForLane("digest", remote).map((o) => o.id), + ["digest"], + ); + // And the backfill lane never picks it up on either engine. + assert.deepEqual(operationsForLane("backfill", remote).map((o) => o.id), []); +}); diff --git a/common/lib/operations.ts b/common/lib/operations.ts @@ -1640,6 +1640,43 @@ export function backfillLaneEntriesOf<T>( return backfillLaneOperationEntriesOf(backfill).map(([, entry]) => entry); } +// THE OPERATIONS A LANE DISPATCHES, in dependency order. +// +// The other half of the four-lane model: `bucketsForKind` says what a lane's +// leaves may draw from the snapshot's buckets, and this says what they may draw +// from its operation entries. A leaf naming no operation draws ALL of them, the +// way a bucket-less leaf draws the default bucket union. +// +// OFF THE QUEUE KEY, and off `laneFor` before `lane`, for the same reason +// pauseGates.ts asks `runner` first: the queue key is what actually serializes +// the work, and digest's key depends on the engine it is configured with. With +// `digest.remoteEnabled` on, the digest operation moves from DIGEST_LOCAL_QUEUE +// to DIGEST_REMOTE_QUEUE — both of which are the digest LANE, which is why both +// are named here rather than one. +// +// The two runner lanes get [] today. They are not opinions about the future: +// download and transcription are EXTERNAL_OPERATIONS with no `state`, so there +// is no `snapshot.backfill[op].ids` for a leaf to draw. Slice 1.5 writes those +// entries, and this function starts answering for them then with no caller +// changing. +export function operationsForLane( + lane: AutoQueueKind, + settings: SiteSettings, +): Operation[] { + const keys = + lane === "digest" + ? [DIGEST_LOCAL_QUEUE, DIGEST_REMOTE_QUEUE] + : lane === "backfill" + ? [BACKFILL_QUEUE] + : []; + if (keys.length === 0) return []; + return orderByDependencies( + allOperations(settings).filter((op) => + keys.includes((op.laneFor?.(settings) ?? op.lane).queueKey), + ), + ); +} + // Resolve a caller-supplied list of kind ids against the registry. An empty or // absent list means "every enabled lane kind" — the sweep's scope default. // Unknown ids are dropped rather than throwing: a settings file may name a kind diff --git a/common/lib/pauseGates.ts b/common/lib/pauseGates.ts @@ -38,12 +38,12 @@ import { // The other three lanes have no live counterpart: their flag IS the gate, read // at dispatch, which is why they need no boot hook. -export type PauseLane = "transcription" | "download" | "digest" | "backfill"; - -// The union of the two id spaces that already exist: AutoQueueKind (the runners) -// and the editor's SweepLaneId (the sweeps). Asserted here for the first; -// operations/lanes.ts asserts the second, where SweepLaneId lives. -type _RunnerLanesArePauseLanes = AutoQueueKind extends PauseLane ? true : never; +// A PAUSE LANE IS A LANE. This was a hand-written union of four names while +// AutoQueueKind was a hand-written union of two; the four lanes in the model +// closed that gap, so this is now an alias and the two id spaces cannot drift. +// The name survives because thirteen call sites read as "which lane's gate", +// and because a gate is what this file is about. +export type PauseLane = AutoQueueKind; // WHICH LANE'S GATE HOLDS THIS OPERATION — the answer to "the operator is on // /operations/diarization and wants to hold it". diff --git a/editor/app/operations/[id]/page.tsx b/editor/app/operations/[id]/page.tsx @@ -4,9 +4,11 @@ import { getPaths } from "yt-dlp-transcript-common/lib/paths"; 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 { operationCatalog, operationLabel, + operationsForLane, type OperationSettingsBlock, } from "yt-dlp-transcript-common/lib/operations"; import { getSettings, type SiteSettings } from "yt-dlp-transcript-common/lib/settings"; @@ -37,14 +39,18 @@ import { getActionableSummary } from "../../lib/actionable/loadActionable"; export const dynamic = "force-dynamic"; -// Buckets each runner can draw from — derived from the policy engine's single +// Buckets each lane can draw from — derived from the policy engine's single // source of truth (selectableBucketsForKind) so the picker can't drift from the // runner. Includes the opt-in auto-caption buckets: a leaf that names one gets // per-channel opt-in without flipping the runner-wide switch. -const BUCKETS_BY_KIND = { - transcription: [...selectableBucketsForKind("transcription")], - download: [...selectableBucketsForKind("download")], -}; +// +// EMPTY IS A REAL ENTRY. The digest and backfill lanes draw from operations, +// never from buckets, so their lists are `[]` and LeafControls draws no bucket +// select at all — which is the honest UI for a lane where a bucket name would +// mean nothing. +const BUCKETS_BY_KIND = Object.fromEntries( + LANES.map((lane) => [lane, [...selectableBucketsForKind(lane)]]), +) as Record<AutoQueueKind, string[]>; // The job kinds that ARE this operation, for the running-jobs list. Only the // sweep-fed operations need one: a runner lane lists its own units in flight, @@ -195,6 +201,20 @@ export default async function OperationPage({ // their pages beside the pause and sweep they also share. The digest lane has // no equivalent — its lane facts (`sweepEnabled`, order and reach) live in the // digest block and the OrderReach control. + // THE OPERATIONS EACH LANE DISPATCHES, as ids, for the ladder's operation + // select. Server-side because it is a settings-dependent registry read + // (`digest.remoteEnabled` moves the digest operation between two queue keys + // that are both the digest lane), and the ladder is a client component. + // + // A lane with 0 or 1 operations draws no select: there is nothing to choose, + // and an operation leaf and a bucket-less leaf claim the same work. + const operationsByLane = Object.fromEntries( + LANES.map((lane) => [ + lane, + operationsForLane(lane, settings).map((o) => o.id), + ]), + ) as Record<AutoQueueKind, string[]>; + const laneSettings = laneId === "backfill" ? ( <LaneSettingsForm key="backfill-lane" initial={settings.backfill} /> @@ -258,6 +278,7 @@ export default async function OperationPage({ channels={channelOptions} platforms={[...PLATFORM_VALUES]} bucketsByKind={BUCKETS_BY_KIND} + operationsByLane={operationsByLane} activeJobs={activeJobs} runnerKind={runnerKind} laneId={laneId} diff --git a/editor/app/operations/components/ClaimLadder.tsx b/editor/app/operations/components/ClaimLadder.tsx @@ -15,6 +15,7 @@ export function ClaimLadder({ channels, platforms, buckets, + operations, data, ops, }: { @@ -22,6 +23,7 @@ export function ClaimLadder({ channels: Channel[]; platforms: string[]; buckets: string[]; + operations: string[]; data: RungData; ops: RungOps; }) { @@ -40,6 +42,7 @@ export function ClaimLadder({ channels={channels} platforms={platforms} buckets={buckets} + operations={operations} data={data} ops={ops} /> diff --git a/editor/app/operations/components/LadderRung.tsx b/editor/app/operations/components/LadderRung.tsx @@ -71,7 +71,13 @@ export function LadderRung(props: { siblingCount: number; channels: Channel[]; platforms: string[]; + // What THIS LANE projects, and both lists can legitimately be empty. A lane + // with no buckets (digest, backfill) draws no bucket select; a lane with no + // operations, or exactly one, draws no operation select — with one there is + // nothing to choose, and naming it claims exactly what a bucket-less leaf + // already claims. buckets: string[]; + operations: string[]; data: RungData; ops: RungOps; }) { @@ -393,10 +399,11 @@ function LeafControls(props: { channels: Channel[]; platforms: string[]; buckets: string[]; + operations: string[]; data: RungData; ops: RungOps; }) { - const { leaf, channels, platforms, buckets, data, ops } = props; + const { leaf, channels, platforms, buckets, operations, data, ops } = props; const type = leaf.match.type; const ordinal = data.leafIds.indexOf(leaf.id) + 1; return ( @@ -465,33 +472,78 @@ function LeafControls(props: { </select> )} - <label className="flex items-center gap-1 text-xs text-muted-foreground"> - bucket - {/* The option TEXT stays the raw bucket name: it is how an operator - names a bucket to the runner, and one spec asserts exactly one - option named `downloadedAutoSubsOnly` exists page-wide. The - plain-language gloss goes in the sentence below instead. */} - <select - value={leaf.match.bucket ?? ""} - aria-label="rule bucket" - onChange={(e) => - ops.update(leaf.id, (n) => { - const match = { ...(n as AutoQueueLeaf).match }; - if (e.target.value) match.bucket = e.target.value; - else delete match.bucket; - return { ...n, match }; - }) - } - className={SELECT_CLASS} - > - <option value="">all buckets (default)</option> - {buckets.map((b) => ( - <option key={b} value={b}> - {b} - </option> - ))} - </select> - </label> + {/* THE OPERATION SELECT, for a lane that carries more than one. + `operation` and `bucket` are two id spaces and a leaf may name at most + one (sanitizeMatch enforces it, operation winning), so setting either + one clears the other here rather than letting the sanitizer decide + silently on save. + + Native <select>, like every other control on this ladder: Radix + portals its options and unmounts them while closed, which would take + the page-wide option counts the e2e suite asserts to zero. */} + {operations.length > 1 && ( + <label className="flex items-center gap-1 text-xs text-muted-foreground"> + operation + <select + value={leaf.match.operation ?? ""} + aria-label="rule operation" + onChange={(e) => + ops.update(leaf.id, (n) => { + const match = { ...(n as AutoQueueLeaf).match }; + if (e.target.value) { + match.operation = e.target.value; + delete match.bucket; + } else { + delete match.operation; + } + return { ...n, match }; + }) + } + className={SELECT_CLASS} + > + <option value="">all operations (default)</option> + {operations.map((o) => ( + <option key={o} value={o}> + {o} + </option> + ))} + </select> + </label> + )} + + {buckets.length > 0 && ( + <label className="flex items-center gap-1 text-xs text-muted-foreground"> + bucket + {/* The option TEXT stays the raw bucket name: it is how an operator + names a bucket to the runner, and one spec asserts exactly one + option named `downloadedAutoSubsOnly` exists page-wide. The + plain-language gloss goes in the sentence below instead. */} + <select + value={leaf.match.bucket ?? ""} + aria-label="rule bucket" + onChange={(e) => + ops.update(leaf.id, (n) => { + const match = { ...(n as AutoQueueLeaf).match }; + if (e.target.value) { + match.bucket = e.target.value; + // The other half of the operation select's rule: at most one of + // the two id spaces, decided here rather than by the sanitizer. + delete match.operation; + } else delete match.bucket; + return { ...n, match }; + }) + } + className={SELECT_CLASS} + > + <option value="">all buckets (default)</option> + {buckets.map((b) => ( + <option key={b} value={b}> + {b} + </option> + ))} + </select> + </label> + )} <span className="text-xs text-muted-foreground"> {leafSentence(leaf, channels)} diff --git a/editor/app/operations/components/OperationDetail.tsx b/editor/app/operations/components/OperationDetail.tsx @@ -30,6 +30,7 @@ export function OperationDetail({ channels, platforms, bucketsByKind, + operationsByLane, activeJobs, runnerKind, laneId, @@ -45,6 +46,11 @@ export function OperationDetail({ channels: Channel[]; platforms: string[]; bucketsByKind: Record<AutoQueueKind, string[]>; + // The operation ids each lane dispatches, resolved on the SERVER off the + // registry (it is settings-dependent — `digest.remoteEnabled` moves the digest + // operation between two queue keys that are both the digest lane). The ladder + // offers a leaf an operation only for a lane carrying more than one. + operationsByLane: Record<AutoQueueKind, string[]>; // Jobs on this operation's queues, SSR-rendered. Empty renders nothing at // all (RunningJobsList returns null), so a quiet page carries no second // in-flight list beside the lane's own. @@ -111,6 +117,7 @@ export function OperationDetail({ channels={channels} platforms={platforms} buckets={bucketsByKind[runnerKind]} + operations={operationsByLane[runnerKind]} onRefresh={refresh} /> </> diff --git a/editor/app/operations/components/OrderReach.tsx b/editor/app/operations/components/OrderReach.tsx @@ -5,7 +5,7 @@ import type { AutoQueueOrder, AutoQueueReach, } from "yt-dlp-transcript-common/jobs/autoQueuePolicy"; -import { ORDER_LABEL, SELECT_CLASS } from "./dispatch"; +import { ORDER_LABEL, RECENCY_ORDERS, SELECT_CLASS } from "./dispatch"; // ORDER AND REACH, at the foot of the lane where the decision lives. // @@ -38,6 +38,11 @@ export function OrderReach({ // ("video order for auto-transcribe"). Optional because the sweep lanes have // no such contract and the visible <label> already names the control. orderAriaLabel, + // WHICH ORDERS THIS LANE OFFERS. Defaults to the three date-keyed ones, which + // is every order that existed before the lanes did — so no console gains an + // option it has no comparator for. A lane whose runner supplies one passes + // its own list. + orders = RECENCY_ORDERS, onChange, }: { order: AutoQueueOrder; @@ -50,6 +55,7 @@ export function OrderReach({ tradeoff: string | null; busy: boolean; orderAriaLabel?: string; + orders?: ReadonlyArray<AutoQueueOrder>; onChange: (next: { order: AutoQueueOrder; reach: AutoQueueReach }) => void; }) { const orderId = useId(); @@ -79,7 +85,7 @@ export function OrderReach({ }) } > - {(Object.keys(ORDER_LABEL) as AutoQueueOrder[]).map((o) => ( + {orders.map((o) => ( <option key={o} value={o}> {ORDER_LABEL[o]} </option> diff --git a/editor/app/operations/components/PolicyTreeEditor.tsx b/editor/app/operations/components/PolicyTreeEditor.tsx @@ -151,12 +151,14 @@ export function PolicyTreeEditor({ channels, platforms, buckets, + operations, }: { kind: AutoQueueKind; status: AutoQueueKindStatus; channels: Channel[]; platforms: string[]; buckets: string[]; + operations: string[]; }) { const [form, setForm] = useState<Form>(() => formOf(status)); // The last value we know is on disk. Everything dirty-related is a comparison @@ -268,6 +270,7 @@ export function PolicyTreeEditor({ channels={channels} platforms={platforms} buckets={buckets} + operations={operations} ops={ops} data={{ pendingByLeaf: status.pendingByLeaf, diff --git a/editor/app/operations/components/RunnerOperationView.tsx b/editor/app/operations/components/RunnerOperationView.tsx @@ -40,6 +40,7 @@ export function RunnerOperationView({ channels, platforms, buckets, + operations, onRefresh, }: { kind: AutoQueueKind; @@ -48,6 +49,9 @@ export function RunnerOperationView({ channels: Channel[]; platforms: string[]; buckets: string[]; + // The operation ids this lane dispatches. Empty for the two runner lanes + // today; the ladder draws a select only when there is more than one. + operations: string[]; onRefresh: () => Promise<void>; }) { const [busy, setBusy] = useState(false); @@ -121,6 +125,7 @@ export function RunnerOperationView({ channels={channels} platforms={platforms} buckets={buckets} + operations={operations} /> <RecentPicks status={status} /> diff --git a/editor/app/operations/components/dispatch.ts b/editor/app/operations/components/dispatch.ts @@ -73,13 +73,28 @@ export const ORDER_LABEL: Record<AutoQueueOrder, string> = { listed: "Listed order", newest: "Newest first", oldest: "Oldest first", + // The digest lane's shortest-first. Named by DURATION, not by date, which is + // why it is not a third arm of the recency axis. + cheapest: "Shortest first", }; +// THE ORDERS A DATE-KEYED LANE OFFERS — the three this control has always +// offered. "cheapest" is deliberately not here: its comparator is supplied by +// the lane's runner (slice 1.2), and a lane whose runner has none falls back to +// "listed", so offering it would be a control that silently does nothing. +export const RECENCY_ORDERS: ReadonlyArray<AutoQueueOrder> = [ + "listed", + "newest", + "oldest", +]; + export function orderTradeoff( kind: AutoQueueKind, order: AutoQueueOrder, ): string | null { - if (order === "listed") return null; + // 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; 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/app/operations/lanes.ts b/editor/app/operations/lanes.ts @@ -47,10 +47,12 @@ import { // have is the same four things every lane has — a state, a reason, work in // flight, and an order — and this is where those are read. // -// NO CLAIM LADDER IS RENDERED FOR THEM, deliberately. A policy tree for these -// lanes does not exist yet (settings.autoQueue has exactly two keys), and -// drawing an empty ladder would say "no rules match" where the truth is "rules -// are not how this lane is dispatched". It gets one when the arbiter does. +// NO CLAIM LADDER IS RENDERED FOR THEM YET. `settings.autoQueue` carries all +// four lanes since slice 1.1, so the tree now exists — but nothing dispatches +// off it until the runner does (slice 1.2), and drawing a ladder over a tree no +// dispatcher reads would say "these rules decide the order" where the truth is +// still "the sweep does". The ladder arrives with the runner, and this whole +// file goes with the sweeps in slice 1.3. export type SweepLaneId = "digest" | "backfill"; diff --git a/editor/app/operations/status.ts b/editor/app/operations/status.ts @@ -12,6 +12,7 @@ import { type AutoQueuePick, readAutoQueueState, } from "yt-dlp-transcript-common/jobs/autoQueueState"; +import { LANES } from "yt-dlp-transcript-common/lib/autoQueueTypes"; import type { AutoQueuePolicy } from "yt-dlp-transcript-common/jobs/autoQueuePolicy"; import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; import { isGateHeld } from "yt-dlp-transcript-common/lib/pauseGates"; @@ -63,13 +64,19 @@ export type AutoQueueKindStatus = { held: boolean; }; -export type AutoQueueStatusPayload = { - transcription: AutoQueueKindStatus; - download: AutoQueueKindStatus; - // The other two pipelines, plus the comparison rail's bands. On the SAME - // payload as the runners rather than a second endpoint, because the rail's - // whole purpose is that four lanes are read together — two polls would let - // the rail and the focused lane disagree about the same moment. +export type AutoQueueStatusPayload = Record< + AutoQueueKind, + AutoQueueKindStatus +> & { + // The sweep half of the digest and backfill pipelines, plus the comparison + // rail's bands. On the SAME payload as the runners rather than a second + // endpoint, because the rail's whole purpose is that four lanes are read + // together — two polls would let the rail and the focused lane disagree about + // the same moment. + // + // Every lane now has a `status[lane]` entry too. The two are not duplicates: + // this is what the SWEEPS are doing (armed, scoped, planned), and the entry + // is what the lane's TREE says. Slice 1.3 deletes this half. lanes: AutoQueueLanesPayload; }; @@ -100,18 +107,28 @@ async function buildKind(kind: AutoQueueKind): Promise<AutoQueueKindStatus> { nextUp: pending.nextUp, picks: state[kind].picks, cooldowns, + // THE ASYMMETRY IS TRANSCRIPTION'S ALONE, and it is not a special case for + // "the first lane": its hold is LIVE on the worker pool, while every other + // lane's flag IS its gate. See lib/pauseGates.ts. held: kind === "transcription" ? getWorkerPool().isPaused() - : isGateHeld(getSettings(), "download"), + : isGateHeld(getSettings(), kind), }; } +// ONE ENTRY PER LANE, off LANES rather than off two hand-written names — so a +// lane added to the model appears on this payload with no edit here, which is +// the whole point of the widened type. export async function buildAutoQueueStatusPayload(): Promise<AutoQueueStatusPayload> { - const [transcription, download, lanes] = await Promise.all([ - buildKind("transcription"), - buildKind("download"), + const [kinds, lanes] = await Promise.all([ + Promise.all(LANES.map((lane) => buildKind(lane))), buildAutoQueueLanes(), ]); - return { transcription, download, lanes }; + return { + ...(Object.fromEntries( + LANES.map((lane, i) => [lane, kinds[i]]), + ) as Record<AutoQueueKind, AutoQueueKindStatus>), + lanes, + }; } diff --git a/plans/one-core-phase-1.md b/plans/one-core-phase-1.md @@ -252,3 +252,63 @@ arm-from-the-console on the fixture corpus produces the same tree the migration 1.4 a pause from the dashboard, the widget and the runner page all flip one `held` key and the runner idles without stopping; after 1.5 `/channels` bands for download and transcription draw from the operation entries with no rendered number changed. + +## 1.1, as shipped + +`7f294df` (this plan) → `f762944` (the numbers script) → this commit (the slice), on +`one-core/phase-1` off `af2a360`. **Numbers: the before/after diff of +`plans/tools/phase1-numbers.ts` over the live corpus is EMPTY.** + +Verification: `tsc --noEmit` clean in common, editor, export, mcp, homepage and umtool; +`yt-dlp-transcript-common` 888 tests (876 before, +12 here), `yt-dlp-transcript-mcp` 205; +`next build` clean; full editor e2e from a `p1-e2e` worktree of the slice commit — +**511 passed, 0 failed, 24.4 min**, one worker behind the queue lock, matching Phase 0's +511/511. The worktree needed the same seed Phase 0 recorded: `export/public` holds only +the tracked static assets, so `build-index` + `compose-site` over a fixture corpus turns +the export webServer's 500 into a 200. + +**The one design change: `retainLeaves` is deleted, not de-moded.** The plan said it +"loses its mode: a leaf is retained when it draws anything from the lane's projection" — +and that sentence, taken literally, describes the identity function. `pending[leaf]` IS +what the leaf drew, so a post-hoc filter over it can only re-apply a rule the draw already +applied. So the rule moved INTO the draw: `buildChannelWork` projects only +`selectableBucketsForKind(lane)` and only `operationsNamedBy(root) ∩ operationsForLane(lane)`, +and a leaf naming anything else finds no list. Verified equivalent at all three old call +sites — the two runner ones (`operationsForLane` is `[]` for both, so an operation leaf +projects nothing, exactly what the `"buckets"` mode zeroed) and the arbiter's, where +`buildArbiterChannelWork` already projects `buckets: {}` and `planArbiterUnits` already +skips every leaf with no `match.operation`. + +Shipped as planned: `LANES` + the four-member `AutoQueueKind` (`PauseLane` an alias); +`digest`/`backfill` policies defaulting `enabled:false`, root `strict`, one `{type:"all"}` +leaf, with an ABSENT lane block falling back to the whole default policy rather than to +`sanitizePolicy({})` — a runner lane's absent root still means the empty tree it has +always meant; `AutoQueueOrder` gains `"cheapest"` with the digest lane defaulting to it; +`bucketsForKind`/`optInBucketsForKind`/`selectableBucketsForKind` return `[]` for the +operation lanes; `operationsForLane(lane, settings)` in `lib/operations.ts`, off the queue +key and off `laneFor` before `lane`; `autoQueueState` keyed by `LANES` with missing lanes +coerced to empty on read AND on write; `/api/auto-queue/status` `buildKind` iterates +`LANES`; `LeafControls` gains an operation select (drawn only above one operation) and +hides the bucket select for a lane with none; `startAutoRunnersIfEnabled` still names the +two lanes that have an executor. + +Two smaller divergences, both to keep "no UI text changes for the two existing lanes" +true: + +- **`OrderReach` gained an `orders` prop** defaulting to the three date-keyed orders. + `Object.keys(ORDER_LABEL)` would have put a fourth option on every existing order + select, for an order whose comparator does not exist until 1.2. The digest lane passes + its own list when the runner supplies the comparator. +- **`/api/auto-queue/control` still refuses a lane that is not `transcription` or + `download`.** Widening it would expose a Start button for a lane the runner cannot + execute. It widens in 1.2 with `run()`. + +**For 1.2.** `buildChannelWork`'s `operations` argument is now `laneOperationIds(kind, +root)` — an INTERSECTION, so the digest lane projects nothing while its tree is the +default catch-all leaf. `next()` drawing the lane's default operation union is the change +that makes the lane live, and it is also where the projection cost lands: one channel's +`digest.ids` is up to 11,329 strings and the status payload now folds four lanes on a +three-second poll, so widen the projection in `run()`'s loop rather than in +`computeLeafPending` if the two need to differ. `AutoQueueStatusPayload` is +`Record<AutoQueueKind, AutoQueueKindStatus> & { lanes }`, so `data.digest` / +`data.backfill` are already on the wire. diff --git a/plans/one-core.md b/plans/one-core.md @@ -181,41 +181,56 @@ Makes the later deletions safe and cheap. four manifest versions, `SUMMARIES_PAGE_SIZE`, layer names) — the three files re-export from it, nothing changes on the wire. -### Phase 1 — Dispatch: one scheduler (4–5 slices) +### Phase 1 — Dispatch: one scheduler (5 slices) Finishes `unified-operations-model.md` steps 5 and 6 and deletes what they retire. This is the phase with live numbers on a 78,000-video corpus; every slice is measured -before/after with offline `tsx` over the real corpus, never a second editor. - -1. **Persist the arbiter** (`.arbiter/state.json`, same shape as - `jobs/autoQueueState.ts`), and let `instrumentation.ts` resume it. Precondition named by - the design doc: the arbiter has run in production once — this slice is the run. -2. **Pause is node state** (step 5's remainder). `lib/pauseGates.ts` keeps `isGateHeld` / - `withGateHeld` as the only API; storage moves from four settings fields to `held` on the - tree root that dispatches the operation. The four fields stay readable for one release - as a migration read, then are deleted with their sanitizers. -3. **Retire `sweepEnabled` / `sweepKinds` / `sweepChannels` / `backfill.weight`** into tree - leaves carrying `operation`, `posture: "passive"`, `order` (the shape the design doc - already specifies). `lib/sweepPlan.ts` becomes the one planner; - `controller/sweepPreview.ts` and `sweepRecency.ts` fold into it (recency stays cached - through `controller/recencyIndex.ts`). -4. **Delete `digestSweep.ts`, `backfillSweep.ts`, and `autoRunner.ts`'s dispatch loop.** The - arbiter dispatches download and transcription too (they are catalogued operations - already — `EXTERNAL_OPERATIONS`). `arbiterWork.buildChannelWork` is the one projection; - autoRunner's copy goes. `digestBatch.ts` + `backfillBatch.ts` → one `operationBatch.ts` - parameterised by the descriptor (`inputs / outputs / applyResult` already exist per - kind). The GPU idle-only rule keys off `posture`, not off the presence of `laneFor` - (FACTS.md records that trap; the test that pins it moves with the rule). -5. **One work list in the snapshot.** Download and transcription gain `state()` in the - registry so `snapshot.backfill[op]` (typed as `operations`, disk key unchanged) covers - every operation. A slice-long assertion that `buckets.undownloaded ≡ operations.download.ids` - and `buckets.untranscribed ≡ operations.transcription.ids` on all 68 snapshots runs - before the bucket read path is deleted; the storage/cleanup buckets - (`wrongFormatAudio`, cleanup sizes) remain buckets — they are not operations. - -Deleted by the end: two sweeps, one runner loop, one batch, two planners, four settings -fields with their sanitizers and forms, `SweepLane`/`ArbiterBar`'s sweep-vs-arbiter -distinction on `/operations`. Kept verbatim: the seven "must not be lost" invariants. +before/after with offline `tsx` over the real corpus (`plans/tools/phase1-numbers.ts`), +never a second editor. + +**Three surveys on 2026-09-07 changed one word of the plan below: the arbiter is not +persisted, it is DELETED.** Zero `operations-arbiter` records among 1,305 production jobs, +no `.arbiter/` state, no boot resume, and it cannot dispatch download or transcription — +the two lanes carrying 100% of live dispatch. The auto-queue runner is the proven loop +(persisted picks, boot resume, worker slots, platform backoff, an e2e-pinned console), so +the RUNNER generalizes to four lanes and the arbiter goes with the sweeps. + +The slice-level plan, with the line numbers and the measurements, is +[`one-core-phase-1.md`](one-core-phase-1.md). + +1. **1.1 — Four lanes in the model.** `AutoQueueKind` widens to `transcription | + download | digest | backfill` and is exported as `LANES`; `PauseLane` becomes an alias; + the two new lanes get a policy, a tree, a status entry and a state block. No dispatch, + no behaviour, no number moves. +2. **1.2 — The runner runs operations.** `digestBatch.ts` + `backfillBatch.ts` → one + `controller/operationBatch.ts` parameterised by the descriptor; `autoRunner.run` + branches on lane; the GPU idle-only rule keys off `contendsFor`, not off the presence + of `laneFor` (FACTS.md records that trap; the test that pins it moves with the rule). + Ends with the digest lane driving a production pass — the "has run in production once" + precondition every retirement below stands on. +3. **1.3 — Sweeps and arbiter retire.** Arming becomes tree authoring: one + `armLaneAction(lane, {operations, channels})` writes `autoQueue[lane].root`. The ten + `sweepEnabled` / `sweepKinds` / `sweepChannels` / `order` / `reach` / `weight` fields + migrate on read, then are deleted with their sanitizers and forms. `digestSweep.ts`, + `backfillSweep.ts`, `sweepPreview.ts`, `sweepRecency.ts`, `lib/sweepPlan.ts`, + `arbiter.ts` and `arbiterWork.ts` go. +4. **1.4 — Pause is lane state.** `AutoQueuePolicy.held`, behind the unchanged + `isGateHeld` / `withGateHeld` API, with the four legacy fields read as a migration for + one release. All 13 callers unchanged; the transcription intent-vs-pool asymmetry stays. +5. **1.5 — One work list per lane in the snapshot.** `generateChannelSnapshot` writes + `backfill.download` and `backfill.transcription` entries whose `ids` are the union + `defaultBucketsForPolicy` draws today, so `snapshot.backfill[op]` (typed `operations`, + disk key unchanged) covers every operation. **There is no `buckets.undownloaded` or + `buckets.untranscribed`** — the earlier draft of this step named fields that do not + exist; download's list is `snapshot.undownloadedIds` ∪ `buckets.partialDownloads` and + transcription's is `downloadedNoTranscript` ∪ `failedListed`. The storage/cleanup + buckets (`wrongFormatAudio`, cleanup sizes) remain buckets — they are not operations. + +Deleted by the end: two sweeps, the arbiter and its work projection, one batch, two +planners, ten settings fields with their sanitizers and forms, and +`SweepLane`/`ArbiterBar` from `/operations`. `autoRunner.ts` is GENERALIZED, not deleted — +it is the loop everything else folds into. Kept verbatim: the seven "must not be lost" +invariants. ### Phase 2 — The contract: one `ArchiveReader` (3 slices)