Archilyzer · Source

archilyzer

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

commit 0ae20fa9fcaf52438333e3e592ce4723646ec63b
parent f196bfe246958aa7125b27f3ea87af3f8d23e3d6
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 11:24:12 -0400

common: the channel-priority model, with per-operation overrides

One ordered-tier document — a tier per channel, an optional rank, one corpus-wide
focus selector — that COMPILES to the four autoQueue[lane].root trees rather than
being consulted beside them. A strict group is already "hold the rest until the
higher one is empty" (pick() filters to children with work and descends into the
first, re-asked on every grant), and all 22 live leaves are bare, so the compiler
destroys nothing and no dispatch code changes.

Per-operation overrides are the advanced half and the reason the migration is
lossless. `channels[slug].overrides` pins any of `sync` + the four lanes to a
different tier than the base, so "keep syncing, park the downloads" and its
inverse "sync only" are both one entry. effectiveTier(model, slug, op) is what
every dispatch-side question asks; tierOf stays the display answer. That retires
the plan's open question 3: excludeFromSync migrates to {sync: "paused"} with the
base tier and rank untouched, so no lane's membership moves.

Paused is NOT a tree shape — a catch-all matches everything and first-match-wins
would let one above Low swallow Low's work — so it is a filter on the runner's
channel list, asked per lane.

Nothing reads the model yet. The live trees are untouched and no number moves.

- `channelPriority` is a SiteSettings key, sanitized in getSettings beside
  syncScheduler and in saveSettings. No migration runs on read: the legacy read
  needs 68 config.json files and getSettings is synchronous over one, so it is
  an offline script in S5. An absent document sanitizes to the empty one, which
  is today's behaviour byte for byte.
- settings/actions.ts PRESERVES channelPriority the way it preserves autoQueue —
  it is the SOURCE the roots are compiled from, so rebuilding it would undo a focus.
- lib -> lib only, so architecture.test.ts's ALLOWED list gains no entry, and
  common/package.json needs none either (`./lib/*` is wildcarded).

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

Diffstat:
Acommon/lib/channelPriority.ts | 709+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/settings.ts | 21+++++++++++++++++++++
Meditor/app/settings/actions.ts | 5+++++
3 files changed, 735 insertions(+), 0 deletions(-)

