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:
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,