Archilyzer · Source

archilyzer

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

commit 3e8087a013c473333befe1c5f6ca00a39836f5d2
parent eeaebd088141d14513df5661f83f36f6ccd9263a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 12:16:56 -0400

priority: one definition of the dispatched tree, one of the site index

S4 shipped `laneRootFor` plus a copy of the default-model gate in
channelPriorityView.ts and flagged three lines in its own header as the fold
S5 owed it. Closed: `laneDispatchRoot` and `isDefaultChannelPriority` are
exported from controller/autoRunner.ts and both copies are gone, so the
console asks the RUNNER which tree a lane dispatches from rather than
compiling a second one that merely agrees.

`siteChannelIndex(paths)` joins it in lib/site.ts — the read "every site.json,
mapped to siteId -> slugs" was inlined four times (the runner, the sync tick,
the /channels writer, the status payload) and four copies is four places for a
focus to resolve against a different set.

`priorityContextFor` takes the settings rather than reading them: both call
sites already held the object, so every tick and every three-second status
poll was parsing settings.json twice.

End focus is on the lane consoles. The banner's `endFocus` slot takes
`EndFocusButton`, which posts `endFocusAction` — S3's named gesture, the one
writer. A console is where an operator watches a focus drain, so it is where
the click that ends it belongs.

Tests for the two functions S1 shipped untested (review finding 6): the
default-model bypass asserted by IDENTITY, not deepEqual — the promise is that
the compiler never runs, and a structural copy would mean it ran and agreed —
plus the context's cache key, its TTL (a channel added to the focused site
joins without a settings write) and the rule that only a site focus reads the
sites directory.

`readPriorityView`'s channel listing is deliberately NOT cached: the runner's
30 s TTL is right for a loop that ticks forever, wrong for a status poll an
operator reads immediately after creating a channel — and it costs nothing at
all while the model is default, which is the early return above it.

common 980/980; tsc clean in common, editor, export, mcp.

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

Diffstat:
Mcommon/controller/autoRunner.test.ts | 238++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/controller/autoRunner.ts | 70+++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcommon/lib/site.ts | 24++++++++++++++++++++++++
Meditor/app/channels/actions.ts | 14+-------------
Aeditor/app/channels/components/EndFocusButton.tsx | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/operations/channelPriorityView.ts | 56++++++++++++++++++++------------------------------------
Meditor/app/operations/components/OperationDetail.tsx | 12+++++++++++-
Meditor/app/operations/status.ts | 4++--
Meditor/app/scheduler/runTick.ts | 14++++++--------
9 files changed, 402 insertions(+), 85 deletions(-)

