Archilyzer · Source

archilyzer

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

commit 51f86a9d94361bf8aed95e7b982e5d4145fb6b51
parent 4444457bf8f6b6b0b60393e2fce179bde1d00b85
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 18:24:52 -0400

priority: a rename moves the document, and the migration guards its own rollout

Three review should-fixes.

THE ROLLOUT HAZARD IS NAMED AS ONE. `excludeFromSync` is deleted and the parser
drops the key, so until the migration runs the 15 channels that carried it read
as saying nothing about sync — and four places act on that from the first
render: `syncScheduler.ts:132`, `:259` (`autoSyncEligible`, so the projection
changes on sight), `channels/actions.ts:524` (Sync all) and
`channelGroupSections.ts:140` (a group's Sync). The automatic tick is off on
the live box, but one Sync all click in that window sweeps channels the
operator had excluded. The changelog said "the editor behaves exactly as it
does today" — it does not, and it now says so and says MIGRATE BEFORE THE
FIRST BOOT. STATE.md and the plan's rollout lead with the warning rather than
the recipe.

And the backup is the SCRIPT's job now: `settings.json.pre-priority-<ISO>`
beside the file before the first write, `flag: "wx"` so it refuses to overwrite
an earlier one, skipped in --dry-run. An operator step on the destructive path
is a step someone skips.

RENAME IS A PRIORITY WRITER. The model keys everything by slug, so a rename
that did not pass through it left the channel's tier, rank and overrides under
a slug that no longer exists — silently, because nothing downstream can tell a
stale entry from a deliberate one — dropped the channel out of a
`{kind:"channels"}` focus, and left a `prio-*-<oldSlug>` leaf in every compiled
root. `renameChannelInPriority` is the pure re-key (unknown slug returns the
INPUT, a rename onto an existing entry keeps the destination's — the channel
controller refuses that rename, so the only way there is an already-inconsistent
document and the live channel's settings are the ones worth keeping); the write
goes through the one writer, like create and delete, and neither seeds.

Three nits: the runner resolves its focus against `known` now, so its
"M channels held" line cannot differ from the banner's by a stale focus slug —
`listChannelMeta` returns the pre-filter slug list beside the filtered meta off
the one read, and the meta refresh moved ahead of the context so the TTL feeds
it; the migration's recompile gate matches the writer's (`hasCompiledLaneRoots`
too, or ending a focus via the script would leave a stale `prio-focus` head);
and the onboarding spec's title says what it now asserts.

common 988/988; tsc clean in common and editor.

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

Diffstat:
Mcommon/bin/migrate-channel-priority.ts | 38+++++++++++++++++++++++++++++++++-----
Mcommon/controller/autoRunner.ts | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcommon/lib/channelPriority.test.ts | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/channelPriority.ts | 48++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 2+-
Meditor/app/channels/actions.ts | 39+++++++++++++++++++++++++++++++++++----
Meditor/e2e/channel-priority.spec.ts | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/new-channel-onboarding.spec.ts | 2+-
Mplans/STATE.md | 23++++++++++++++++++++---
Mplans/channel-priority.md | 30++++++++++++++++++++++++++++--
10 files changed, 351 insertions(+), 29 deletions(-)

diff --git a/common/bin/migrate-channel-priority.ts b/common/bin/migrate-channel-priority.ts @@ -52,9 +52,16 @@ import { parseFlags } from "./_parseFlags"; // into the tree that order produced. And a config with no `excludeFromSync` // key is left alone rather than rewritten. // -// NEVER RUN AGAINST A LIVE EDITOR. Stop the server first (a running editor -// holds settings in memory and writes it back on its own schedule), and back -// the file up: `cp settings.json ~/settings.json.pre-priority`. +// RUN IT BEFORE THE FIRST BOOT OF THIS CODE, not after. `excludeFromSync` is +// DELETED, so until this has run the 15 channels that carried it are sync +// -eligible again: the scheduler's selection, `autoSyncEligible`, *Sync all* +// and every group's Sync all read the priority document and find nothing said +// about them. Stop the editor, run this, start it. +// +// NEVER RUN IT AGAINST A LIVE EDITOR — a running editor holds settings in +// memory and writes them back on its own schedule, so a migration underneath +// one is a write that gets overwritten. It takes its own backup (below), so +// there is no `cp` for the operator to forget. // // THE DRY RUN WRITES NOTHING AT ALL — not settings.json, not a config.json, // not a temp file. It is safe to point at a live corpus, and is how the table @@ -288,15 +295,36 @@ async function main(): Promise<void> { return; } + // THE BACKUP IS THIS SCRIPT'S JOB, not the operator's, because the operator + // step would be on the destructive path: a migration that says "back it up + // first" in a comment has already lost the file for anyone who did not. + // Written before the first real write, never in --dry-run, and it REFUSES to + // overwrite an existing name rather than clobber an earlier backup. + const backup = `${paths.settingsFile}.pre-priority-${new Date() + .toISOString() + .replace(/[:.]/g, "-")}`; + await writeFile(backup, await readFile(paths.settingsFile, "utf8"), { + flag: "wx", + }); + console.log(`\nbacked up ${paths.settingsFile} -> ${backup}`); + const autoQueue = { ...settings.autoQueue }; - if (!isDefaultChannelPriority(model)) { + // The same gate the one writer applies (editor/app/channels/actions.ts): a + // document that says nothing leaves a NEVER-COMPILED tree alone, but a tree + // the compiler has already written is recompiled regardless — there is no + // hand-made tree left to protect there, and a stale compiled tree is what + // the runner would dispatch from on its bypass. + if ( + !isDefaultChannelPriority(model) || + hasCompiledLaneRoots(settings.autoQueue) + ) { const roots = compileLanes(model, slugs, focusSlugs); for (const lane of LANES) { autoQueue[lane] = { ...settings.autoQueue[lane], root: roots[lane] }; } } await writeSettings({ ...settings, channelPriority: model, autoQueue }); - console.log(`\nwrote ${paths.settingsFile}`); + console.log(`wrote ${paths.settingsFile}`); let cleared = 0; for (const slug of slugs) { diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts @@ -312,18 +312,29 @@ const SNAPSHOT_READ_CONCURRENCY = 64; // `excludeFromSync` channels migrate to) is still drawn by the download lane, // and one with `{tier:"paused"}` or `overrides:{download:"paused"}` is not. // Re-evaluated on CHANNEL_LIST_TTL_MS, which is the clock for this decision. +// +// It returns the PRE-FILTER slug list beside the filtered meta, off the one +// read. `known` is an EXISTENCE filter for the focus resolver (a site whose +// membership has outrun the corpus, or a hand-edited settings.json), and the +// paused-filtered list is the wrong answer for it twice over: it would drop a +// focused channel that this lane happens to have paused, and it would make the +// runner's "M channels held" line disagree with the banner, which resolves +// against every channel (editor/app/operations/channelPriorityView.ts). async function listChannelMeta( paths: Paths, kind: AutoQueueKind, priority: ChannelPriority, -): Promise<ChannelMeta[]> { +): Promise<{ meta: ChannelMeta[]; slugs: string[] }> { const configs = await listChannelConfigs(paths); - return configs - .filter(({ slug }) => !isChannelPaused(priority, slug, kind)) - .map(({ slug, config }) => ({ - slug, - platform: detectPlatform(config.url), - })); + return { + meta: configs + .filter(({ slug }) => !isChannelPaused(priority, slug, kind)) + .map(({ slug, config }) => ({ + slug, + platform: detectPlatform(config.url), + })), + slugs: configs.map(({ slug }) => slug), + }; } // --- The compiled priority trees ------------------------------------------- @@ -383,6 +394,17 @@ type PriorityContext = PriorityDispatchContext & { focusSlugs: string[]; }; +// The key carries `known` because the focus set is resolved against it: a +// channel created or deleted since the last resolution changes the answer, and +// a cache keyed on the document alone would keep the old one for a minute. +function contextKey( + model: ChannelPriority, + paths: Paths, + known: readonly string[], +): string { + return JSON.stringify([model, paths.sitesDir, known]); +} + let priorityContext: PriorityContext | null = null; // `settings` is a PARAMETER, not a read: both call sites already hold the @@ -400,11 +422,15 @@ export function resetPriorityContextForTest(): void { export function priorityContextFor( paths: Paths, settings: SiteSettings, + // Every channel slug that exists — the existence filter for the focus, the + // same one the editor's status payload passes. Omitted only by a caller that + // genuinely has no list. + known: readonly string[] = [], ): 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]); + const key = contextKey(model, paths, known); const now = Date.now(); if ( priorityContext && @@ -423,7 +449,11 @@ export function priorityContextFor( key, at: now, model, - focusSlugs: resolveFocusSlugs(model, index), + focusSlugs: resolveFocusSlugs( + model, + index, + known.length > 0 ? known : undefined, + ), }; return priorityContext; } @@ -790,8 +820,12 @@ export async function computeLeafPending( // 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, settings); - const meta = await listChannelMeta(paths, kind, ctx.model); + const { meta, slugs } = await listChannelMeta( + paths, + kind, + settings.channelPriority, + ); + const ctx = priorityContextFor(paths, settings, slugs); const root = laneDispatchRoot(kind, policy, ctx, meta.map((m) => m.slug)); const laneOperations = laneOperationIds(kind); const { channels, owner } = await buildChannelWork( @@ -1021,6 +1055,8 @@ async function runLoop( const childJobIds = new Map<string, string>(); let metaCache: ChannelMeta[] = []; + // Every channel slug, pre-paused-filter, on the same TTL as metaCache. + let knownSlugs: string[] = []; let metaAt = 0; // The root the last pick was made from. next() sets it every tick; // runOperationPick reads it to resolve the leaf its own pick named. @@ -1302,12 +1338,23 @@ async function runLoop( // `metaCache.length === 0` is the cache-miss test now: a lane on which // 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. + // + // THE META REFRESH COMES FIRST, because the focus resolution needs the + // channel list it produces: `known` is what drops a focus slug the corpus + // no longer has, and resolving without it makes this lane's "M channels + // held" line disagree with the banner by one. const now = Date.now(); - const ctx = priorityContextFor(paths, settings); if (metaAt === 0 || now - metaAt > CHANNEL_LIST_TTL_MS) { - metaCache = await listChannelMeta(paths, kind, ctx.model); + const listed = await listChannelMeta( + paths, + kind, + settings.channelPriority, + ); + metaCache = listed.meta; + knownSlugs = listed.slugs; metaAt = now; } + const ctx = priorityContextFor(paths, settings, knownSlugs); // THE COMPILED ROOT ENTERS HERE, and this is the only place it does for the // dispatch path: everything below — the projection, the completed filter, // the pick and the leaf lookup in runOperationPick — reads `dispatchRoot`, diff --git a/common/lib/channelPriority.test.ts b/common/lib/channelPriority.test.ts @@ -10,6 +10,7 @@ import { compileLaneRoot, compileLanes, hasCompiledLaneRoots, + renameChannelInPriority, defaultChannelPriority, effectiveTier, focusSummary, @@ -1058,3 +1059,65 @@ test("a focus on a live-shaped tree lifts exactly its channels above the rest", "leaflit-rumble", ]); }); + +// --------------------------------------------------------------------------- +// renameChannelInPriority — the document keys by slug, so it follows a rename +// --------------------------------------------------------------------------- + +test("a rename carries the entry and rewrites a channel focus", () => { + const model = sanitizeChannelPriority({ + focus: { kind: "channels", slugs: ["old", "other"] }, + channels: { + old: { tier: "low", rank: 3, overrides: { sync: "paused" } }, + other: { tier: "paused" }, + }, + }); + const next = renameChannelInPriority(model, "old", "new"); + // The whole entry moves — tier, rank AND the per-operation overrides. Losing + // any of them is silent: nothing downstream can tell a stale entry from a + // deliberate one. + assert.deepEqual(next.channels["new"], { + tier: "low", + rank: 3, + overrides: { sync: "paused" }, + }); + assert.equal(next.channels["old"], undefined); + assert.deepEqual(next.channels["other"], { tier: "paused" }); + // A focus NAMING the channel keeps naming it. Order is preserved: the focus + // group compiles in the order the selector listed. + assert.deepEqual(next.focus, { kind: "channels", slugs: ["new", "other"] }); + // And the compiled tree has no leaf under the old slug any more. + const root = compileLaneRoot("download", next, ["new", "other"], ["new"]); + assert.equal(JSON.stringify(root).includes("prio-focus-old"), false); + assert.equal(JSON.stringify(root).includes("prio-focus-new"), true); +}); + +test("a rename is a no-op when there is nothing to carry", () => { + const model = sanitizeChannelPriority({ + focus: { kind: "site", siteId: "s" }, + channels: { kept: { tier: "low" } }, + }); + // An unknown slug, a same-slug rename, and blanks all return the document + // unchanged — and a SITE focus needs no rewrite at all: it resolves through + // the site's own channels[], which the rename updates on disk. + assert.equal(renameChannelInPriority(model, "absent", "new"), model); + assert.equal(renameChannelInPriority(model, "kept", "kept"), model); + assert.equal(renameChannelInPriority(model, "", "new"), model); + assert.equal(renameChannelInPriority(model, "kept", " "), model); + assert.deepEqual(renameChannelInPriority(model, "kept", "moved").focus, { + kind: "site", + siteId: "s", + }); +}); + +test("a rename onto an existing entry keeps the destination's", () => { + // The channel controller refuses this rename, so the only way here is a + // document that is already inconsistent — and the live channel's settings + // are the ones worth keeping. + const model = sanitizeChannelPriority({ + focus: { kind: "none" }, + channels: { old: { tier: "low" }, taken: { tier: "paused" } }, + }); + const next = renameChannelInPriority(model, "old", "taken"); + assert.deepEqual(next.channels, { taken: { tier: "paused" } }); +}); diff --git a/common/lib/channelPriority.ts b/common/lib/channelPriority.ts @@ -351,6 +351,54 @@ 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<string, ChannelPriorityEntry> = {}; + 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; diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## [Unreleased] -- **Channels have priorities now, and the auto-queue's rules are generated from them.** Focusing on one group of channels — "finish Jeralyzer, hold the rest" — used to mean hand-editing four rule trees, and the only per-channel switch on `/channels` was **Sync included / excluded**, which gated sync and nothing else. Every channel row now carries a **tier** — *Normal*, *Low* or *Paused* — plus a corpus-wide **focus**: pick channels and press *Focus these*, or focus a whole site, and every lane runs the focused channels until they have nothing left, then falls through to the rest and retakes the lane the moment new focused work arrives. A focus is one fact, not four: the download, transcription, digest and speaker lanes are all held by it, and each lane's console carries a banner saying what is focused, how much of it is pending there, how many channels are held behind it, and **End focus**. Behind the disclosure on each row, any single operation can be pinned to its own tier — "keep this channel's playlist current but stop downloading it" is a *download* pin, and *Sync only* is a preset for it. A paused channel is dropped from the automatic lanes and from the sync scheduler, and **still runs from every Run button**: a hold is not a stop. Its row dims and its Build toggle is untouched, because publishing is a different question from scheduling. The four rule trees are **generated** from all of this: the policy editor on an operation's page shows them read-only with a link back to `/channels`, keeps editing everything that is not generated (enable, workers, order, the replace-auto-captions lane), and the channel leaves you had are replaced by the compiled ones. **Sync included / excluded is gone**, and it is the same statement said better: the 15 channels that carried it become *paused for sync alone* and keep every lane they were on. **Nothing is migrated until you run the migration** — `pnpm -C common exec tsx bin/migrate-channel-priority.ts`, with `--dry-run` first to see the table; back up `settings.json`, stop the editor, run it, restart. Until then the editor behaves exactly as it does today, and the first tier you set seeds itself from the rule trees you already had, so the order you hand-built is kept either way. +- **Channels have priorities now, and the auto-queue's rules are generated from them.** Focusing on one group of channels — "finish Jeralyzer, hold the rest" — used to mean hand-editing four rule trees, and the only per-channel switch on `/channels` was **Sync included / excluded**, which gated sync and nothing else. Every channel row now carries a **tier** — *Normal*, *Low* or *Paused* — plus a corpus-wide **focus**: pick channels and press *Focus these*, or focus a whole site, and every lane runs the focused channels until they have nothing left, then falls through to the rest and retakes the lane the moment new focused work arrives. A focus is one fact, not four: the download, transcription, digest and speaker lanes are all held by it, and each lane's console carries a banner saying what is focused, how much of it is pending there, how many channels are held behind it, and **End focus**. Behind the disclosure on each row, any single operation can be pinned to its own tier — "keep this channel's playlist current but stop downloading it" is a *download* pin, and *Sync only* is a preset for it. A paused channel is dropped from the automatic lanes and from the sync scheduler, and **still runs from every Run button**: a hold is not a stop. Its row dims and its Build toggle is untouched, because publishing is a different question from scheduling. The four rule trees are **generated** from all of this: the policy editor on an operation's page shows them read-only with a link back to `/channels`, keeps editing everything that is not generated (enable, workers, order, the replace-auto-captions lane), and the channel leaves you had are replaced by the compiled ones. **Sync included / excluded is gone**, and it is the same statement said better: the 15 channels that carried it become *paused for sync alone* and keep every lane they were on. **Run the migration before you first start this version.** `Sync included / excluded` is a deleted field, and until the migration has moved those 15 channels to *paused for sync*, the editor reads them as having said nothing about sync — so they are back in the schedule, back in **Sync every channel**, back in each group's **Sync**, and shown as auto-sync eligible. Nothing downloads or transcribes differently, and the automatic tick only fires if your scheduler heartbeat is on, but a *Sync all* click in that window sweeps channels you had excluded. The order is: **stop the editor → `pnpm -C common exec tsx bin/migrate-channel-priority.ts` → start it again.** Run it with `--dry-run` first to see exactly what it would write, per channel, and what each row was derived from; the real run takes its own timestamped backup of `settings.json` beside the file, so there is nothing to copy by hand. After that it is a no-op — run it twice and the second run changes nothing. Your rule trees survive either way: they are what the migration reads the channel order out of, and if you set a tier before running it, the first save seeds itself from those same trees rather than replacing the order you hand-built. - **Every pipeline is dispatched by one thing now: its lane’s runner. The two corpus sweeps and the arbiter are gone.** Digest and Speaker work were driven by a *sweep* — a corpus walk armed by its own switch, with its own scope, its own order and its own console — while Download and Transcription were driven by the auto-queue runner, with rules, a claim ladder, a next-up and a pick log. Two mechanisms, two vocabularies, two sets of bugs. There is one: **each of the four lanes has a runner, a rule list, and Start / Drain / Stop beside its pause**, on the operation’s own page. Arming a corpus pass is switching the lane on; scoping it to particular channels or operations is a *rule*, written the same way auto-transcribe’s have been written since it shipped. The dashboard and the widget keep a one-click switch per lane — **Run every channel** / **Stop the lane** where they said *Sweep every channel* / *Stop sweeping* — and the scope lives on the lane’s page, where you can see what it would do next. **Your armed scope is carried over, and no lane is switched on that was not.** The ten settings fields the sweeps used (`digest.sweepEnabled`, `sweepChannels`, `recencyOrder`, `recencyReach`; `backfill.sweepEnabled`, `sweepKinds`, `sweepChannels`, `order`, `reach`, `weight`) are read once and written into the lane’s rules the first time the editor starts: a sweep armed on three channels becomes three rules, an unscoped one becomes a single *every channel* rule, and a disarmed sweep becomes a switched-off lane. What is retired rather than migrated: **Reach**, because a rule already orders every video it claims across every channel — which rule goes first is the rule list’s job; the digest **order**, whose real meaning was always *newest day first, shortest video within a day* and which the lane spells as **Shortest first** (pick *Newest first* there if you want the date order alone); and the backfill lane’s **Resource share**, which was one number answering two different questions. A lane now stands aside for transcription when it would actually compete for the graphics card, and keeps its slots when it would not — so speaker-naming over an LLM endpoint no longer parks itself behind a transcription it was not competing with. **The arbiter, which never ran a single unit in production, is deleted**; the runner is what dispatches an operation-named rule. **Nothing on disk changes**, and the retired keys are left in `settings.json` — harmless, ignored, and yours to delete. - **The transcode operation is gone — it never fired.** A channel page had a *Transcode* stage, `/operations/transcode` had a "no console here" panel, `/cleanup` offered "Clear failed transcodings", and the video list drew a third status dot — all for a re-encode step built against two failures that never happened in production: in 68 channels, no snapshot has ever listed a video as missing its target format, no `failed-transcodings` file has ever held an id, and only four channels even met the stage's gate. Transcription never needed it — a video whose audio is in another format transcribes from that file. What stayed is everything that was never the operation's: the download path still re-encodes what it extracts itself, the video page still offers **Transcode audio.\<ext\> → \<fmt\>** per file, and both audio-format sweeps on the Cleanup stage and `/cleanup` are unchanged (gated on the channel having an `audioFormat`, which is what they compare against). The snapshot bucket behind the sweep is `wrongFormatAudio` now — its operator-facing name — and old reports keep their stray key until their next refresh. A `?stage=transcode` bookmark opens the channel overview. **Nothing on disk changes.** Also: the Pool's running-jobs list names the eight kinds its buttons enqueue, and the site's Search aliases tab no longer carries a "no site selected" branch that could not run. - **A site has tabs, and the family has one page.** Charts, Search aliases, Deploy, Build and Homepage were five sidebar entries beside *Sites*, three of them reading the site from a `?site=` parameter the sidebar picker had to seed, one of them (Build) about no site at all, and one (Homepage) about the family's own hub. A site is one thing now: **`/sites/<id>` is Settings · Charts · Search aliases · Publish**, the site named in the path, the picker following it (and Dashboard and Channels following the picker). **`/sites` is the family page**: the list, then *Release notes*, *Build all sites* with the Basic/Docker mode, the *Hub*, and the *Pool* — the corpus-wide index, stats, sidecar and archive jobs — folded under a disclosure. Search aliases keep both sections on the site's tab: the global dictionary and the site's overrides. Every button, label and log is unchanged; "Select a specific site from the sidebar" is gone because a site's page always has one. The five routes redirect — a `?site=<id>` bookmark lands on that site's tab (the query rides along), `?site=__all__` and the bare routes on `/sites`; a bookmark to a deleted site 404s there exactly as `/sites/<id>` does. The Sites group is one entry; the nav is **eleven**, the IA doc's end state. **Nothing on disk changes.** diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -50,6 +50,7 @@ import { isChannelPaused, isDefaultChannelPriority, rankOf, + renameChannelInPriority, resolveFocusSlugs, sanitizeChannelPriority, tierOrder, @@ -622,6 +623,28 @@ export async function renameChannelAction( } catch (e) { return { error: (e as Error).message }; } + // THE PRIORITY DOCUMENT KEYS BY SLUG, so it has to follow the rename or the + // channel's tier, rank and per-operation overrides stay under a slug that no + // longer exists — silently, because nothing can tell a stale entry from a + // deliberate one — a `{kind:"channels"}` focus stops naming it, and every + // compiled root keeps a `prio-*-<oldSlug>` leaf matching nothing. + // + // Through the one writer, like create and delete: the re-key is the pure + // `renameChannelInPriority`, and the writer recompiles the four roots in the + // same `writeSettings`. Best-effort — the directory has already moved, and + // reporting a settings failure as a rename failure would be a lie. + try { + await saveChannelPriorityAction({ + kind: "rename", + from: oldSlug, + to: newSlug, + }); + } catch (e) { + console.warn( + `Channel rename ${oldSlug} -> ${newSlug}: channel priority not updated:`, + (e as Error).message, + ); + } // The directory move succeeded; any warnings are non-fatal metadata-migration // problems. Log them (we redirect on success, so there's no UI to show them). if (result.warnings.length > 0) { @@ -674,7 +697,11 @@ export type ChannelPriorityEdit = // NOT AN EDIT: the channel POPULATION changed (a channel was created or // deleted), so the four trees have to be re-derived from an unchanged // document. Never seeds — see the writer. - | { kind: "recompile" }; + | { kind: "recompile" } + // A channel was RENAMED. The document keys by slug, so the entry and any + // focus naming it move with it. Never seeds, for the same reason a + // recompile does not: a rename is not a statement about priority. + | { kind: "rename"; from: string; to: string }; function entryFor( model: ChannelPriority, @@ -694,6 +721,9 @@ function applyPriorityEdit( edit: ChannelPriorityEdit, ): ChannelPriority { if (edit.kind === "recompile") return model; + if (edit.kind === "rename") { + return renameChannelInPriority(model, edit.from, edit.to); + } if (edit.kind === "focus") return { ...model, focus: edit.focus }; if (edit.kind === "promote") { const slug = edit.slug.trim(); @@ -789,11 +819,12 @@ export async function saveChannelPriorityAction( // and apply the edit on top of THAT. The corpus's existing order survives a // first click by an operator who never ran the migration. // - // NOT ON A `recompile`: creating a channel is not an operator's statement - // about priority, and it must not silently switch a corpus from its stored - // trees to compiled ones. + // NOT ON A `recompile` OR A `rename`: creating or renaming a channel is not + // an operator's statement about priority, and neither must silently switch a + // corpus from its stored trees to compiled ones. const base = edit.kind !== "recompile" && + edit.kind !== "rename" && isDefaultChannelPriority(stored) && !hasCompiledLaneRoots(settings.autoQueue) ? channelPriorityFromLegacy( diff --git a/editor/e2e/channel-priority.spec.ts b/editor/e2e/channel-priority.spec.ts @@ -376,3 +376,65 @@ test("the first tier click seeds from the legacy trees and keeps their order", a ]); } }); + +// --- A RENAME MOVES THE DOCUMENT WITH THE CHANNEL -------------------------- +// +// The model keys everything by slug, so a rename that does not pass through +// the one writer loses the channel's tier, rank and per-operation overrides +// under a slug that no longer exists — silently, because nothing downstream +// can tell a stale entry from a deliberate one — and leaves a +// `prio-*-<oldSlug>` leaf in every compiled root, matching nothing. + +test("renaming a channel carries its tier and leaves no leaf behind", async ({ + page, +}) => { + await resetData("two-slow-channels"); + await page.goto(ALL); + + await page.getByLabel("tier for slow-a", { exact: true }).selectOption("low"); + await expect + .poll(async () => (await settings()).channelPriority?.channels) + .toMatchObject({ "slow-a": { tier: "low" } }); + + // Deliberately no generateReport: renaming refuses while a job for the + // channel is active, and the danger zone renders without a report. + await page.goto("/channels/slow-a?stage=danger"); + await page.getByLabel("new slug").fill("slow-a-renamed"); + await page.getByLabel("confirm current slug").fill("slow-a"); + await page.getByRole("button", { name: "Rename channel" }).click(); + await expect(page).toHaveURL(/\/channels\/slow-a-renamed/); + + await expect + .poll(async () => (await settings()).channelPriority?.channels) + .toEqual({ "slow-a-renamed": { tier: "low" } }); + + // And the compiled trees followed in the same write: the low group names the + // new slug, and nothing anywhere still names the old one. + { + const s = await settings(); + const raw = JSON.stringify(s.autoQueue); + expect(raw.includes("prio-low-slow-a-renamed")).toBe(true); + expect(raw.includes("prio-low-slow-a\"")).toBe(false); + expect(raw.includes("prio-normal-slow-a\"")).toBe(false); + const root = s.autoQueue?.download.root; + expect(root?.children.map((c) => c.id)).toEqual([ + "prio-normal", + "prio-low", + "prio-all", + ]); + expect( + root?.children.find((c) => c.id === "prio-low")?.children?.map((c) => c.id), + ).toEqual(["prio-low-slow-a-renamed"]); + expect( + root?.children + .find((c) => c.id === "prio-normal") + ?.children?.map((c) => c.id), + ).toEqual(["prio-normal-slow-b"]); + } + + // The row on /channels shows the carried tier under the new slug. + await page.goto(ALL); + await expect( + page.getByLabel("tier for slow-a-renamed", { exact: true }), + ).toHaveValue("low"); +}); diff --git a/editor/e2e/new-channel-onboarding.spec.ts b/editor/e2e/new-channel-onboarding.spec.ts @@ -108,7 +108,7 @@ test("creating with 'Fetch playlist now' stores the playlist", async ({ .toBe(true); }); -test("'Add to top of auto-queue' prepends a channel leaf and enables the runner", async ({ +test("'Add to top of auto-queue' takes the first rank and enables the runner", async ({ page, }) => { await resetData("empty"); diff --git a/plans/STATE.md b/plans/STATE.md @@ -192,7 +192,21 @@ way and both were right: `channel-priority.spec.ts:172` caught End focus leaving `prio-focus` at the head of the stored tree, and `new-channel-onboarding.spec.ts:111` was still reading "Add to top of auto-queue" as a hand-written leaf. -**THE MIGRATION HAS NOT BEEN RUN — that is the operator's step at rollout.** +**⚠ THE MIGRATION MUST RUN BEFORE THE FIRST BOOT OF THIS CODE, not after it.** +`excludeFromSync` is deleted and `parseChannelConfig` drops the key, so on a corpus that has +not been migrated the 15 channels that carried it read as *saying nothing about sync* — and +four places act on that the moment the editor starts: `syncScheduler.ts:132` (selection), +`:259` (`autoSyncEligible`, so the projection changes on sight), +`channels/actions.ts:524` (*Sync all*) and `channelGroupSections.ts:140` (a group's Sync). +The automatic tick is the one thing that does NOT fire on the live box — its +`heartbeatSeconds` is 0 — but *Sync all* or a group Sync in that window sweeps every channel +the operator had excluded. Nothing else moves: no lane's membership, no download, no +transcription. **Order: stop the editor → run the migration → start it.** The script takes +its own timestamped `settings.json.pre-priority-<ISO>` backup beside the file before its +first write (refusing to overwrite an existing one), so the backup is not a step anyone can +skip. + +**IT HAS NOT BEEN RUN — that is the operator's step at rollout.** `common/bin/migrate-channel-priority.ts --dry-run` against a READ-ONLY copy of the live `settings.json` (no config.json or settings.json mtime moved) says: **68 channels, 28 entries, 14 ranked, 15 pinned `sync=paused`, no focus, every base tier `normal`**, and all @@ -202,8 +216,11 @@ darlingstrawb, rekietalaw-rumble, chibi-reviews, nux-taku, destiny, omnivods-ody leaflit-rumble, piratesoftware` — dense 0–13, merged index then the transcription lane's order then slug. No lane's membership moves; only `sync` loses anyone. **Rollout order:** (1) stop the editor, (2) `cp settings.json ~/settings.json.pre-priority`, (3) -`pnpm -C common exec tsx bin/migrate-channel-priority.ts` (no flag), (4) restart the editor — -the runner re-reads the document per tick, but the worker pool and the heartbeat arm at boot. +`pnpm -C common exec tsx bin/migrate-channel-priority.ts` (no flag — step 2 is the script's +own job now, and is kept only as a belt-and-braces), (4) restart the editor — the runner +re-reads the document per tick, but the worker pool and the heartbeat arm at boot. Never run +it against a live editor: a running one holds settings in memory and writes them back on its +own schedule. **Next:** [`relocate-channel-media.md`](relocate-channel-media.md) FIRST, 3 slices — `/home` is at **100 %, 6.9 G free**, and the mechanism (a symlinked `data/` plus a diff --git a/plans/channel-priority.md b/plans/channel-priority.md @@ -571,6 +571,13 @@ dense ranks and idempotence (`8c335d5`), one writer of `root` plus the legacy se `prioritizeChannelDownloadAction` is a `{kind:"promote"}` priority edit. The writer takes one option, `enableLanes`, so "Add to the top of the download queue" stays one write. 8. **`channel-sync-toggle.spec.ts` went in S3**, not S5 — S3 removed the control it drove. +9. **`renameChannelAction` is a priority writer too.** Not in the plan, which named only + create and delete. The document keys by slug, so a rename without it loses the channel's + tier, rank and overrides under a dead slug, drops it out of a `{kind:"channels"}` focus, + and leaves a `prio-*-<oldSlug>` leaf in every compiled root. The re-key is the pure + `renameChannelInPriority`; the write goes through the one writer. +10. **The migration takes its own backup.** The plan left that to the operator. An operator + step on the destructive path is a step someone skips. ### The banner's End focus, and why the link stayed @@ -602,5 +609,24 @@ leaflit-rumble, piratesoftware` `omnivods-odysee` is the channel that is both ranked and excluded: it keeps its rank, keeps downloading, and loses only `sync`. No lane's membership moves. -**Rollout:** stop the editor → `cp settings.json ~/settings.json.pre-priority` → -`pnpm -C common exec tsx bin/migrate-channel-priority.ts` → restart. +### Rollout — MIGRATE BEFORE THE FIRST BOOT, not after + +**This is a warning, not a recipe.** `excludeFromSync` is deleted and the parser drops the +key, so until the migration has run the 15 channels that carried it read as saying nothing +about sync, and four places act on that from the first render: `syncScheduler.ts:132` +(selection), `:259` (`autoSyncEligible`), `channels/actions.ts:524` (*Sync all*) and +`channelGroupSections.ts:140` (a group's Sync). The automatic tick is off on the live box +(`heartbeatSeconds: 0`), so the scheduler will not act on its own — but one *Sync all* click +in that window sweeps every channel the operator had excluded. No lane's membership moves and +nothing downloads or transcribes differently; it is the sync pool alone. + +1. **Stop the editor.** Never migrate under a running one: it holds settings in memory and + writes them back on its own schedule. +2. `pnpm -C common exec tsx bin/migrate-channel-priority.ts --dry-run` — read the table. +3. `pnpm -C common exec tsx bin/migrate-channel-priority.ts` — it writes + `settings.json.pre-priority-<ISO>` beside the file first, refusing to overwrite an + existing backup name, so the backup is not a step anyone can skip. +4. **Start the editor.** The runner re-reads the document per tick; the worker pool and the + heartbeat arm at boot. + +Idempotent: a second run changes nothing.