diff --git a/common/lib/channelPriority.ts b/common/lib/channelPriority.ts @@ -0,0 +1,709 @@ +// CHANNEL PRIORITY: one tier per channel, one focus, four compiled trees. +// +// The operator-facing model is a single ordered-tier document in settings.json: +// every channel sits in a tier (normal / low / paused), optionally carries a +// rank inside it, and ONE corpus-wide focus selector lifts a set of channels +// above everything else until their work is exhausted. +// +// THE MODEL DOES NOT REACH DISPATCH. It COMPILES to the four +// `autoQueue[lane].root` trees, which the runner already walks. A strict group +// is *already* "hold the rest until the higher one is empty": +// `jobs/autoQueuePolicy.ts`'s `pick()` filters children to those with work and +// descends into the FIRST of them, and it is re-asked on every grant. So focus +// work present ⇒ nothing below it is picked; focus work exhausted ⇒ the next +// group runs; new focus work arrives ⇒ the very next pick retakes the lane. +// Compiling means NO dispatch code changes: `buildPendingByLeaf`, +// `selectNextWork`, `operationBatch`, `laneLimit`, `pauseGates` and every +// `held` key are untouched, and "a zero limit is a hold, never a stop" is +// preserved trivially because nothing here ever returns a limit. +// +// PAUSED IS THE ONE THING THAT IS *NOT* A TREE SHAPE. A tree cannot express +// exclusion — a `{type:"all"}` catch-all matches everything, and first-match- +// wins would let a catch-all above the Low group swallow Low's work. So paused +// is a filter on the runner's CHANNEL LIST (`controller/autoRunner.ts`'s +// `listChannelMeta`, slice S1), which makes a paused channel invisible to the +// lane, catch-all included, in one predicate — asked per lane, because a +// per-operation override means a channel can be paused for one and not another. +// +// A PAUSE IS NOT A STOP, and it is not a stop of a MANUAL run either. Paused +// gates the sync scheduler, *Sync all* and the four auto lanes. The per-video +// and per-channel Run buttons still work — the operator asking for one video is +// not the automation this model governs. +// +// A FOCUS NEVER CHANGES A LANE'S `enabled`. Focusing must not start a stopped +// lane, for the same reason `saveAutoQueueAction` refuses to unhold one. +// +// PER-OPERATION OVERRIDES ARE THE ADVANCED HALF. A channel's tier is its base, +// and an entry may pin any single operation — `sync` plus the four lanes — to a +// different tier. That is not a luxury: the flag this model replaces, +// `excludeFromSync`, was exactly "stop syncing, keep everything else", and a +// channel that must keep its playlist current while its downloads are parked is +// the same statement the other way round. The focus set stays ONE, corpus-wide; +// a focused channel whose override for an operation is `paused` is simply not +// drawn for that operation. Every dispatch-side question therefore asks +// `effectiveTier(model, slug, op)` and never the base tier directly. +// +// PURE, AND lib/-ONLY. It imports `./autoQueueTypes` and nothing else, so +// ../architecture.test.ts's ALLOWED list gains no entry. It builds RAW nodes +// that `sanitizeAutoQueue` normalizes exactly as it normalizes a hand-edited +// file — the same discipline as lib/laneMigration.ts — except that every node +// here already spells `weight` and `maxWorkers`, so a compiled tree survives +// that sanitizer unchanged and a round-trip through settings.json is identity. + +import { + LANES, + type AutoQueueGroup, + type AutoQueueKind, + type AutoQueueLeaf, + type AutoQueueNode, + isGroup, +} from "./autoQueueTypes"; + +// --- The vocabulary --------------------------------------------------------- + +// Every tier a channel can occupy in a COMPILED tree, highest first. +// +// "focus" is a POSITION, not a stored value: it is produced by the focus +// selector, and `sanitizeChannelPriority` coerces a stored `tier: "focus"` to +// "normal". Storing it would give two ways to say the same thing and no way to +// end a focus in one click. +export const CHANNEL_TIERS = ["focus", "normal", "low", "paused"] as const; +export type ChannelTier = (typeof CHANNEL_TIERS)[number]; + +// What may be STORED against a channel. The `/channels` tier control writes one +// of these three; "focus" is reached through the focus selector instead. +export const STORED_CHANNEL_TIERS = ["normal", "low", "paused"] as const; +export type StoredChannelTier = (typeof STORED_CHANNEL_TIERS)[number]; + +export const DEFAULT_CHANNEL_TIER: StoredChannelTier = "normal"; + +export function isChannelTier(value: unknown): value is ChannelTier { + return ( + typeof value === "string" && + (CHANNEL_TIERS as readonly string[]).includes(value) + ); +} + +export function isStoredChannelTier(value: unknown): value is StoredChannelTier { + return ( + typeof value === "string" && + (STORED_CHANNEL_TIERS as readonly string[]).includes(value) + ); +} + +// THE OPERATIONS A TIER CAN BE PINNED TO: the four dispatch lanes plus `sync`. +// +// `sync` is first because it is the one that is NOT a lane — it is a per-channel +// cadence, which is the same answer `pauseLaneFor` and `laneForOperation` give +// it — and because "sync only" is the preset that retires `excludeFromSync`. +// The four lanes come from LANES rather than being restated, so a fifth lane is +// overridable the day it exists. +export const PRIORITY_OPERATIONS = ["sync", ...LANES] as const; +export type PriorityOperation = (typeof PRIORITY_OPERATIONS)[number]; + +export function isPriorityOperation(value: unknown): value is PriorityOperation { + return ( + typeof value === "string" && + (PRIORITY_OPERATIONS as readonly string[]).includes(value) + ); +} + +// --- The document ----------------------------------------------------------- + +// The focus selector. `site` is the first-class answer to "focus = the channels +// of site X" and is resolved at COMPILE time against that site's `channels[]`, +// so it tracks membership rather than freezing a list; `channels` backs +// "Focus these". +export type ChannelFocus = + | { kind: "none" } + | { kind: "site"; siteId: string } + | { kind: "channels"; slugs: string[] }; + +export type ChannelPriorityEntry = { + // THE BASE TIER: what every operation gets unless an override says otherwise. + tier: StoredChannelTier; + // Order WITHIN the tier, ascending. Absent = unranked, which sorts after + // every ranked sibling and then by slug. ONE rank per channel, not one per + // lane — the two hand-made lane orders collapse into this on migration. + rank?: number; + // PER-OPERATION OVERRIDES of the base tier. Only operations that DIFFER from + // the base appear: the sanitizer normalises an override equal to `tier` away, + // so the on-disk document stays a list of exceptions to a list of exceptions. + // + // `{tier:"normal", overrides:{sync:"paused"}}` is "everything but sync" — the + // lossless reading of the retired `excludeFromSync`. Its inverse, + // `{tier:"paused", overrides:{sync:"normal"}}`, is "sync only": keep the + // playlist and metadata current, dispatch nothing. + overrides?: Partial<Record<PriorityOperation, StoredChannelTier>>; +}; + +export type ChannelPriority = { + focus: ChannelFocus; + // ONLY channels that differ from the default appear. An absent slug is + // `normal`, unranked — so the default document is empty and "absent document + // = today's behaviour" holds byte for byte. + channels: Record<string, ChannelPriorityEntry>; +}; + +export function defaultChannelPriority(): ChannelPriority { + return { focus: { kind: "none" }, channels: {} }; +} + +// --- The sanitizer ---------------------------------------------------------- + +function trimmedSlugs(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const out: string[] = []; + for (const v of value) { + if (typeof v !== "string") continue; + const s = v.trim(); + if (s && !out.includes(s)) out.push(s); + } + return out; +} + +function sanitizeFocus(value: unknown): ChannelFocus { + const r = (value ?? {}) as Record<string, unknown>; + if (r.kind === "site") { + const siteId = typeof r.siteId === "string" ? r.siteId.trim() : ""; + // A site focus with no site is not a focus. Collapsing it here means every + // reader can treat `kind !== "none"` as "something is focused" without + // repeating the emptiness check. + return siteId ? { kind: "site", siteId } : { kind: "none" }; + } + if (r.kind === "channels") { + const slugs = trimmedSlugs(r.slugs); + return slugs.length > 0 ? { kind: "channels", slugs } : { kind: "none" }; + } + return { kind: "none" }; +} + +// THE OVERRIDE MAP'S NORMALISATION, and each rule is a bug it avoids: +// +// 1. AN UNKNOWN OPERATION KEY IS DROPPED. The key space is +// PRIORITY_OPERATIONS and nothing else; a typo must not sit in the file +// looking like it does something. +// 2. AN UNKNOWN TIER VALUE IS DROPPED, not coerced. Coercing it to `normal` +// (which is what the BASE tier does) would silently UNPAUSE an operation +// on a paused channel — an override's job is to differ from the base, so a +// junk one must fall back to the base rather than to a tier nobody chose. +// 3. AN OVERRIDE EQUAL TO THE BASE IS NORMALISED AWAY, and an empty map with +// it, so the document stays small and re-sanitizing is identity. +// +// Keys are emitted in PRIORITY_OPERATIONS order for a stable file diff. +function sanitizeOverrides( + value: unknown, + tier: StoredChannelTier, +): Partial<Record<PriorityOperation, StoredChannelTier>> | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const raw = value as Record<string, unknown>; + const out: Partial<Record<PriorityOperation, StoredChannelTier>> = {}; + let any = false; + for (const op of PRIORITY_OPERATIONS) { + const v = raw[op]; + if (!isStoredChannelTier(v)) continue; + if (v === tier) continue; + out[op] = v; + any = true; + } + return any ? out : undefined; +} + +// Coerce a raw `settings.channelPriority` into a clean document. +// +// Coerce-to-legal, the settings sanitizers' existing style: an unknown tier is +// `normal`, a junk rank is dropped, a blank slug is dropped. NOTE what it does +// NOT do — it never invents a `held`, never touches a lane, and never resolves +// a focus: an unknown siteId survives sanitation and resolves to no focus group +// at compile time, so a typo cannot hold the whole corpus and is still visible +// to the operator in the UI that wrote it. +export function sanitizeChannelPriority(value: unknown): ChannelPriority { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return defaultChannelPriority(); + } + const r = value as Record<string, unknown>; + const rawChannels = + r.channels && typeof r.channels === "object" && !Array.isArray(r.channels) + ? (r.channels as Record<string, unknown>) + : {}; + const channels: Record<string, ChannelPriorityEntry> = {}; + // Sorted by the TRIMMED slug, so the on-disk document has a stable key order + // and a settings.json diff shows only what an operator changed. + const keys = Object.keys(rawChannels) + .filter((key) => key.trim()) + .sort((a, b) => a.trim().localeCompare(b.trim())); + for (const key of keys) { + const slug = key.trim(); + const raw = (rawChannels[key] ?? {}) as Record<string, unknown>; + // A stored "focus" is not a tier (see CHANNEL_TIERS) — it reads as normal. + const tier: StoredChannelTier = isStoredChannelTier(raw.tier) + ? raw.tier + : DEFAULT_CHANNEL_TIER; + const entry: ChannelPriorityEntry = { tier }; + const overrides = sanitizeOverrides(raw.overrides, tier); + if (overrides) entry.overrides = overrides; + if (typeof raw.rank === "number" && Number.isFinite(raw.rank)) { + // RANK IS ONLY MEANINGFUL WHERE SOMETHING IS ORDERED. A channel that is + // paused for EVERY operation is in no group anywhere, so its rank is dead + // weight in the file; a `{tier:"paused", overrides:{sync:"normal"}}` + // "sync only" channel still orders inside the sync scheduler's tier, so + // it keeps one. + const orderedSomewhere = PRIORITY_OPERATIONS.some( + (op) => (overrides?.[op] ?? tier) !== "paused", + ); + if (orderedSomewhere) entry.rank = Math.floor(raw.rank); + } + // An entry that says nothing the default does not say is DROPPED, so the + // document stays a list of exceptions and re-sanitizing is identity. + if ( + entry.tier === DEFAULT_CHANNEL_TIER && + entry.rank === undefined && + entry.overrides === undefined + ) { + continue; + } + channels[slug] = entry; + } + return { focus: sanitizeFocus(r.focus), channels }; +} + +// --- Reading the document --------------------------------------------------- + +// The channel's BASE tier — what `/channels` shows in the tier column, and the +// fallback for every operation the entry does not override. Dispatch should ask +// `effectiveTier` instead; this is the display answer. +export function tierOf(model: ChannelPriority, slug: string): StoredChannelTier { + return model.channels[slug]?.tier ?? DEFAULT_CHANNEL_TIER; +} + +// THE TIER THAT ACTUALLY DECIDES, for one operation. Every dispatch-side +// question goes through here: the compiler asks it per lane, `listChannelMeta` +// asks it for the lane it is listing, and the sync scheduler asks it for +// "sync". ONE definition of "what does this channel's priority mean for this +// operation", the way `isGateHeld` is one definition of "is this lane held". +export function effectiveTier( + model: ChannelPriority, + slug: string, + op: PriorityOperation, +): StoredChannelTier { + const entry = model.channels[slug]; + if (!entry) return DEFAULT_CHANNEL_TIER; + return entry.overrides?.[op] ?? entry.tier; +} + +// The per-operation overrides an entry carries, normalised (see +// sanitizeOverrides). Empty object when there are none, so a UI can spread it +// without a null check. +export function overridesOf( + model: ChannelPriority, + slug: string, +): Partial<Record<PriorityOperation, StoredChannelTier>> { + return { ...(model.channels[slug]?.overrides ?? {}) }; +} + +export function rankOf(model: ChannelPriority, slug: string): number | null { + const rank = model.channels[slug]?.rank; + return typeof rank === "number" ? rank : null; +} + +// THE ONE PAUSE PREDICATE. `listChannelMeta` (per lane), the sync scheduler's +// due loop and *Sync all* (op "sync") all ask this and nothing else. +// +// `op` is optional only so a display surface can ask about the BASE tier; every +// dispatch caller names the operation, because with overrides "paused" is not a +// property of a channel, it is a property of a channel AND an operation. +export function isChannelPaused( + model: ChannelPriority, + slug: string, + op?: PriorityOperation, +): boolean { + return (op ? effectiveTier(model, slug, op) : tierOf(model, slug)) === "paused"; +} + +// The slugs an operation may draw from, input order preserved. S1's +// `listChannelMeta` filter and S2's due loop are each one call to this. +export function channelsForOperation( + model: ChannelPriority, + slugs: readonly string[], + op: PriorityOperation, +): string[] { + return slugs.filter((slug) => !isChannelPaused(model, slug, op)); +} + +// Tier order as a sortable number: focus 0, normal 1, low 2, paused 3. The sync +// scheduler sorts by this, then `rank`, then most-overdue-first — so a focus +// channel due by a minute outranks a low channel due by a day. +export function tierOrder(tier: ChannelTier): number { + const i = CHANNEL_TIERS.indexOf(tier); + return i < 0 ? CHANNEL_TIERS.length : i; +} + +// siteId -> the channel slugs that site exposes. Built by the caller from +// `transcripts/sites/*/site.json`; this module does no I/O. +export type SiteChannelIndex = Readonly<Record<string, readonly string[]>>; + +// The channels the focus selector currently names, in compile order. +// +// `known` is every channel slug that exists. When supplied, a focus naming a +// slug that is not there drops it — a site whose membership list has outrun the +// corpus, or a hand-edited settings.json. When omitted there is no existence +// filter, which is what a caller that already holds a filtered list wants. +// +// AN UNKNOWN siteId RESOLVES TO `[]`, deliberately: an empty resolution +// compiles NO focus group, so a typo leaves the tree exactly as it would be +// with no focus at all rather than holding the whole corpus behind a site that +// does not exist. +export function resolveFocusSlugs( + model: ChannelPriority, + siteChannels: SiteChannelIndex, + known?: readonly string[], +): string[] { + const focus = model.focus; + const raw = + focus.kind === "site" + ? trimmedSlugs(siteChannels[focus.siteId] ?? []) + : focus.kind === "channels" + ? trimmedSlugs(focus.slugs) + : []; + if (raw.length === 0) return []; + const exists = known ? new Set(known) : null; + return raw.filter((slug) => (exists ? exists.has(slug) : true)); +} + +// --- Compiled-tree ids ------------------------------------------------------ + +// Ids are DERIVED and recognisable on sight, for the same reason +// `laneRootFromScope`'s are: a lane's fairness memory and its pick log are +// keyed by leaf id, so a stable id means a recompile that did not move a +// channel keeps the ledger it had — and a hand-authored leaf is identifiable +// precisely because it does NOT carry this prefix. +export const PRIO_ID_PREFIX = "prio-"; +export const PRIO_CATCH_ALL_ID = "prio-all"; + +export function prioGroupId(tier: ChannelTier): string { + return `${PRIO_ID_PREFIX}${tier}`; +} + +export function prioLeafId(tier: ChannelTier, slug: string): string { + return `${PRIO_ID_PREFIX}${tier}-${slug}`; +} + +// `prio-normal-foo` -> `{tier:"normal", slug:"foo"}`; anything else -> null. +// The catch-all is not a channel leaf and answers null. +export function parsePrioLeafId( + id: string, +): { tier: ChannelTier; slug: string } | null { + if (!id.startsWith(PRIO_ID_PREFIX)) return null; + const rest = id.slice(PRIO_ID_PREFIX.length); + for (const tier of CHANNEL_TIERS) { + const head = `${tier}-`; + if (!rest.startsWith(head)) continue; + const slug = rest.slice(head.length); + return slug ? { tier, slug } : null; + } + return null; +} + +// --- The compiler ----------------------------------------------------------- + +// Within a group: `rank` ascending, unranked last, then slug. ONE ordering for +// all three groups, including focus — the operator's tier document is the only +// place order is expressed, so a site focus and a "focus these" focus cannot +// disagree about what comes first. +function orderWithin(model: ChannelPriority, slugs: readonly string[]): string[] { + return [...slugs].sort((a, b) => { + const ra = rankOf(model, a); + const rb = rankOf(model, b); + if (ra !== rb) { + if (ra === null) return 1; + if (rb === null) return -1; + return ra - rb; + } + return a.localeCompare(b); + }); +} + +function channelLeaf(tier: ChannelTier, slug: string): AutoQueueLeaf { + return { + id: prioLeafId(tier, slug), + match: { type: "channel", value: slug }, + weight: 1, + maxWorkers: null, + }; +} + +function tierGroup(tier: ChannelTier, slugs: readonly string[]): AutoQueueGroup { + return { + id: prioGroupId(tier), + // STRICT at every level. The outer strict is what makes focus hold the rest; + // the inner strict is what the two hand-made lists already meant — they + // walked their channels in order and never interleaved. + mode: "strict", + weight: 1, + maxWorkers: null, + children: slugs.map((slug) => channelLeaf(tier, slug)), + }; +} + +// THE COMPILED TREE, per lane: +// +// root strict +// ├─ prio-focus strict — one bare channel leaf per focus slug (omitted when empty) +// ├─ prio-normal strict — one bare channel leaf per normal slug (omitted when empty) +// ├─ prio-low strict — one bare channel leaf per low slug (omitted when empty) +// └─ prio-all leaf {type:"all"} ← safety net, LAST +// +// `slugs` is every channel the lane may draw from; paused channels are filtered +// here as well as out of the runner's channel list, so a caller that hands the +// whole corpus in still gets a tree with no paused leaf. +// +// The trailing catch-all only ever claims a channel with NO leaf of its own — +// i.e. one created since the last compile — so drift is always SAFE (bottom +// priority) and self-heals on the next write. Paused channels never reach it +// because they are gone from the runner's channel list entirely. +// +// `lane` is used for the ROOT id only (`<lane>-root`, the spelling +// `laneRootFromScope` already produces). The four lanes compile to the same +// shape by design: the model has ONE rank per channel, not one per lane. +export function compileLaneRoot( + lane: AutoQueueKind, + model: ChannelPriority, + slugs: readonly string[], + focusSlugs: readonly string[], +): AutoQueueGroup { + // PER LANE, through the effective tier: a channel paused for `download` and + // normal for `transcription` is absent from one tree and present in the other, + // which is the whole point of the override map. The four lanes are only + // identical when no channel overrides one of them. + const live = trimmedSlugs([...slugs]).filter( + (slug) => !isChannelPaused(model, slug, lane), + ); + const focusSet = new Set( + trimmedSlugs([...focusSlugs]).filter((slug) => live.includes(slug)), + ); + const focus: string[] = []; + const normal: string[] = []; + const low: string[] = []; + for (const slug of live) { + // FOCUS WINS OVER THE STORED TIER (a focused `low` channel is focused); + // paused wins over focus, and is already gone above. + if (focusSet.has(slug)) focus.push(slug); + else if (effectiveTier(model, slug, lane) === "low") low.push(slug); + else normal.push(slug); + } + const children: AutoQueueNode[] = []; + for (const [tier, members] of [ + ["focus", focus], + ["normal", normal], + ["low", low], + ] as ReadonlyArray<[ChannelTier, string[]]>) { + if (members.length === 0) continue; + children.push(tierGroup(tier, orderWithin(model, members))); + } + children.push({ + id: PRIO_CATCH_ALL_ID, + match: { type: "all" }, + weight: 1, + maxWorkers: null, + }); + return { + id: `${lane}-root`, + mode: "strict", + weight: 1, + maxWorkers: null, + children, + }; +} + +// Every lane's root, from one model. The writer that persists the document +// assigns these onto `autoQueue[lane].root` while SPREADING each policy, so +// `held`, `snoozeUntil`, `enabled`, `order` and `maxWorkers` survive. +// +// The four trees are identical UNLESS a channel overrides a lane — the model +// has one rank per channel, so the only thing that can differ between lanes is +// membership, and the only thing that changes membership is an override. +export function compileLanes( + model: ChannelPriority, + slugs: readonly string[], + focusSlugs: readonly string[], +): Record<AutoQueueKind, AutoQueueGroup> { + return Object.fromEntries( + LANES.map((lane) => [lane, compileLaneRoot(lane, model, slugs, focusSlugs)]), + ) as Record<AutoQueueKind, AutoQueueGroup>; +} + +// --- The banner's numbers --------------------------------------------------- + +// `buildPendingByLeaf`'s return shape, named structurally: lib/ may not import +// jobs/ (../architecture.test.ts), and this is the only thing of the engine's +// this module needs to read. +export type PendingByLeaf = Readonly<Record<string, readonly string[]>>; + +export type FocusSummary = { + kind: ChannelFocus["kind"]; + // Set only for a site focus, so a caller can resolve the site's title. + siteId: string | null; + // The resolved focus set, in compile order. + slugs: readonly string[]; + channelCount: number; + // A focus that resolved to nothing is not active — see resolveFocusSlugs. + active: boolean; + // Units pending under the compiled focus group, in the lane these counts came + // from. + focusPending: number; + // Units pending everywhere else in that lane, catch-all included. + otherPending: number; + // The focus is actually HOLDING the lane right now: it has work, so strict + // descent never reaches the groups below it. + holding: boolean; + // How many non-focus channels have pending work while the focus holds. This + // is a DISPLAY fact, not an idle reason: a lane whose focus group holds the + // rest is not idle, it is dispatching focus work. + heldChannels: number; +}; + +// The banner's line — "Focus: <name> (N channels) · <units> pending in this +// lane · M channels held" — from numbers the status panel already computed. +// Costs one pass over `pendingByLeaf`'s keys and no new read. +export function focusSummary( + model: ChannelPriority, + focusSlugs: readonly string[], + pendingByLeaf: PendingByLeaf = {}, +): FocusSummary { + let focusPending = 0; + let otherPending = 0; + let heldChannels = 0; + for (const [id, ids] of Object.entries(pendingByLeaf)) { + const count = ids?.length ?? 0; + const parsed = parsePrioLeafId(id); + if (parsed?.tier === "focus") { + focusPending += count; + continue; + } + otherPending += count; + if (parsed && count > 0) heldChannels += 1; + } + const holding = focusPending > 0; + return { + kind: model.focus.kind, + siteId: model.focus.kind === "site" ? model.focus.siteId : null, + slugs: focusSlugs, + channelCount: focusSlugs.length, + active: model.focus.kind !== "none" && focusSlugs.length > 0, + focusPending, + otherPending, + holding, + heldChannels: holding ? heldChannels : 0, + }; +} + +// --- The migration's pure half ---------------------------------------------- + +// Just enough of a channel row to migrate it. `listChannelConfigs`' rows +// satisfy this structurally, so the caller hands them straight over and this +// file does not have to name `ChannelConfig`. +export type LegacyChannelRow = { + slug: string; + config: { excludeFromSync?: boolean | undefined }; +}; + +// Just enough of `settings.autoQueue`: `AutoQueueSettings` is assignable. +export type LegacyLaneRoots = Partial< + Record<AutoQueueKind, { root?: AutoQueueNode | undefined } | undefined> +>; + +// The two lanes that carry a hand-made channel order today. Digest and backfill +// are one `{type:"all"}` leaf apiece and contribute no ranking. +const LEGACY_RANKED_LANES: readonly AutoQueueKind[] = [ + "transcription", + "download", +]; + +function bareChannelSlugs(root: AutoQueueNode | undefined): string[] { + if (!root) return []; + const out: string[] = []; + const walk = (node: AutoQueueNode): void => { + if (isGroup(node)) { + for (const child of node.children) walk(child); + return; + } + // BARE means a channel leaf and nothing else: a leaf narrowed by + // `match.bucket` or `match.operation` is a different statement (a retry + // lane, an operation subtree) and the priority model has no way to say it, + // so it must not be read as a rank. Zero live leaves carry either. + if (node.match.type !== "channel") return; + const slug = typeof node.match.value === "string" ? node.match.value.trim() : ""; + if (!slug || node.match.bucket || node.match.operation) return; + if (!out.includes(slug)) out.push(slug); + }; + walk(root); + return out; +} + +// THE LEGACY READ: one exclusion flag and two hand-made lane orders become one +// document. Pure, no I/O, and asserted through `sanitizeChannelPriority` — the +// same shape `lib/laneMigration.ts` uses, for the same reason. +// +// IT IS LOSSLESS, AND THAT IS THE POINT OF THE OVERRIDE MAP. `excludeFromSync` +// meant "stop syncing", not "stop everything", and the override map says +// exactly that: `{tier: <base>, overrides: {sync: "paused"}}`. So a channel +// that is both excluded from sync and ranked in the download tree — on the live +// corpus, `omnivods-odysee` — KEEPS DOWNLOADING, exactly as it does today. No +// lane's membership moves, which is what makes the migration a settings rewrite +// rather than a behaviour change, and which retires the plan's open question +// about which of the 15 excluded channels should be paused outright: none of +// them are, by construction. An operator who wants one paused everywhere sets +// its base tier afterwards, deliberately. +// +// THREE RULES: +// +// 1. `excludeFromSync: true` BECOMES A SYNC OVERRIDE, never a base tier. The +// base tier stays `normal` and the rank (if the channel has one) stands. +// 2. THE TWO LANE ORDERS COLLAPSE TO ONE. A channel named by a bare channel +// leaf in EITHER root gets the LOWER of its two indices as its rank — its +// position among that root's BARE CHANNEL LEAVES, which is a dense order +// and is the leaf index exactly when every leaf is bare. All 22 live +// leaves are, so on the live tree the two readings coincide; on a +// hand-edited tree the dense one is the one that means something, because +// a bucket leaf the model cannot express must not leave a hole in the rank. +// The live lists disagree on six channels and on the Quartering ordering; +// the model has one rank per channel, so one of the two orders has to give +// and "whichever lane ranked it higher" is the answer that loses no +// priority. This is the one thing the migration DOES change, and it is +// measured with plans/tools/phase1-numbers.ts. +// 3. EVERYTHING ELSE IS ABSENT — normal, unranked — and the focus starts at +// `none`. A migration does not start a focus. +// +// `excludeFromBuild` is NOT read: it is a different axis (publishing, not +// scheduling) and the lowest tier must not gate export. +export function channelPriorityFromLegacy( + configs: readonly LegacyChannelRow[], + autoQueue: LegacyLaneRoots, +): ChannelPriority { + const syncPaused = new Set<string>(); + for (const row of configs) { + const slug = typeof row?.slug === "string" ? row.slug.trim() : ""; + if (!slug) continue; + if (row.config?.excludeFromSync === true) syncPaused.add(slug); + } + const ranks = new Map<string, number>(); + for (const lane of LEGACY_RANKED_LANES) { + const slugs = bareChannelSlugs(autoQueue[lane]?.root); + slugs.forEach((slug, index) => { + const seen = ranks.get(slug); + if (seen === undefined || index < seen) ranks.set(slug, index); + }); + } + const channels: Record<string, ChannelPriorityEntry> = {}; + const touch = (slug: string): ChannelPriorityEntry => + (channels[slug] ??= { tier: DEFAULT_CHANNEL_TIER }); + for (const slug of syncPaused) { + touch(slug).overrides = { sync: "paused" }; + } + for (const [slug, rank] of ranks) { + touch(slug).rank = rank; + } + return sanitizeChannelPriority({ focus: { kind: "none" }, channels }); +} diff --git a/common/lib/settings.ts b/common/lib/settings.ts @@ -21,6 +21,11 @@ import { } from "./workers"; import type { AutoQueueSettings } from "./autoQueueTypes"; import { + defaultChannelPriority, + sanitizeChannelPriority, + type ChannelPriority, +} from "./channelPriority"; +import { migrateHeldToLanes, migrateSweepsToLanes, } from "./laneMigration"; @@ -63,6 +68,7 @@ import { export type { Worker } from "./workers"; export type { AutoQueueSettings } from "./autoQueueTypes"; +export type { ChannelPriority } from "./channelPriority"; // Transcribe placeholder/arg helpers now live with the whisper-cpp app in // transcriptionApps.ts. Re-exported here so existing import sites keep working. @@ -198,6 +204,14 @@ export type SiteSettings = { // Independent of syncScheduler (which decides staleness, not work order). See // common/jobs/autoQueuePolicy.ts. autoQueue: AutoQueueSettings; + // THE OPERATOR-FACING PRIORITY MODEL: one tier per channel plus one + // corpus-wide focus selector. It is the SOURCE the four `autoQueue[lane].root` + // trees are compiled from (common/lib/channelPriority.ts), not a second + // mechanism beside them — and its `paused` tier is the one part that is not a + // tree shape, filtering the runner's channel list instead. An empty document + // (the default) is today's behaviour exactly: no focus, every channel normal, + // the stored trees stand. + channelPriority: ChannelPriority; // Default social links applied to every site that doesn't define its own. // A site inherits these unless its site.json carries an explicit // `socialLinks` array — see Site.socialLinks / resolveSocialLinks in @@ -1016,6 +1030,7 @@ function defaults(): SiteSettings { autoRefreshIntervalSeconds: AUTO_REFRESH_INTERVAL_DEFAULT_SECONDS, syncScheduler: defaultSyncScheduler(), autoQueue: defaultAutoQueue(), + channelPriority: defaultChannelPriority(), socialLinks: [], homepageUrl: "", savedVideoBackup: defaultSavedVideoBackup(), @@ -1401,6 +1416,11 @@ export function getSettings(): SiteSettings { // no longer tell "absent" from "default". It never enables a lane the sweep // flag did not. See lib/laneMigration.ts. merged.autoQueue = sanitizeAutoQueue(migrateSweepsToLanes(parsed)); + // No migration beside it: the legacy read (`channelPriorityFromLegacy`) needs + // 68 config.json files and getSettings is synchronous and reads one. It is a + // one-shot offline script instead, and an absent document sanitizes to the + // empty one, which means today's behaviour. + merged.channelPriority = sanitizeChannelPriority(merged.channelPriority); merged.socialLinks = parseSocialLinks(merged.socialLinks); merged.homepageUrl = normalizeHomepageUrl(merged.homepageUrl); merged.savedVideoBackup = sanitizeSavedVideoBackup(merged.savedVideoBackup); @@ -1611,6 +1631,7 @@ export async function writeSettings(next: SiteSettings): Promise<void> { ), syncScheduler: sanitizeSyncScheduler(next.syncScheduler), autoQueue: sanitizeAutoQueue(next.autoQueue), + channelPriority: sanitizeChannelPriority(next.channelPriority), socialLinks, homepageUrl: normalizeHomepageUrl(next.homepageUrl), savedVideoBackup: sanitizeSavedVideoBackup(next.savedVideoBackup), diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -237,6 +237,11 @@ export async function saveSettingsAction( // (this form doesn't edit it; the Auto-queue page does). writeSettings // re-sanitizes it regardless. autoQueue: getSettings().autoQueue, + // Preserved for the same reason, and for one more: it is the SOURCE the + // four roots above are compiled from, so rebuilding it here would silently + // undo a focus. Edited on /channels by saveChannelPriorityAction, which is + // its one writer. + channelPriority: getSettings().channelPriority, socialLinks, homepageUrl, // Preserve the saved-video backup config on an unrelated settings save (the