diff --git a/common/controller/autoRunner.test.ts b/common/controller/autoRunner.test.ts @@ -1,7 +1,29 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { focusHoldLine, makeFocusHoldReporter } from "./autoRunner"; -import type { FocusSummary } from "../lib/channelPriority"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + focusHoldLine, + isDefaultChannelPriority, + laneDispatchRoot, + makeFocusHoldReporter, + priorityContextFor, + resetPriorityContextForTest, +} from "./autoRunner"; +import { + defaultChannelPriority, + sanitizeChannelPriority, + type ChannelPriority, + type FocusSummary, +} from "../lib/channelPriority"; +import { LANES } from "../lib/autoQueueTypes"; +import type { + AutoQueueGroup, + AutoQueuePolicy, +} from "../jobs/autoQueuePolicy"; +import type { Paths } from "../lib/paths"; +import type { SiteSettings } from "../lib/settings"; // THE RUNNER'S HALF OF S1 (plans/channel-priority.md), and it is here rather // than in `jobs/channelPriorityCompile.test.ts` for one reason: @@ -97,3 +119,215 @@ test("the line names the lane and pluralises the focus set", () => { ); assert.equal(focusHoldLine("download", null), null); }); + +// --- The two functions S1 shipped untested (the S0/S1 review, finding 6) ----- +// +// `laneDispatchRoot` and `priorityContextFor` are the whole of "compile, not +// consult" on the dispatch side, and between them they carry the one promise +// that lets this plan ship without a corpus-wide gate: an absent document +// changes NOTHING. S5 exported both — the first because the status payload has +// to ask the runner which tree it dispatches from rather than compiling a +// second one (editor/app/operations/channelPriorityView.ts), the second +// because its cache key, its TTL and its "only a site focus reads the sites +// directory" rule are three claims no pure function can be asked about. + +function policyWith(root: AutoQueueGroup): AutoQueuePolicy { + return { + enabled: true, + maxWorkers: null, + replaceAutoSubs: false, + order: "listed", + snoozeUntil: null, + root, + }; +} + +const STORED_ROOT: AutoQueueGroup = { + id: "download-root", + mode: "strict", + weight: 1, + maxWorkers: null, + children: [ + { id: "hand-made", match: { type: "channel", value: "slow-b" } }, + { id: "download-all", match: { type: "all" } }, + ], +}; + +test("a default model is the byte-identical bypass: the STORED root, by identity", () => { + const policy = policyWith(STORED_ROOT); + const model = defaultChannelPriority(); + assert.equal(isDefaultChannelPriority(model), true); + for (const lane of LANES) { + const root = laneDispatchRoot(lane, policy, { model, focusSlugs: [] }, [ + "slow-a", + "slow-b", + ]); + // NOT deepEqual. The promise is that the compiler never runs, so the object + // the runner dispatches from is the one `getSettings()` returned — a + // structural copy would mean a compile happened and merely agreed. + assert.equal(root, policy.root, `${lane} must bypass the compiler`); + } +}); + +test("one channel entry is enough to leave the bypass, and the tree is compiled", () => { + const policy = policyWith(STORED_ROOT); + // A `low` tier says something even though nothing is focused: the whole + // point of `isDefaultChannelPriority` is that it is the FULL document, not + // the focus alone. + const model = sanitizeChannelPriority({ + focus: { kind: "none" }, + channels: { "slow-b": { tier: "low" } }, + }); + assert.equal(isDefaultChannelPriority(model), false); + const root = laneDispatchRoot("download", policy, { model, focusSlugs: [] }, [ + "slow-a", + "slow-b", + ]); + assert.notEqual(root, policy.root); + assert.deepEqual( + root.children.map((c) => c.id), + ["prio-normal", "prio-low", "prio-all"], + ); + // And the hand-made leaf is simply not in it: the stored tree is not + // consulted at all while a model exists. + assert.equal(JSON.stringify(root).includes("hand-made"), false); +}); + +test("a focus alone leaves the bypass too", () => { + const model = sanitizeChannelPriority({ + focus: { kind: "channels", slugs: ["slow-a"] }, + channels: {}, + }); + assert.equal(isDefaultChannelPriority(model), false); +}); + +// --- priorityContextFor: the cache, the TTL, and who reads the sites dir ---- + +function sitesFixture(): Paths { + const dir = mkdtempSync(path.join(tmpdir(), "prio-ctx-")); + const sitesDir = path.join(dir, "sites"); + mkdirSync(path.join(sitesDir, "testsite"), { recursive: true }); + writeFileSync( + path.join(sitesDir, "testsite", "site.json"), + JSON.stringify({ + siteTitle: "Test site", + channels: [{ slug: "slow-a" }, { slug: "slow-b" }], + }), + ); + return { sitesDir } as Paths; +} + +function settingsWith(priority: ChannelPriority): SiteSettings { + return { channelPriority: priority } as SiteSettings; +} + +test("priorityContextFor resolves a site focus against the sites directory", () => { + resetPriorityContextForTest(); + const paths = sitesFixture(); + const ctx = priorityContextFor( + paths, + settingsWith( + sanitizeChannelPriority({ + focus: { kind: "site", siteId: "testsite" }, + channels: {}, + }), + ), + ); + assert.deepEqual(ctx.focusSlugs, ["slow-a", "slow-b"]); +}); + +test("only a site focus reads the sites directory", () => { + resetPriorityContextForTest(); + // A path that does not exist: `listSites` would return [] rather than throw, + // so the proof is the RESULT — a channel focus resolves in full from a + // document alone, against a sites dir that could not have been read. + const paths = { sitesDir: path.join(tmpdir(), "prio-ctx-absent") } as Paths; + const ctx = priorityContextFor( + paths, + settingsWith( + sanitizeChannelPriority({ + focus: { kind: "channels", slugs: ["slow-b", "slow-a"] }, + channels: {}, + }), + ), + ); + assert.deepEqual(ctx.focusSlugs, ["slow-b", "slow-a"]); + // The same document with a SITE focus against the same absent directory + // resolves to nothing — which is the designed answer for an unknown siteId + // (a typo must not hold the whole corpus), and is what makes the line above + // a statement about the read and not about the focus kind. + resetPriorityContextForTest(); + assert.deepEqual( + priorityContextFor( + paths, + settingsWith( + sanitizeChannelPriority({ + focus: { kind: "site", siteId: "testsite" }, + channels: {}, + }), + ), + ).focusSlugs, + [], + ); +}); + +test("the context is cached on the document, and a changed document re-resolves", () => { + resetPriorityContextForTest(); + const paths = sitesFixture(); + const settings = settingsWith( + sanitizeChannelPriority({ + focus: { kind: "site", siteId: "testsite" }, + channels: {}, + }), + ); + const first = priorityContextFor(paths, settings); + // Same document, same paths, inside the TTL: the SAME object, so the sites + // directory was not read a second time. This is the claim that lets the + // runner call it on every three-second tick. + assert.equal(priorityContextFor(paths, settings), first); + + // A channel added to the focused site joins the focus WITHOUT a settings + // write — that is why this is a TTL and not a pure memo. Rewrite the site, + // expire the cache by hand, and the focus set moves. + writeFileSync( + path.join(paths.sitesDir, "testsite", "site.json"), + JSON.stringify({ + siteTitle: "Test site", + channels: [{ slug: "slow-a" }, { slug: "slow-b" }, { slug: "slow-c" }], + }), + ); + assert.deepEqual(priorityContextFor(paths, settings).focusSlugs, [ + "slow-a", + "slow-b", + ]); + first.at = 0; + assert.deepEqual(priorityContextFor(paths, settings).focusSlugs, [ + "slow-a", + "slow-b", + "slow-c", + ]); + + // A DIFFERENT DOCUMENT is the other trigger, and it does not wait for the + // TTL: the key is the document plus the sites dir. + const ended = priorityContextFor( + paths, + settingsWith(defaultChannelPriority()), + ); + assert.deepEqual(ended.focusSlugs, []); + + // And the paths are in the key, so two worktrees' runners in one process + // cannot share a focus resolved against the other's sites directory. + const other = sitesFixture(); + writeFileSync( + path.join(other.sitesDir, "testsite", "site.json"), + JSON.stringify({ siteTitle: "Other", channels: [{ slug: "only-here" }] }), + ); + assert.deepEqual(priorityContextFor(paths, settings).focusSlugs, [ + "slow-a", + "slow-b", + "slow-c", + ]); + assert.deepEqual(priorityContextFor(other, settings).focusSlugs, [ + "only-here", + ]); +}); diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts @@ -2,7 +2,7 @@ import path from "node:path"; import type { Paths } from "../lib/paths"; import { mapConcurrent } from "../lib/concurrency"; import { getPaths } from "../lib/paths"; -import { getSettings } from "../lib/settings"; +import { getSettings, type SiteSettings } from "../lib/settings"; import { diskGate } from "../lib/diskSpace"; import { formatBytes } from "../lib/format"; import { detectPlatform } from "../lib/platform"; @@ -86,7 +86,7 @@ import { isChannelPaused, resolveFocusSlugs, } from "../lib/channelPriority"; -import { listSites } from "../lib/site"; +import { siteChannelIndex } from "../lib/site"; import { listChannelConfigs, readChannelConfig, @@ -363,13 +363,22 @@ const PRIORITY_CONTEXT_TTL_MS = 60_000; // stored document changes or the TTL lapses, the same two triggers the // operation lanes' run context uses (settings key + 60 s), and for the same // reason: `resolveFocusSlugs` reads `transcripts/sites/*/site.json` through -// `listSites`, and a `{kind:"site"}` focus tracks that file's membership rather +// `siteChannelIndex`, and a `{kind:"site"}` focus tracks that file's membership rather // than freezing a list. The TTL is what makes a channel added to the focused // site join the focus without a settings write. -type PriorityContext = { +// The half of it `laneDispatchRoot` needs, and the only half a caller OUTSIDE +// this file can supply: the editor's status payload resolves the same two +// fields its own way (editor/app/operations/channelPriorityView.ts) and then +// asks THIS function for the tree, so the console can never draw a tree the +// runner does not dispatch from. +export type PriorityDispatchContext = { + model: ChannelPriority; + focusSlugs: readonly string[]; +}; + +type PriorityContext = PriorityDispatchContext & { key: string; at: number; - model: ChannelPriority; focusSlugs: string[]; }; @@ -378,15 +387,34 @@ let priorityContext: PriorityContext | null = null; // No focus and no per-channel entry: the document says nothing, so the compiler // must not run. Local to the runner rather than in lib/channelPriority.ts // because it is a dispatch-side question ("is there anything to compile"), not -// part of the model's contract. -function isDefaultChannelPriority(model: ChannelPriority): boolean { +// part of the model's contract — and EXPORTED because it is the gate three +// other callers have to ask with exactly this spelling: the status payload +// (channelPriorityView.ts), the one writer's legacy seed and the two actions +// that may no longer write a lane's root while a model exists. S4 shipped a +// copy of it; S5 deleted that copy. One definition. +export function isDefaultChannelPriority(model: ChannelPriority): boolean { return ( model.focus.kind === "none" && Object.keys(model.channels).length === 0 ); } -function priorityContextFor(paths: Paths): PriorityContext { - const model = getSettings().channelPriority; +// `settings` is a PARAMETER, not a read: both call sites already hold the +// settings object for this tick (the runner's `next()` reads it to see +// `enabled` and `snoozeUntil`; `computeLeafPending` reads it for the policy), +// and a second `getSettings()` here made every tick and every three-second +// status poll parse settings.json twice. +// Exported for its own test: the cache key, the TTL and the "only a site focus +// reads the sites directory" rule are three claims a caller cannot observe +// through `laneDispatchRoot`, which is pure. +export function resetPriorityContextForTest(): void { + priorityContext = null; +} + +export function priorityContextFor( + paths: Paths, + settings: SiteSettings, +): PriorityContext { + const model = settings.channelPriority; // The paths go in the key so two worktrees' runners in one process cannot // share a focus resolved against the other's sites directory. const key = JSON.stringify([model, paths.sitesDir]); @@ -400,15 +428,10 @@ function priorityContextFor(paths: Paths): PriorityContext { } // ONLY A SITE FOCUS READS THE SITES DIRECTORY. `{kind:"channels"}` and // `{kind:"none"}` resolve from the document alone, so the common case pays - // nothing. `listSites` is the existing reader (lib/site.ts) — there is no - // second one here. - const siteChannels: Record<string, string[]> = {}; - if (model.focus.kind === "site") { - for (const site of listSites(paths)) { - siteChannels[site.siteId] = site.channels.map((c) => c.slug); - } - } - const index: SiteChannelIndex = siteChannels; + // nothing. `siteChannelIndex` (lib/site.ts) is the ONE spelling of that read + // — the sync tick, the /channels writer and the status payload ask it too. + const index: SiteChannelIndex = + model.focus.kind === "site" ? siteChannelIndex(paths) : {}; priorityContext = { key, at: now, @@ -428,10 +451,10 @@ function priorityContextFor(paths: Paths): PriorityContext { // lane. Compiling is pure and O(channels) — ~69 string pushes against the // ~6.5 MB of snapshot JSON the same tick folds — so it happens per tick and // only the focus resolution above is cached. -function laneDispatchRoot( +export function laneDispatchRoot( kind: AutoQueueKind, policy: AutoQueuePolicy, - ctx: PriorityContext, + ctx: PriorityDispatchContext, slugs: readonly string[], ): AutoQueueGroup { if (isDefaultChannelPriority(ctx.model)) return policy.root; @@ -772,14 +795,15 @@ export async function computeLeafPending( kind: AutoQueueKind, paths: Paths = getPaths(), ): Promise<LeafPending> { - const policy = getSettings().autoQueue[kind]; + const settings = getSettings(); + const policy = settings.autoQueue[kind]; // THE SAME TWO PRIORITY DECISIONS THE RUNNER MAKES, in the same order: the // paused filter on the channel list, then the compiled root. This function's // whole contract is that its numbers are the runner's numbers, so both sides // of channel priority have to be here too — otherwise the panel would count // pending work for a paused channel, or attribute it to a stored leaf the // runner is not dispatching from. - const ctx = priorityContextFor(paths); + const ctx = priorityContextFor(paths, settings); const meta = await listChannelMeta(paths, kind, ctx.model); const root = laneDispatchRoot(kind, policy, ctx, meta.map((m) => m.slug)); const laneOperations = laneOperationIds(kind); @@ -1292,7 +1316,7 @@ async function runLoop( // every channel is paused has a legitimately empty list, and the old // sentinel would re-read 68 configs on every three-second tick for it. const now = Date.now(); - const ctx = priorityContextFor(paths); + const ctx = priorityContextFor(paths, settings); if (metaAt === 0 || now - metaAt > CHANNEL_LIST_TTL_MS) { metaCache = await listChannelMeta(paths, kind, ctx.model); metaAt = now; diff --git a/common/lib/site.ts b/common/lib/site.ts @@ -8,6 +8,7 @@ import { type ChannelGroup, } from "./channelGroups"; import { getPaths, type Paths } from "./paths"; +import type { SiteChannelIndex } from "./channelPriority"; import { parseAccent } from "./accent"; import { getSettings, @@ -305,6 +306,29 @@ export function siteChannelSlugs(site: Site): Set<string> { return new Set(site.channels.map((c) => c.slug)); } +// siteId -> that site's channel slugs: the one shape `resolveFocusSlugs` +// (lib/channelPriority.ts) resolves a `{kind:"site"}` focus against. +// +// ONE SPELLING, four callers. It was inlined four times — the runner's +// `priorityContextFor`, the sync tick, the /channels writer and the status +// payload — and four copies of "read every site.json and map it" is four +// places for a focus to resolve against a different set. Read at the moment +// the focus is resolved, never stored: a site focus tracks the site's +// membership rather than freezing a list, which is the whole reason +// `focus.kind === "site"` exists. +// +// THE CALLER DECIDES WHETHER TO CALL IT AT ALL. `{kind:"channels"}` and +// `{kind:"none"}` resolve from the document alone, so every caller gates this +// on `focus.kind === "site"` and a corpus with no site focus never reads the +// sites directory. +export function siteChannelIndex(paths: Paths = getPaths()): SiteChannelIndex { + const index: Record<string, string[]> = {}; + for (const site of listSites(paths)) { + index[site.siteId] = [...siteChannelSlugs(site)]; + } + return index; +} + // The effective social links for a site: its own override when present, else // the global default. Pass `settings` to avoid a redundant read; defaults to // getSettings() for callers that don't already have it. diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -30,8 +30,7 @@ import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { runManagedFunction } from "yt-dlp-transcript-common/jobs/streamCommand"; import { drainStream } from "yt-dlp-transcript-common/jobs/drainStream"; import { - listSites, - siteChannelSlugs, + siteChannelIndex, type Site, } from "yt-dlp-transcript-common/lib/site"; import { @@ -708,17 +707,6 @@ function applyPriorityEdit( return { ...model, channels }; } -// siteId -> its channel slugs, for `resolveFocusSlugs`. Read at WRITE time, not -// stored: a site focus tracks the site's membership rather than freezing a list, -// which is the whole reason `focus.kind === "site"` exists. -function siteChannelIndex(paths: ReturnType<typeof getPaths>): SiteChannelIndex { - const index: Record<string, string[]> = {}; - for (const site of listSites(paths)) { - index[site.siteId] = [...siteChannelSlugs(site)]; - } - return index; -} - // THE ONE WRITER. Reads the current settings, applies one edit through // `sanitizeChannelPriority`, recompiles the four lane roots from the result and // persists both in a single `writeSettings`. diff --git a/editor/app/channels/components/EndFocusButton.tsx b/editor/app/channels/components/EndFocusButton.tsx @@ -0,0 +1,55 @@ +"use client"; + +// THE BANNER'S "End focus", for the surfaces that are NOT /channels. +// +// The banner itself has no writer and must not grow one (FocusBanner.tsx's +// header states why): `settings.channelPriority` has exactly one writer, +// `saveChannelPriorityAction`, and a second one for a single button is the +// thing the model was built to avoid. So this is not a second writer — it is +// the same one, reached through `endFocusAction`, the named gesture +// `ChannelFocusBar` on /channels posts. One writer, two controls. +// +// It lives here rather than under operations/ because it belongs to the +// priority vocabulary, beside the action it posts and the bar that shares it. +// +// A FAILED WRITE MUST SAY SO. The writer returns `{error}` before it +// revalidates, so a swallowed result would read as "the click did nothing" — +// the same rule, and the same shape, as `useBarAction` in ChannelBulkBar. + +import { useState, useTransition } from "react"; +import { endFocusAction } from "../actions"; + +export default function EndFocusButton(): React.ReactNode { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState<string | null>(null); + return ( + <> + <button + type="button" + disabled={pending} + onClick={() => + startTransition(async () => { + try { + const result = await endFocusAction(); + setError(result?.error ?? null); + } catch (e) { + setError((e as Error).message); + } + }) + } + className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50" + > + End focus + </button> + {error && ( + <span + role="alert" + aria-label="focus error" + className="text-xs text-destructive" + > + {error} + </span> + )} + </> + ); +} diff --git a/editor/app/operations/channelPriorityView.ts b/editor/app/operations/channelPriorityView.ts @@ -1,18 +1,13 @@ 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 { listSites, siteChannelIndex } 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 { isDefaultChannelPriority } from "yt-dlp-transcript-common/controller/autoRunner"; import { type ChannelPriority, type FocusSummary, type PendingByLeaf, type SiteChannelIndex, - compileLaneRoot, focusSummary, resolveFocusSlugs, } from "yt-dlp-transcript-common/lib/channelPriority"; @@ -28,12 +23,12 @@ import { // 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. +// COMPILED BY THE RUNNER'S OWN FUNCTION, not by a second one. S4 shipped a +// `laneRootFor` here and a copy of the default-model gate beside it, flagged in +// this header as the one fold S5 owed the file; S5 exported `laneDispatchRoot` +// and `isDefaultChannelPriority` from controller/autoRunner.ts and deleted both +// copies. There is now ONE definition of "which tree does this lane dispatch +// from", and the console asks the runner for it. // // 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 @@ -55,12 +50,6 @@ export type PriorityView = { 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 { @@ -78,37 +67,32 @@ export async function readPriorityView( if (isDefaultChannelPriority(model)) { return { model, compiled: false, slugs: [], focusSlugs: [], name: null }; } + // NOT CACHED, DELIBERATELY. The runner holds its channel list on a 30 s TTL + // because it ticks every three seconds forever; this runs on the status + // poll, which is what an e2e spec (and an operator) reads immediately after + // creating, renaming or deleting a channel. A TTL here would make the + // console lag the corpus by up to half a minute for a listing that costs + // nothing at all while the model is default — the early return above never + // reaches it. 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[]> = {}; + // two kinds resolve from the document alone. `siteChannelIndex` is the one + // spelling of that read (lib/site.ts); the site TITLE is this surface's + // extra, because the banner names the focus and the model stores an id. + let index: SiteChannelIndex = {}; let name: string | null = null; if (model.focus.kind === "site") { + index = siteChannelIndex(paths); 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 diff --git a/editor/app/operations/components/OperationDetail.tsx b/editor/app/operations/components/OperationDetail.tsx @@ -6,6 +6,7 @@ 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 EndFocusButton from "../../channels/components/EndFocusButton"; import { HowPriorityWorks } from "./HowPriorityWorks"; import { OperationRail } from "./OperationRail"; import { RunnerOperationView } from "./RunnerOperationView"; @@ -136,11 +137,20 @@ export function OperationDetail({ 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. */} + It renders nothing at all when no focus resolves. + + END FOCUS IS HERE, not a link to go and find it. The banner's + `endFocus` slot takes a control from a caller that can reach the + ONE writer, and `EndFocusButton` posts `endFocusAction` — the + same named gesture /channels' focus bar posts. A console is where + an operator watches a focus drain, so it is where the click that + ends it belongs; the "Channel priorities" link beside it is still + the way to everything else. */} <FocusBanner summary={data[runnerKind].focus} name={data[runnerKind].focusName ?? undefined} lane={runnerKind} + endFocus={<EndFocusButton />} /> <RunnerOperationView kind={runnerKind} diff --git a/editor/app/operations/status.ts b/editor/app/operations/status.ts @@ -6,6 +6,7 @@ import { type RecencyKeyView, computeLeafPending, getAutoRunnerStatus, + laneDispatchRoot, } from "yt-dlp-transcript-common/controller/autoRunner"; import { type AutoQueueKind, @@ -21,7 +22,6 @@ import { buildAutoQueueLanes, type AutoQueueLanesPayload } from "./lanes"; import { type PriorityView, laneFocusSummary, - laneRootFor, readPriorityView, } from "./channelPriorityView"; @@ -116,7 +116,7 @@ async function buildKind( // policy in this repo follows. const policy: AutoQueuePolicy = { ...stored, - root: laneRootFor(priority, kind, stored), + root: laneDispatchRoot(kind, stored, priority, priority.slugs), }; const runner = getAutoRunnerStatus(kind); const state = await readAutoQueueState(paths); diff --git a/editor/app/scheduler/runTick.ts b/editor/app/scheduler/runTick.ts @@ -25,7 +25,7 @@ import { } from "yt-dlp-transcript-common/jobs/syncSchedulerState"; import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig"; import { resolveFocusSlugs } from "yt-dlp-transcript-common/lib/channelPriority"; -import { listSites } from "yt-dlp-transcript-common/lib/site"; +import { siteChannelIndex } from "yt-dlp-transcript-common/lib/site"; import { syncAction } from "../channels/[slug]/pipelineActions"; import { fetchPostsAction } from "../channels/[slug]/socialActions"; // STORAGE CHORES riding this heartbeat because it is the one timer the editor @@ -121,14 +121,12 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> { // `channels[]` so a focus on a site tracks its membership instead of // freezing a list. `{kind:"none"}` and `{kind:"channels"}` resolve from the // document alone. No cache here — the tick runs on the heartbeat, not per - // grant, so one `listSites()` per tick is not a cost worth memoizing. + // grant, so one `siteChannelIndex()` per tick is not a cost worth memoizing. + // `siteChannelIndex` (lib/site.ts) is the one spelling of that read; the + // runner, the /channels writer and the status payload ask the same one. const priority = settings.channelPriority; - const siteChannels: Record<string, string[]> = {}; - if (priority.focus.kind === "site") { - for (const site of listSites(paths)) { - siteChannels[site.siteId] = site.channels.map((c) => c.slug); - } - } + const siteChannels = + priority.focus.kind === "site" ? siteChannelIndex(paths) : {}; const { due, skipped } = selectDueChannels({ channels, scheduler,