// 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"; import type { FieldDocs } from "./fieldDocs"; // --- 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 const CHANNEL_FOCUS_FIELD_DOCS: FieldDocs = { kind: "\"none\" (no focus), \"site\" (the channels of one site, resolved at " + "compile time so it tracks membership) or \"channels\" (an explicit " + "list, from \"Focus these\").", siteId: "kind \"site\" only: the site whose channels are focused. A blank id " + "reads as no focus; an unknown one survives and focuses nothing.", slugs: "kind \"channels\" only: the focused channel slugs, trimmed and " + "de-duplicated. An empty list reads as no focus.", }; // Each field is documented in CHANNEL_PRIORITY_ENTRY_FIELD_DOCS below (rendered into SETTINGS.md). export type ChannelPriorityEntry = { tier: StoredChannelTier; rank?: number; overrides?: Partial>; autoPaused?: ChannelAutoPause; }; export const CHANNEL_PRIORITY_ENTRY_FIELD_DOCS: FieldDocs = { tier: "THE BASE TIER: what every operation gets unless an override says " + "otherwise.", rank: "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.", overrides: "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.\n\n" + "`{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.", autoPaused: "PAUSED BY THE MACHINE, NOT BY THE OPERATOR, and what to put back.\n\n" + "Set when the drive a channel's media is on stops being there: the " + "watch pass records the tier the channel HAD and forces `paused`, so " + "nothing in any lane dispatches against a `data/` nobody can read. " + "Cleared — and the tier restored — when the drive comes back.\n\n" + "WHY IT IS A FIELD AND NOT A DERIVED STATE. The lanes read `tier`; " + "making them all ask a second question would be four more places to " + "forget. And the tier the channel is to be RESTORED to is not derivable" + " from anything once it has been overwritten — that is the whole " + "content of this field.\n\n" + "OPTIONAL, and an older binary that drops it leaves the channel Paused " + "with nothing lost but the automatic restore. The operator's own word " + "always wins: a MANUAL tier change clears it (see clearAutoPause), so a" + " drive coming back can never un-pause a channel somebody paused on " + "purpose.", }; // The machine's pause record on a channel entry (see `autoPaused` above). // Each field is documented in CHANNEL_AUTO_PAUSE_FIELD_DOCS below (rendered into SETTINGS.md). export type ChannelAutoPause = { reason: "storage"; since: string; previousTier: StoredChannelTier; cause?: AutoPauseCause; }; // Which storage trouble paused it: the drive is not there (unmounted, // unplugged), or it is there and not answering (lib/storageHealth.ts). A record // with none was written before the second existed, when the first was the only // one. export type AutoPauseCause = "not-there" | "not-answering"; export const CHANNEL_AUTO_PAUSE_FIELD_DOCS: FieldDocs = { reason: "One reason today. A union so a second one has somewhere to go, and so " + "a surface can say WHICH machine decided rather than \"automatic\".", since: "ISO, for \"auto-paused — media unreachable since \".", previousTier: "The base tier the channel had before the machine paused it; what a " + "restore puts back. Never `paused` (that would restore to paused — a " + "no-op dressed as a restore).", cause: "`not-there` (the drive is unmounted or unplugged) or `not-answering` (it " + "is there and does not answer: a stalled disk). Only what the words on " + "/review, the rack and the channel page say. Optional: a record written " + "before it existed reads as `not-there`.", }; // Each field is documented in CHANNEL_PRIORITY_FIELD_DOCS below (rendered into SETTINGS.md). export type ChannelPriority = { focus: ChannelFocus; channels: Record; }; export const CHANNEL_PRIORITY_FIELD_DOCS: FieldDocs = { focus: "The corpus-wide focus selector: none, one site's channels, or a list of channels. A focus is compiled into a leading `prio-focus` group in every lane's tree.", channels: "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.", }; 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; 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> | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; } const raw = value as Record; const out: Partial> = {}; 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; const rawChannels = r.channels && typeof r.channels === "object" && !Array.isArray(r.channels) ? (r.channels as Record) : {}; const channels: Record = {}; // 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; // 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 }; // AUTO-PAUSE FIRST, BECAUSE THE OVERRIDES ARE NORMALISED AGAINST THE BASE // TIER — AND WHILE THE MACHINE'S PAUSE STANDS, `tier` IS NOT IT. // // `sanitizeOverrides` drops any override equal to the base, which is what // keeps the document a list of exceptions. Measure that against the FORCED // `paused` and every `{op: "paused"}` fence the operator set is an // "exception" that is no longer an exception, so it is deleted — and the // restore then hands back a channel with the fence gone. Reproduced: // `{tier:"normal", overrides:{sync:"paused"}}` auto-paused and restored // came back as a bare `{tier:"normal"}`. On this corpus that is // legal-mindset losing both its fences, and cornbreadman — which lives on // the platter with `overrides:{sync:"paused"}` — losing its sync fence to // one hiccup of a USB cable. // // `previousTier` is the base the operator actually set, so that is what the // exceptions are exceptions to. const autoPaused = sanitizeAutoPause(raw.autoPaused, tier); if (autoPaused) entry.autoPaused = autoPaused; const overrides = sanitizeOverrides( raw.overrides, autoPaused ? autoPaused.previousTier : 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", ); // AN AUTO-PAUSED CHANNEL KEEPS ITS RANK. The rule above is about a pause // the operator MEANT — a channel that is off everywhere is in no group // and its rank is dead weight in the file. An auto-pause is temporary by // construction (the record exists precisely to undo it), so dropping the // rank here would mean an unplugged USB cable silently destroyed the // operator's queue order, and the restore would put the channel back // unranked at the bottom of its tier. if (orderedSomewhere || autoPaused) 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 && entry.autoPaused === undefined ) { continue; } channels[slug] = entry; } return { focus: sanitizeFocus(r.focus), channels }; } // AN AUTO-PAUSE ONLY MEANS ANYTHING ON A PAUSED CHANNEL. A record whose tier // is not `paused` is a leftover — the operator changed the tier through some // path that did not clear it, or the file was hand-edited — and keeping it // would make the next restore put back a tier from an earlier era. Dropped. function sanitizeAutoPause( value: unknown, tier: StoredChannelTier, ): ChannelPriorityEntry["autoPaused"] | null { if (tier !== "paused") return null; if (!value || typeof value !== "object" || Array.isArray(value)) return null; const r = value as Record; if (r.reason !== "storage") return null; const previousTier: StoredChannelTier = isStoredChannelTier(r.previousTier) ? r.previousTier : DEFAULT_CHANNEL_TIER; // A `previousTier` of `paused` would restore to paused, which is a no-op // dressed as a restore. It reads as the default instead. return { reason: "storage", since: typeof r.since === "string" ? r.since : "", previousTier: previousTier === "paused" ? DEFAULT_CHANNEL_TIER : previousTier, ...(r.cause === "not-there" || r.cause === "not-answering" ? { cause: r.cause } : {}), }; } // --- Auto-pause: the machine's own pause, and its undo ---------------------- // PAUSE A CHANNEL BECAUSE ITS DRIVE IS NOT THERE (OR NOT ANSWERING), recording // what to put back, and which of the two it was. // // A NO-OP IN TWO CASES, and both matter. Already auto-paused: a flapping drive // must not overwrite `previousTier` with the `paused` it wrote last time, which // would make the restore restore nothing. Already paused by the OPERATOR: the // channel is off because somebody said so, and claiming the machine did it // would hand the next restore permission to turn it back on. export function autoPauseForMedia( model: ChannelPriority, slug: string, now: Date = new Date(), cause?: AutoPauseCause, ): ChannelPriority { const key = slug.trim(); if (!key) return model; const entry = model.channels[key]; if (entry?.autoPaused) return model; const previousTier = entry?.tier ?? DEFAULT_CHANNEL_TIER; if (previousTier === "paused") return model; return { ...model, channels: { ...model.channels, [key]: { ...(entry ?? { tier: DEFAULT_CHANNEL_TIER }), tier: "paused", autoPaused: { reason: "storage", since: now.toISOString(), previousTier, ...(cause ? { cause } : {}), }, }, }, }; } // THE UNDO, and only of what this made. No record → nothing to restore, and in // particular a channel the operator paused by hand stays paused. export function restoreAfterMedia( model: ChannelPriority, slug: string, ): ChannelPriority { const key = slug.trim(); const entry = model.channels[key]; if (!entry?.autoPaused) return model; const { autoPaused, ...rest } = entry; return { ...model, channels: { ...model.channels, [key]: { ...rest, tier: autoPaused.previousTier }, }, }; } // THE OPERATOR'S WORD WINS. Called by the one priority writer whenever a tier // is set by hand: the record goes, so a drive coming back later cannot undo a // decision a person made in the meantime. export function clearAutoPause( entry: ChannelPriorityEntry, ): ChannelPriorityEntry { if (!entry.autoPaused) return entry; const { autoPaused: _dropped, ...rest } = entry; return rest; } // The sentence a surface says beside a Paused badge, or null. ONE wording, so // the rack, the channel page and /review cannot word it three ways. export function autoPauseReasonOf( model: ChannelPriority, slug: string, ): string | null { const auto = model.channels[slug]?.autoPaused; if (!auto) return null; const since = auto.since ? ` since ${auto.since.slice(0, 10)}` : ""; if (auto.cause === "not-answering") { return ( `Auto-paused — its media is on a drive that is not answering${since}. ` + `It returns to ${auto.previousTier} on its own when the drive answers again.` ); } return ( `Auto-paused — its media is on a drive that is not there${since}. ` + `It returns to ${auto.previousTier} on its own when the drive is back.` ); } // Every channel this document has auto-paused, sorted. What the watch pass asks // so it can restore the ones whose drives came back. export function autoPausedSlugs(model: ChannelPriority): string[] { return Object.entries(model.channels) .filter(([, e]) => e.autoPaused) .map(([slug]) => slug) .sort(); } // --- 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> { 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. // THE GATE ON THE WHOLE COMPILER, and the plan's "absent document = today's // behaviour, byte for byte": no focus and no per-channel entry means the // document says nothing, the compiler never runs, and the stored trees stand. // // It lives HERE, in the model, because five callers in two packages ask it and // they must all ask it the same way: the runner (`laneDispatchRoot`), the // status payload, the one writer (both for its legacy seed and for the rule // that a default document never overwrites a stored tree), the backfill's // keep decision, and the two actions that may no longer write a lane's root. // S1 kept a copy in controller/autoRunner.ts and S4 a second in the editor; // S5 deleted both. A controller may not import the runner without opening an // import cycle, which is the other half of why the model owns it. export function isDefaultChannelPriority(model: ChannelPriority): boolean { return model.focus.kind === "none" && Object.keys(model.channels).length === 0; } // A CHANNEL WAS RENAMED, so the document has to follow it. // // The model keys everything by slug — the entry, and the slugs a // `{kind:"channels"}` focus names — so a rename that does not pass through // here loses the channel's tier, its rank and its per-operation overrides // silently (they stay under a slug that no longer exists, and the sanitizer // has no way to know they are stale), and drops the channel out of a focus // that was explicitly naming it. // // Pure and total: an unknown `from` is a no-op, a rename ONTO an existing // entry keeps the destination's entry rather than clobbering it — the channel // controller refuses that rename anyway, so the only way to get here is a // document that is already inconsistent, and losing the live channel's // settings to a dead one is the worse of the two readings. A `{kind:"site"}` // focus needs nothing: it resolves through the site's own `channels[]`, which // the rename updates on disk. export function renameChannelInPriority( model: ChannelPriority, from: string, to: string, ): ChannelPriority { const oldSlug = from.trim(); const newSlug = to.trim(); if (!oldSlug || !newSlug || oldSlug === newSlug) return model; // Nothing in the document mentions the old slug: return the INPUT, not a // copy of it. The writer recompiles on the result either way, so this is // about being honest that nothing moved rather than about the allocation. const named = model.channels[oldSlug] !== undefined || (model.focus.kind === "channels" && model.focus.slugs.includes(oldSlug)); if (!named) return model; const channels: Record = {}; for (const [slug, entry] of Object.entries(model.channels)) { if (slug === oldSlug) continue; channels[slug] = entry; } const moved = model.channels[oldSlug]; if (moved && channels[newSlug] === undefined) channels[newSlug] = moved; const focus: ChannelFocus = model.focus.kind === "channels" ? { kind: "channels", slugs: model.focus.slugs.map((s) => (s === oldSlug ? newSlug : s)), } : model.focus; return { focus, channels }; } 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>; // 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 (`-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 { return Object.fromEntries( LANES.map((lane) => [lane, compileLaneRoot(lane, model, slugs, focusSlugs)]), ) as Record; } // --- 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>; 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: (N channels) · 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, named structurally so this file // does not have to import `ChannelConfig`. // // NOTE the one key is gone from `ChannelConfig` (S5) and `parseChannelConfig` // drops it, so a PARSED config no longer satisfies this usefully — the // migration script reads the raw config.json for it // (common/bin/migrate-channel-priority.ts), and the editor's legacy seed // passes `{}` because it is after the lane ORDER, not the flag. export type LegacyChannelRow = { slug: string; config: { excludeFromSync?: boolean | undefined }; }; // Just enough of `settings.autoQueue`: `AutoQueueSettings` is assignable. export type LegacyLaneRoots = Partial< Record >; // 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; } // Does any lane's stored root already carry a COMPILED leaf? // // `prio-*` ids are produced by `compileLaneRoot` and by nothing else, which is // what makes them a reliable "this tree was written by the priority writer" // marker — and the marker two callers need before they read a tree as legacy: // // - `channelPriorityFromLegacy` (below), so a second migration run cannot // re-derive ranks from its own output; // - the one writer's legacy seed (editor/app/channels/actions.ts), so a // document that was cleared back to empty on a corpus whose trees are // already compiled is not re-seeded from those compiled trees. // // Both hazards are the same one, and it is not hypothetical: compiled channel // leaves ARE bare channel leaves, so `bareChannelSlugs` reads them happily and // would replace a hand-made 9+9 order with a reading of the tree that order // already produced — dense, alphabetical inside each tier, and irrecoverable. export function hasCompiledLaneRoots(autoQueue: LegacyLaneRoots): boolean { const seen = (node: AutoQueueNode | undefined): boolean => { if (!node) return false; if (typeof node.id === "string" && node.id.startsWith(PRIO_ID_PREFIX)) { return true; } return isGroup(node) ? node.children.some(seen) : false; }; return LANES.some((lane) => seen(autoQueue[lane]?.root)); } // 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: , 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. // // THE MERGED RANKS ARE THEN RENUMBERED DENSELY, 0..n-1, and that is not // cosmetic: the merge produces COLLISIONS (four pairs on the live trees — // `the-quartering` and `the-quartering-rumble` both land on 1, because // each lane ranked one of them second), and a collision is resolved by // `orderWithin`'s slug fallback, which is NEITHER lane's order. The tie // rule, in order: the lane that ranked the channel higher (that is the // merged index itself), then the TRANSCRIPTION lane's own order — the // longer-standing of the two hand-made lists, and the one that ranks the // Quartering channels the way the operator most recently arranged them — // then the slug. A channel the transcription lane never ranked sorts // after every channel it did, within the same merged index. // // 4. IT DOES NOT RE-DERIVE FROM ITS OWN OUTPUT. A compiled tree is made of // bare channel leaves, so rule 2 would read one perfectly happily and // collapse a hand-made order into a reading of the tree that order // produced. `hasCompiledLaneRoots` is the detector; `stored` is what is // returned instead, so a second migration run is a no-op rather than a // quiet rewrite. // 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, // The document already on disk. Returned unchanged when the trees are // compiled — see rule 4. Defaults to the empty document, which is what a // caller with nothing stored has anyway. stored: ChannelPriority = defaultChannelPriority(), ): ChannelPriority { if (hasCompiledLaneRoots(autoQueue)) return sanitizeChannelPriority(stored); const syncPaused = new Set(); 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 merged = new Map(); for (const lane of LEGACY_RANKED_LANES) { const slugs = bareChannelSlugs(autoQueue[lane]?.root); slugs.forEach((slug, index) => { const seen = merged.get(slug); if (seen === undefined || index < seen) merged.set(slug, index); }); } // The tie-break lane's own order, for the renumbering below. const transcription = bareChannelSlugs(autoQueue.transcription?.root); const tieIndex = (slug: string): number => { const i = transcription.indexOf(slug); return i === -1 ? Number.POSITIVE_INFINITY : i; }; const ranks = new Map(); [...merged.entries()] .sort( ([aSlug, a], [bSlug, b]) => a - b || tieIndex(aSlug) - tieIndex(bSlug) || aSlug.localeCompare(bSlug), ) .forEach(([slug], index) => ranks.set(slug, index)); const channels: Record = {}; 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 }); }