Archilyzer · Source

archilyzer

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

commit eeaebd088141d14513df5661f83f36f6ccd9263a
parent 00f8679f25645b0fe6015379addfa2eb96476e1f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 12:06:25 -0400

Merge channel-priority/s4 — the focus banner and the read-only tree

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

Diffstat:
Meditor/app/channels/components/FocusBanner.tsx | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Aeditor/app/operations/channelPriorityView.ts | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/operations/components/ClaimLadder.tsx | 12++++++++++--
Meditor/app/operations/components/HowPriorityWorks.tsx | 39++++++++++++++++++++++++++++++++++++++-
Meditor/app/operations/components/LadderRung.tsx | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Meditor/app/operations/components/OperationDetail.tsx | 19++++++++++++++++++-
Meditor/app/operations/components/PolicyTreeEditor.tsx | 37+++++++++++++++++++++++++++++++++++++
Meditor/app/operations/status.ts | 51++++++++++++++++++++++++++++++++++++++++++++++++---
Aeditor/e2e/focus-banner.spec.ts | 326+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 748 insertions(+), 33 deletions(-)

diff --git a/editor/app/channels/components/FocusBanner.tsx b/editor/app/channels/components/FocusBanner.tsx @@ -1,25 +1,31 @@ -// THE FOCUS BANNER — a STUB, filled in by slice S4. +// THE FOCUS BANNER — what a focus is doing, said once, wherever dispatch is +// being watched. // -// It exists at S0 for the same reason ChannelTierSelect.tsx does: S3 and S4 -// then each edit one file the other does not. It already honours the one rule -// S4 is written around — RENDER NOTHING WHEN NOTHING IS FOCUSED — so it is -// green on /channels before S3 exists and green on the lane consoles before -// anything writes a focus. +// It answers the one question a focus creates and nothing else on the page can: +// "why is only jeralyzer moving?" A lane console can show a runner running, a +// ladder full of rungs and a pending count of thousands and still not say that +// three quarters of those rungs are being held behind a group at the top. // -// What S4 makes of it: `Focus: <name> (N channels) · <units> pending in this -// lane · M channels held`, plus an "End focus" button posting `endFocusAction()`. -// The numbers come from `computeLeafPending`, which the status panel already -// computes, so the banner is one sum over `counts` and no new read. +// NOT A `role="status"`, and not a <section>. It sits immediately above +// RunnerOperationView's `<section data-lane>`, whose structural contract +// reserves `role="status"` for "Saved." and forbids a nested <section> — and +// this is a persistent statement of state, not a live region announcing a +// change. `data-focus-banner` is how a test finds it. // -// WHERE IT GOES on the lane consoles: OperationDetail.tsx, between -// HowPriorityWorks and RunnerOperationView — i.e. OUTSIDE the `<section -// data-lane>` that RunnerOperationView opens, whose contract comment reserves -// `role="status"` and forbids a nested `<section>`. +// NOT AN AutoRunnerIdleReason either. A lane whose focus group holds the rest is +// not idle, it is dispatching focus work; "M channels held" is a DISPLAY fact, +// computed from counts the status panel already had — one pass over +// `pendingByLeaf` keyed by compiled leaf id, and no new read. // -// It is NOT a new AutoRunnerIdleReason. A lane whose focus group holds the rest -// is not idle, it is dispatching focus work; "M channels held" is a display -// fact. +// NO WRITER OF ITS OWN. Ending a focus writes `settings.channelPriority`, and +// that document has exactly one writer (`saveChannelPriorityAction`, S3). This +// component therefore LINKS to /channels rather than posting an action of its +// own — a second writer for one button is the thing the model was built to +// avoid. `endFocus` is the slot a page that already holds that writer drops its +// own control into; the link is what every other placement gets. +import type { ReactNode } from "react"; +import Link from "next/link"; import type { AutoQueueKind } from "yt-dlp-transcript-common/lib/autoQueueTypes"; import type { FocusSummary } from "yt-dlp-transcript-common/lib/channelPriority"; @@ -32,12 +38,65 @@ export type FocusBannerProps = { // The lane this banner is drawn beside, when it is on a lane console. Null on // /channels, where the per-lane line is repeated for each ENABLED lane. lane?: AutoQueueKind | null; + // An "End focus" control, supplied by a page that already owns the priority + // writer. Omitted everywhere else, where the link below is the way out. + endFocus?: ReactNode; }; +function channelCount(n: number): string { + return `${n} channel${n === 1 ? "" : "s"}`; +} + export default function FocusBanner({ summary, + name, + lane, + endFocus, }: FocusBannerProps): React.ReactNode { - // Nothing focused, nothing to say. S4 replaces the rest of this body. + // Nothing focused, nothing to say — including a focus that resolved to no + // channels at all, which compiles no focus group and holds no one. if (!summary.active) return null; - return null; + + const label = name ?? summary.siteId ?? "selected channels"; + + return ( + <div + data-focus-banner={lane ?? "all"} + data-focus-holding={summary.holding ? "true" : "false"} + className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-md border border-brand/30 bg-brand-soft px-3 py-2 text-sm" + > + <span className="font-medium text-foreground"> + Focus: {label} ({channelCount(summary.channelCount)}) + </span> + + {lane && ( + <span className="tabular-nums text-muted-foreground"> + · {summary.focusPending.toLocaleString()} pending in this lane ·{" "} + {summary.otherPending.toLocaleString()} waiting behind it + </span> + )} + + <span className="text-muted-foreground"> + {summary.holding ? ( + <>· {channelCount(summary.heldChannels)} held</> + ) : ( + // The focus has nothing left here, so strict descent has already + // fallen through to the groups below it. Worth saying: it is the + // moment the operator is waiting for, and the banner is the only + // thing that can see it. + <>· nothing left to focus here — the rest of the lane is running</> + )} + </span> + + <span className="ml-auto flex items-center gap-3"> + {endFocus} + <Link + href="/channels" + className="underline underline-offset-2 hover:text-brand" + > + Channel priorities + </Link> + </span> + </div> + ); } diff --git a/editor/app/operations/channelPriorityView.ts b/editor/app/operations/channelPriorityView.ts @@ -0,0 +1,135 @@ +import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { listSites } from "yt-dlp-transcript-common/lib/site"; +import { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels"; +import type { + AutoQueueGroup, + AutoQueuePolicy, +} from "yt-dlp-transcript-common/jobs/autoQueuePolicy"; +import type { AutoQueueKind } from "yt-dlp-transcript-common/lib/autoQueueTypes"; +import { + type ChannelPriority, + type FocusSummary, + type PendingByLeaf, + type SiteChannelIndex, + compileLaneRoot, + focusSummary, + resolveFocusSlugs, +} from "yt-dlp-transcript-common/lib/channelPriority"; + +// THE STATUS PAYLOAD'S HALF OF CHANNEL PRIORITY — the tree the lane actually +// dispatches from, and the focus banner's numbers. +// +// WHY THIS EXISTS AT ALL. The payload used to ship `getSettings().autoQueue[kind]` +// verbatim, which is the STORED policy. While a priority model exists the runner +// does not dispatch from the stored tree — it compiles one from the model per +// tick (S1, `laneDispatchRoot` in controller/autoRunner.ts) — so a ladder drawn +// from the stored root would name leaves the runner does not have and attribute +// zero pending to every one of them. The console's whole contract is that its +// rungs ARE the dispatch order, so it has to compile the same tree. +// +// COMPILED FROM THE SAME CONTRACT, not from a second one: `compileLaneRoot` is +// the one compiler (common/lib/channelPriority.ts) and both sides call it with +// the same arguments. The duplication that remains is the three-line gate below +// (`isDefaultChannelPriority` + the call), because S1 kept `laneDispatchRoot` +// private to the runner. AT MERGE: export it there and delete `laneRootFor` +// here — that is the one fold S5 owes this file. +// +// THE ABSENT DOCUMENT COSTS NOTHING. No focus and no channel entry means the +// compiler never runs, the stored tree is shipped byte for byte, and neither +// `listChannelConfigs` nor `listSites` is read — which is what keeps a corpus +// with no priorities set on exactly today's payload and today's cost. + +export type PriorityView = { + model: ChannelPriority; + // The model says something, so the four trees are compiled rather than + // stored. The lane consoles read this to go read-only on the tree. + compiled: boolean; + // Every channel slug, the population `compileLaneRoot` filters per lane. + // Empty when nothing is compiled — it is never read in that case. + slugs: string[]; + focusSlugs: string[]; + // A display name for the focus: the site's title for a site focus, the slugs + // for a channel focus. Resolved here because the model stores a siteId and + // the banner does no I/O. + name: string | null; +}; + +// No focus and no per-channel entry: the document says nothing, so the compiler +// must not run. The same predicate the runner applies, spelled the same way. +function isDefaultChannelPriority(model: ChannelPriority): boolean { + return model.focus.kind === "none" && Object.keys(model.channels).length === 0; +} + +// "a, b and c" for a short channel focus; "a, b and 4 more" past three, because +// this lands mid-sentence in a banner and a 30-slug list is not a name. +function channelFocusName(slugs: readonly string[]): string { + if (slugs.length <= 3) { + if (slugs.length <= 1) return slugs[0] ?? ""; + return `${slugs.slice(0, -1).join(", ")} and ${slugs[slugs.length - 1]}`; + } + return `${slugs.slice(0, 2).join(", ")} and ${slugs.length - 2} more`; +} + +export async function readPriorityView( + paths: Paths = getPaths(), +): Promise<PriorityView> { + const model = getSettings().channelPriority; + if (isDefaultChannelPriority(model)) { + return { model, compiled: false, slugs: [], focusSlugs: [], name: null }; + } + const slugs = (await listChannelConfigs(paths)).map((row) => row.slug); + // ONLY A SITE FOCUS READS THE SITES DIRECTORY, as in the runner: the other + // two kinds resolve from the document alone. + const siteChannels: Record<string, string[]> = {}; + let name: string | null = null; + if (model.focus.kind === "site") { + for (const site of listSites(paths)) { + siteChannels[site.siteId] = site.channels.map((c) => c.slug); + if (site.siteId === model.focus.siteId) name = site.siteTitle; + } + name = name ?? model.focus.siteId; + } + const index: SiteChannelIndex = siteChannels; + const focusSlugs = resolveFocusSlugs(model, index, slugs); + if (model.focus.kind === "channels") name = channelFocusName(focusSlugs); + return { model, compiled: true, slugs, focusSlugs, name }; +} + +// THE ROOT THIS LANE DISPATCHES FROM, for the ladder to draw. Identical to the +// runner's answer by construction: same compiler, same model, same focus set, +// and `compileLaneRoot` re-applies the per-lane paused filter itself, so handing +// it the whole corpus and handing it a pre-filtered list give the same tree. +export function laneRootFor( + view: PriorityView, + lane: AutoQueueKind, + policy: AutoQueuePolicy, +): AutoQueueGroup { + if (!view.compiled) return policy.root; + return compileLaneRoot(lane, view.model, view.slugs, view.focusSlugs); +} + +// `focusSummary` reads nothing but each leaf's COUNT, and `computeLeafPending` +// throws the arrays away before this layer sees them (it returns counts plus a +// truncated head). Rather than widen that return type — which would put the +// whole pending set of every leaf on a three-second poll's heap — the counts are +// re-presented as arrays of the right length. `new Array(n)` allocates no +// elements; only `.length` is ever read. +export function pendingByLeafFromCounts( + counts: Record<string, number>, +): PendingByLeaf { + const out: Record<string, readonly string[]> = {}; + for (const [id, n] of Object.entries(counts)) out[id] = new Array<string>(n); + return out; +} + +export function laneFocusSummary( + view: PriorityView, + counts: Record<string, number>, +): FocusSummary { + return focusSummary( + view.model, + view.focusSlugs, + pendingByLeafFromCounts(counts), + ); +} diff --git a/editor/app/operations/components/ClaimLadder.tsx b/editor/app/operations/components/ClaimLadder.tsx @@ -18,6 +18,7 @@ export function ClaimLadder({ operations, data, ops, + readOnly = false, }: { root: AutoQueueGroup; channels: Channel[]; @@ -26,11 +27,17 @@ export function ClaimLadder({ operations: string[]; data: RungData; ops: RungOps; + // The tree is COMPILED from the channel priorities, so it is shown rather + // than edited. See LadderRung. + readOnly?: boolean; }) { return ( - <div className="flex flex-col gap-2 rounded-md border border-border bg-card px-3 py-2"> + <div + className="flex flex-col gap-2 rounded-md border border-border bg-card px-3 py-2" + data-policy-compiled={readOnly ? "true" : "false"} + > <p className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground"> - Policy + Policy{readOnly ? " (generated)" : ""} </p> <LadderRung node={root} @@ -45,6 +52,7 @@ export function ClaimLadder({ operations={operations} data={data} ops={ops} + readOnly={readOnly} /> </div> ); diff --git a/editor/app/operations/components/HowPriorityWorks.tsx b/editor/app/operations/components/HowPriorityWorks.tsx @@ -12,8 +12,14 @@ import { // It is reference material — true, worth having, and read once — so it was // costing every subsequent visit the height of the answer to "what is it doing // right now", which is the question people actually arrive with. +// +// `compiled` says whether the rules below are GENERATED from the channel +// priorities rather than hand-authored here. It changes what the last paragraph +// claims, because with a priority model set "read top to bottom" is still true +// and "edit them here" is not: the compiler wins at dispatch, so a rule typed +// into this tree could not change what the lane does. -export function HowPriorityWorks() { +export function HowPriorityWorks({ compiled = false }: { compiled?: boolean }) { const [open, setOpen] = useState(false); return ( <Collapsible open={open} onOpenChange={setOpen}> @@ -35,6 +41,37 @@ export function HowPriorityWorks() { claims. Rule order still wins — for a pure newest-first archive, use one catch-all rule. </p> + {/* THE TREE IS GENERATED, and this is where that is said in prose — + the read-only ladder says it as a state, this says it as a rule. + Drawn in both cases because "where do the rules come from" has an + answer either way, and the two answers are different. */} + <p> + {compiled ? ( + <> + <strong className="text-foreground"> + These rules are generated + </strong>{" "} + from the channel priorities — one tier per channel plus one + focus — set on{" "} + <Link href="/channels" className="underline"> + the channels page + </Link> + . All four lanes are compiled from that one document, so a + focus group sits at the top of every one of them and the tree + below is read-only here. + </> + ) : ( + <> + No channel priorities are set, so these rules are hand-authored + here. Setting a tier or a focus on{" "} + <Link href="/channels" className="underline"> + the channels page + </Link>{" "} + generates all four lanes&rsquo; rules instead, and this tree + becomes read-only. + </> + )} + </p> <p> A video is claimed by exactly one rule (the first that matches), so overlapping rules never double-process it. This is independent of the{" "} diff --git a/editor/app/operations/components/LadderRung.tsx b/editor/app/operations/components/LadderRung.tsx @@ -38,6 +38,16 @@ import { // DEPTH IS THE RAIL, NOT AN INDENT. Each level draws its own hairline on the // left, and that same hairline carries the claim fill — so nesting and // occupancy are one mark instead of a margin plus a chip. +// +// READ-ONLY IS A RENDERING MODE, NOT A SECOND COMPONENT. When the lane's tree is +// COMPILED from the channel priorities (settings.channelPriority), editing a +// rung here could not change what the lane dispatches — the compiler wins — so +// every control that would rewrite the tree is disabled and the ones that would +// ADD or REMOVE a node are not drawn at all. What stays is everything that +// reads: the ordinal, the match sentence, the claim rail, the live occupancy +// and the pending drill-down. The switches that are NOT part of the tree +// (enable, worker cap, order, auto-captions) live in PolicyTreeEditor and are +// unaffected. export type RungData = { pendingByLeaf: Record<string, number>; @@ -80,20 +90,39 @@ export function LadderRung(props: { operations: string[]; data: RungData; ops: RungOps; + // The tree is generated, so it is shown rather than edited. See above. + readOnly?: boolean; }) { - const { node, depth, parentId, parentMode, index, siblingCount, data, ops } = - props; + const { + node, + depth, + parentId, + parentMode, + index, + siblingCount, + data, + ops, + readOnly = false, + } = props; const group = isGroup(node); const active = data.activeByNode[node.id] ?? 0; const isNext = !group && data.nextUpLeafId === node.id; return ( - <div className="flex gap-2"> + // The node id is on the DOM, which is the only way to check by eye (or from + // a test) that a compiled leaf is the leaf the runner has: `prio-focus-<slug>` + // is a generated id, and an id that does not carry that prefix is + // hand-authored. Nothing renders it as text. + <div className="flex gap-2" data-node-id={node.id}> <ClaimRail active={active} capacity={data.capacity} /> <div className="flex min-w-0 flex-1 flex-col gap-2 pb-1"> <div className="flex flex-wrap items-center gap-2"> {group ? ( - <GroupControls node={node as AutoQueueGroup} update={ops.update} /> + <GroupControls + node={node as AutoQueueGroup} + update={ops.update} + readOnly={readOnly} + /> ) : ( <LeafControls {...props} leaf={node as AutoQueueLeaf} /> )} @@ -104,6 +133,7 @@ export function LadderRung(props: { <input type="number" min={1} + disabled={readOnly} value={node.weight ?? 1} onChange={(e) => ops.update(node.id, (n) => ({ @@ -122,6 +152,7 @@ export function LadderRung(props: { type="number" min={1} placeholder="∞" + disabled={readOnly} value={node.maxWorkers ?? ""} onChange={(e) => ops.update(node.id, (n) => ({ @@ -157,7 +188,7 @@ export function LadderRung(props: { )} <span className="ml-auto flex items-center gap-1"> - {parentId && siblingCount > 1 && ( + {!readOnly && parentId && siblingCount > 1 && ( <> <Button type="button" @@ -196,7 +227,7 @@ export function LadderRung(props: { </Button> </> )} - {parentId && ( + {!readOnly && parentId && ( <Button type="button" size="xs" @@ -231,6 +262,7 @@ export function LadderRung(props: { </p> )} </div> + {!readOnly && ( <div className="flex flex-wrap gap-2"> <AddButton onClick={() => ops.addChild(node.id, ops.makeLeaf("channel"))}> + Channel rule @@ -245,6 +277,7 @@ export function LadderRung(props: { + Group </AddButton> </div> + )} </> )} </div> @@ -363,9 +396,11 @@ function PendingDrilldown({ function GroupControls({ node, update, + readOnly, }: { node: AutoQueueGroup; update: RungOps["update"]; + readOnly: boolean; }) { return ( <> @@ -375,6 +410,7 @@ function GroupControls({ {/* Named: this select had no label at all. */} <select aria-label="group mode" + disabled={readOnly} value={node.mode} onChange={(e) => update(node.id, (n) => ({ ...n, mode: e.target.value as AutoQueueMode })) @@ -402,8 +438,18 @@ function LeafControls(props: { operations: string[]; data: RungData; ops: RungOps; + readOnly?: boolean; }) { - const { leaf, channels, platforms, buckets, operations, data, ops } = props; + const { + leaf, + channels, + platforms, + buckets, + operations, + data, + ops, + readOnly = false, + } = props; const type = leaf.match.type; const ordinal = data.leafIds.indexOf(leaf.id) + 1; return ( @@ -416,6 +462,7 @@ function LeafControls(props: { {/* Named: the match-type select had no label. */} <select aria-label="rule match type" + disabled={readOnly} value={type} onChange={(e) => ops.update(leaf.id, (n) => ({ @@ -433,6 +480,7 @@ function LeafControls(props: { {type === "channel" && ( <select aria-label="channel rule value" + disabled={readOnly} value={leaf.match.value ?? ""} onChange={(e) => ops.update(leaf.id, (n) => ({ @@ -454,6 +502,7 @@ function LeafControls(props: { {type === "platform" && ( <select aria-label="platform rule value" + disabled={readOnly} value={leaf.match.value ?? ""} onChange={(e) => ops.update(leaf.id, (n) => ({ @@ -487,6 +536,7 @@ function LeafControls(props: { <select value={leaf.match.operation ?? ""} aria-label="rule operation" + disabled={readOnly} onChange={(e) => ops.update(leaf.id, (n) => { const match = { ...(n as AutoQueueLeaf).match }; @@ -521,6 +571,7 @@ function LeafControls(props: { <select value={leaf.match.bucket ?? ""} aria-label="rule bucket" + disabled={readOnly} onChange={(e) => ops.update(leaf.id, (n) => { const match = { ...(n as AutoQueueLeaf).match }; diff --git a/editor/app/operations/components/OperationDetail.tsx b/editor/app/operations/components/OperationDetail.tsx @@ -5,6 +5,7 @@ import type { AutoQueueKind } from "yt-dlp-transcript-common/jobs/autoQueueState import type { AutoQueueStatusPayload } from "../status"; // Type-only: syncRow.ts is a server module. See OperationRail. import type { SyncRowView } from "../syncRow"; +import FocusBanner from "../../channels/components/FocusBanner"; import { HowPriorityWorks } from "./HowPriorityWorks"; import { OperationRail } from "./OperationRail"; import { RunnerOperationView } from "./RunnerOperationView"; @@ -124,7 +125,23 @@ export function OperationDetail({ activeJobs={activeJobs} /> )} - <HowPriorityWorks /> + <HowPriorityWorks compiled={data[runnerKind].policyCompiled} /> + {/* THE FOCUS BANNER, OUTSIDE THE LANE SECTION. RunnerOperationView + opens the page's one `<section data-lane>` and its contract + forbids a nested <section> and reserves role="status"; the banner + is neither, and it sits above rather than inside so a + `section[data-lane]`-scoped lookup in the suite never sees it. + + Its numbers are THIS LANE's: `focusPending`/`otherPending` are + sums over the lane's own compiled `pendingByLeaf`, so the same + focus reads differently on the four consoles — which is the point, + since a focus can be holding one lane and exhausted on another. + It renders nothing at all when no focus resolves. */} + <FocusBanner + summary={data[runnerKind].focus} + name={data[runnerKind].focusName ?? undefined} + lane={runnerKind} + /> <RunnerOperationView kind={runnerKind} title={RUNNER_TITLE[runnerKind]} diff --git a/editor/app/operations/components/PolicyTreeEditor.tsx b/editor/app/operations/components/PolicyTreeEditor.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; import { type AutoQueueGroup, type AutoQueueLeaf, @@ -27,6 +28,26 @@ import { // DRAWING of the tree moved to ClaimLadder/LadderRung, which also render the // live counts — so what used to be a form sitting above a separate counts list // is now one object. +// +// THE TREE GOES READ-ONLY WHEN IT IS GENERATED, and only the tree. +// `status.policyCompiled` is true whenever `settings.channelPriority` says +// anything: the lane then dispatches from a tree compiled off that document per +// tick, so a rule typed in here could not change what the lane does — the +// compiler wins (controller/autoRunner.ts, S1). Rather than let the ladder +// offer edits that are silently overruled, it renders as the statement of +// dispatch order it is, with one line saying where the order is set. +// +// WHAT STAYS EDITABLE IS EVERYTHING THAT IS NOT THE TREE: the enable switch, the +// worker cap, the order, and the auto-captions opt-in. None of them is derivable +// from a channel priority, none of them is touched by the compiler (every writer +// SPREADS the policy), and moving them to /channels would put lane settings on a +// channels page. The Save button therefore still means something here; it just +// writes back the same root it was given. +// +// It is NOT deleted for a compiled lane, either: the bucket and operation axes a +// hand-authored leaf can express have no equivalent in the priority model, and a +// corpus with no priorities set — which is every corpus until one is — edits its +// trees here exactly as before. let idSeq = 0; function newId(): string { @@ -161,6 +182,9 @@ export function PolicyTreeEditor({ buckets: string[]; operations: string[]; }) { + // The lane's tree is compiled from settings.channelPriority, so the ladder is + // a reading of dispatch rather than a control on it. + const compiled = status.policyCompiled; const [form, setForm] = useState<Form>(() => formOf(status)); // The last value we know is on disk. Everything dirty-related is a comparison // against this, so "unsaved" means genuinely unsaved rather than "different @@ -269,8 +293,21 @@ export function PolicyTreeEditor({ </label> </div> + {compiled && ( + <p className="text-xs text-muted-foreground"> + These rules are generated from the channel priorities on{" "} + <Link href="/channels" className="underline underline-offset-2"> + the channels page + </Link>{" "} + — one tier per channel plus one focus, compiled into all four lanes. + Edit them there; the switches on this page are still this lane&rsquo;s + own. + </p> + )} + <ClaimLadder root={form.root} + readOnly={compiled} channels={channels} platforms={platforms} buckets={buckets} diff --git a/editor/app/operations/status.ts b/editor/app/operations/status.ts @@ -16,7 +16,14 @@ 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"; +import type { FocusSummary } from "yt-dlp-transcript-common/lib/channelPriority"; import { buildAutoQueueLanes, type AutoQueueLanesPayload } from "./lanes"; +import { + type PriorityView, + laneFocusSummary, + laneRootFor, + readPriorityView, +} from "./channelPriorityView"; // Read-only payload for the Auto-Queue panel: per-kind runner status (running?, // what's in flight, in-flight counts per tree node), the effective policy, the @@ -35,6 +42,11 @@ export type PlatformCooldownView = { export type AutoQueueKindStatus = { kind: AutoQueueKind; + // THE POLICY AS THE LANE DISPATCHES IT, not as it is stored. Every field is + // the stored one except `root`, which is the COMPILED tree whenever + // `settings.channelPriority` says anything (see channelPriorityView.ts). The + // claim ladder is drawn from this, so a rung is a rule the runner actually + // has — and with a priority model set the stored tree is not one. policy: AutoQueuePolicy; runner: AutoRunnerStatus; pendingByLeaf: Record<string, number>; @@ -62,6 +74,18 @@ export type AutoQueueKindStatus = { // settings wholesale between tests while the pool keeps its pausedSnapshot. // Download has no live counterpart: its flag IS the gate, read at dispatch. held: boolean; + // THE TREE ABOVE IS GENERATED, so the editor for it is read-only and the + // channel priorities on /channels are where it is edited. False for a corpus + // with no priorities set, which is every corpus until one is. + policyCompiled: boolean; + // THE FOCUS BANNER'S NUMBERS, FOR THIS LANE. Always present and inert when + // `active` is false, so the banner is one component with one early return + // rather than a conditional on the payload. The counts are this lane's own — + // `focusPending`/`otherPending` differ per lane by construction. + focus: FocusSummary; + // The focus's display name, resolved on the server (the model stores a + // siteId, and the banner does no I/O). Null when nothing is focused. + focusName: string | null; }; export type AutoQueueStatusPayload = Record< @@ -80,9 +104,20 @@ export type AutoQueueStatusPayload = Record< lanes: AutoQueueLanesPayload; }; -async function buildKind(kind: AutoQueueKind): Promise<AutoQueueKindStatus> { +async function buildKind( + kind: AutoQueueKind, + priority: PriorityView, +): Promise<AutoQueueKindStatus> { const paths = getPaths(); - const policy = getSettings().autoQueue[kind]; + const stored = getSettings().autoQueue[kind]; + // The stored policy, with the DISPATCHED root in place of the stored one. + // Spread rather than rebuilt, so `held`, `snoozeUntil`, `enabled`, `order` + // and `maxWorkers` come through untouched — the same rule every writer of a + // policy in this repo follows. + const policy: AutoQueuePolicy = { + ...stored, + root: laneRootFor(priority, kind, stored), + }; const runner = getAutoRunnerStatus(kind); const state = await readAutoQueueState(paths); const pending = await computeLeafPending(kind, paths); @@ -114,6 +149,12 @@ async function buildKind(kind: AutoQueueKind): Promise<AutoQueueKindStatus> { kind === "transcription" ? getWorkerPool().isPaused() : isGateHeld(getSettings(), kind), + policyCompiled: priority.compiled, + // Keyed by COMPILED leaf id (`prio-focus-<slug>`), which is why this is + // computed here and not on the client: it is only meaningful against the + // counts of the tree the lane dispatches from. + focus: laneFocusSummary(priority, pending.counts), + focusName: priority.name, }; } @@ -121,8 +162,12 @@ async function buildKind(kind: AutoQueueKind): Promise<AutoQueueKindStatus> { // 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> { + // ONE RESOLUTION FOR ALL FOUR LANES. The focus set costs a channel listing + // and, for a site focus, a sites read; the four lanes compile from the same + // one, so resolving per lane would pay for it four times on a 3 s poll. + const priority = await readPriorityView(); const [kinds, lanes] = await Promise.all([ - Promise.all(LANES.map((lane) => buildKind(lane))), + Promise.all(LANES.map((lane) => buildKind(lane, priority))), buildAutoQueueLanes(), ]); return { diff --git a/editor/e2e/focus-banner.spec.ts b/editor/e2e/focus-banner.spec.ts @@ -0,0 +1,326 @@ +import { test, expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { + generateReport, + resetData, + writeChannelConfig, + writeDigestVideo, + writeSettings, + writeSite, +} from "./helpers"; + +// THE FOCUS BANNER AND THE GENERATED TREE, on a lane console. +// +// One document — `settings.channelPriority` — changes three things about +// /operations/<lane> at once, and this spec is what says so: +// +// 1. THE BANNER. A focus is a statement nothing else on the page can make: +// the ladder can be full and the count in the thousands while most of it is +// held behind a group at the top. "Focus: <name> (N channels) · <focus> +// pending in this lane · <rest> waiting behind it · M channels held". +// 2. THE LADDER IS THE COMPILED TREE. The status payload used to ship the +// STORED policy, and with a model set the lane does not dispatch from it — +// so the rungs are `prio-focus-*` / `prio-normal-*` / `prio-all`, keyed by +// the compiled ids, which is what makes the counts land on them. +// 3. THE EDITOR IS READ-ONLY. Editing a rung could not change dispatch (the +// compiler wins), so the controls that would rewrite the tree are disabled +// and the ones that would add or remove a node are gone. +// +// And clearing the document puts all three back exactly as they were, which is +// the half that matters most: every corpus has no priorities set until one does. +// +// THE SEEDED SETTINGS CARRY THE COMPILED ROOTS TOO, because that is what lands +// on disk — S3's one writer persists `channelPriority` and the four +// `autoQueue[lane].root` trees in a single save. Seeding the model alone would +// be a state no writer produces. + +const FOCUSED = "focus-chan"; +const OTHER = "other-chan"; +const SITE = "focusite"; +const SITE_TITLE = "Focus Site"; +const SLOW = 120_000; + +type Leaf = { + id: string; + match: { type: string; value?: string }; + weight: number; + maxWorkers: number | null; +}; +type Group = { + id: string; + mode: string; + weight: number; + maxWorkers: number | null; + children: (Group | Leaf)[]; +}; + +// `compileLaneRoot`'s output, spelled out rather than imported: the ids and the +// group order ARE the contract this page renders, so writing them here asserts +// them a second time instead of re-deriving them from the code under test. +function channelLeaf(tier: string, slug: string): Leaf { + return { + id: `prio-${tier}-${slug}`, + match: { type: "channel", value: slug }, + weight: 1, + maxWorkers: null, + }; +} +function tierGroup(tier: string, slugs: string[]): Group { + return { + id: `prio-${tier}`, + mode: "strict", + weight: 1, + maxWorkers: null, + children: slugs.map((slug) => channelLeaf(tier, slug)), + }; +} +function compiledRoot(lane: string, focus: string[], normal: string[]): Group { + const children: (Group | Leaf)[] = []; + if (focus.length > 0) children.push(tierGroup("focus", focus)); + if (normal.length > 0) children.push(tierGroup("normal", normal)); + children.push({ + id: "prio-all", + match: { type: "all" }, + weight: 1, + maxWorkers: null, + }); + return { + id: `${lane}-root`, + mode: "strict", + weight: 1, + maxWorkers: null, + children, + }; +} + +// One hand-authored catch-all, the shape a corpus with no priorities carries. +const CATCH_ALL: Group = { + id: "root", + mode: "strict", + weight: 1, + maxWorkers: null, + children: [ + { id: "all", match: { type: "all" }, weight: 1, maxWorkers: null }, + ], +}; + +// The digest lane is the one whose work list a spec can seed from a transcript +// alone (writeDigestVideo), and the one lane-runner.spec already drives. +function settingsDoc(over: Record<string, unknown> = {}) { + return { + adminTitle: "Test Admin", + maxTranscriptPageBytes: 8388608, + sleepBetweenDownloadsSeconds: 0, + minFreeDiskGB: 0, + verifyAvailabilityBeforeClean: false, + syncScheduler: { fullSweepIntervalMinutes: 0 }, + digest: { + localAppId: "ollama-direct", + remoteAppId: "claude-code", + sections: ["chapters"], + yieldToTranscription: false, + }, + ...over, + }; +} + +async function seed(page: Page) { + await resetData(null); + await writeChannelConfig(FOCUSED); + await writeChannelConfig(OTHER); + await writeDigestVideo({ channelSlug: FOCUSED, videoId: "focusvid0001" }); + await writeDigestVideo({ channelSlug: OTHER, videoId: "othervid0001" }); + await writeSite(SITE, { + siteTitle: SITE_TITLE, + channels: [{ slug: FOCUSED }], + }); + // The snapshot is the lane's WORK LIST — nothing is pending until it exists. + await writeSettings( + settingsDoc({ + autoQueue: { digest: { enabled: true, maxWorkers: 1, root: CATCH_ALL } }, + }), + ); + await generateReport(page, FOCUSED); + await generateReport(page, OTHER); +} + +// The lane's console, hydrated. Every assertion below reads state React put +// there, so waiting for the section rather than for a timeout is the difference +// between a spec and a race. +async function openDigestConsole(page: Page) { + await page.goto("/operations/digest"); + await expect(page.locator('section[data-lane="digest"]')).toHaveAttribute( + "data-hydrated", + "true", + { timeout: 30_000 }, + ); +} + +test("a site focus banners the lane, compiles the ladder and freezes the editor", async ({ + page, +}) => { + test.setTimeout(SLOW); + await seed(page); + + // No focus yet: the page is the page it has always been. + await openDigestConsole(page); + await expect(page.locator("[data-focus-banner]")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "+ Channel rule" }).first(), + ).toBeVisible(); + + // THE DOCUMENT, AND THE TREES IT COMPILES TO, in one write — which is what + // S3's single writer puts on disk. + await writeSettings( + settingsDoc({ + channelPriority: { + focus: { kind: "site", siteId: SITE }, + channels: {}, + }, + autoQueue: { + digest: { + enabled: true, + maxWorkers: 1, + root: compiledRoot("digest", [FOCUSED], [OTHER]), + }, + }, + }), + ); + + await openDigestConsole(page); + + // (1) THE BANNER. The site's TITLE, not its id — the model stores a siteId and + // the name is resolved on the server. + const banner = page.locator('[data-focus-banner="digest"]'); + await expect(banner).toBeVisible(); + await expect(banner).toContainText(`Focus: ${SITE_TITLE} (1 channel)`); + await expect(banner).toContainText("1 pending in this lane"); + await expect(banner).toContainText("1 waiting behind it"); + // THE HELD COUNT. One non-focus channel has work it is not getting, because + // strict descent never reaches the group it is in while the focus group has + // anything. This is the display fact the banner exists for. + await expect(banner).toContainText("1 channel held"); + await expect(banner).toHaveAttribute("data-focus-holding", "true"); + + // (2) THE LADDER IS THE COMPILED TREE, keyed by the compiled ids. + await expect(page.locator('[data-node-id="prio-focus"]')).toHaveCount(1); + await expect( + page.locator(`[data-node-id="prio-focus-${FOCUSED}"]`), + ).toHaveCount(1); + await expect( + page.locator(`[data-node-id="prio-normal-${OTHER}"]`), + ).toHaveCount(1); + await expect(page.locator('[data-node-id="prio-all"]')).toHaveCount(1); + // The counts landed on the compiled leaves, which is the whole reason the + // payload had to stop shipping the stored tree: a leaf the runner does not + // have would read zero. + await expect( + page + .locator(`[data-node-id="prio-focus-${FOCUSED}"]`) + .getByRole("button", { name: /Show pending videos for rule/ }), + ).toHaveText(/1/); + + // (3) THE EDITOR IS READ-ONLY — the tree only. The rules cannot be rewritten, + // added to or removed from. + await expect( + page.getByRole("button", { name: "+ Channel rule" }), + ).toHaveCount(0); + await expect(page.getByRole("button", { name: "Remove" })).toHaveCount(0); + await expect(page.getByLabel("group mode").first()).toBeDisabled(); + await expect(page.getByLabel("rule match type").first()).toBeDisabled(); + await expect( + page.locator('[data-policy-compiled="true"]'), + ).toHaveCount(1); + // And only the tree: the lane's own switches still belong to this page. + await expect(page.getByLabel("video order for auto-digest")).toBeEnabled(); + + // THE PROSE FOLLOWS THE STATE. "How priority works" is a disclosure, so it is + // opened rather than read through it. The phrase asserted is the one only the + // disclosure carries: the ladder's own note opens with the same sentence by + // design — it states the same fact as a state rather than as a rule — so a + // shorter match resolves to both and fails strict mode. + await page.getByText("How priority works", { exact: false }).click(); + await expect( + page.getByText("All four lanes are compiled from that one document"), + ).toBeVisible(); +}); + +test("clearing the priority document puts the console back exactly as it was", async ({ + page, +}) => { + test.setTimeout(SLOW); + await seed(page); + + await writeSettings( + settingsDoc({ + channelPriority: { + focus: { kind: "site", siteId: SITE }, + channels: {}, + }, + autoQueue: { + digest: { + enabled: true, + maxWorkers: 1, + root: compiledRoot("digest", [FOCUSED], [OTHER]), + }, + }, + }), + ); + await openDigestConsole(page); + await expect(page.locator('[data-focus-banner="digest"]')).toBeVisible(); + + // Focus ended, priorities cleared, the hand-authored tree back. + await writeSettings( + settingsDoc({ + autoQueue: { digest: { enabled: true, maxWorkers: 1, root: CATCH_ALL } }, + }), + ); + await openDigestConsole(page); + + await expect(page.locator("[data-focus-banner]")).toHaveCount(0); + await expect(page.locator('[data-node-id="prio-focus"]')).toHaveCount(0); + await expect(page.locator('[data-node-id="all"]')).toHaveCount(1); + await expect( + page.getByRole("button", { name: "+ Channel rule" }).first(), + ).toBeVisible(); + await expect(page.getByLabel("rule match type").first()).toBeEnabled(); + await expect(page.locator('[data-policy-compiled="false"]')).toHaveCount(1); +}); + +test("a focus that resolves to nothing banners nothing and compiles no group", async ({ + page, +}) => { + test.setTimeout(SLOW); + await seed(page); + + // An unknown siteId resolves to no channels, which compiles NO focus group — + // deliberately, so a typo leaves the tree as it would be with no focus rather + // than holding the whole corpus behind a site that does not exist. + await writeSettings( + settingsDoc({ + channelPriority: { + focus: { kind: "site", siteId: "not-a-site" }, + channels: {}, + }, + autoQueue: { + digest: { + enabled: true, + maxWorkers: 1, + root: compiledRoot("digest", [], [FOCUSED, OTHER]), + }, + }, + }), + ); + await openDigestConsole(page); + + await expect(page.locator("[data-focus-banner]")).toHaveCount(0); + await expect(page.locator('[data-node-id="prio-focus"]')).toHaveCount(0); + // The document still says something, so the tree is still GENERATED and the + // editor still read-only — an unresolvable focus is not an absent document. + await expect( + page.locator(`[data-node-id="prio-normal-${FOCUSED}"]`), + ).toHaveCount(1); + await expect( + page.getByRole("button", { name: "+ Channel rule" }), + ).toHaveCount(0); +});