commit 26689d10b832efe9345baa20d62a27facfeb089e
parent 0186d2f4ab15cab7f3228b1dbc2292d0bb7e332d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 11:24:38 -0400
plans: channel-priority gains per-operation overrides, and question 3 is answered
The operator's case: "I find the download on channels that sync to be an issue
(e.g. omnibased)" — a channel must be able to keep its playlist current while its
download lane is parked. channels[slug].overrides pins any of `sync` plus the four
lanes to a tier of its own, effectiveTier(model, slug, op) is what dispatch asks,
and the focus set stays one, corpus-wide.
The consequence worth the change: the migration becomes LOSSLESS. excludeFromSync
meant "stop syncing", not "stop everything", so it maps to {sync: "paused"} with
the base tier and the rank untouched — omnivods-odysee keeps downloading, no lane's
membership moves, and open question 3 (which of the 15 excluded channels are really
meant to be paused everywhere) needs no list from the operator: none of them are, by
construction. The order collapse is now the migration's only behaviour change.
Also recorded: the four decisions taken (Q1 collapse, Q2 a pause never stops a
manual run, Q3 above, Q5 a focus never raises a lane's enabled), S1/S2 reading the
effective tier per operation, S3's advanced disclosure plus a "Sync only" preset,
and where the sanitizer round-trip test has to live.
S0 is implemented as of this branch.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
1 file changed, 122 insertions(+), 41 deletions(-)
diff --git a/plans/channel-priority.md b/plans/channel-priority.md
@@ -1,6 +1,15 @@
# Channel priority — one tier per channel, one focus, four compiled trees
-**Status:** design only, nothing implemented. Anchors are at `61eae05` on `one-core/phase-1`.
+**Status:** **S0 (the contract) is implemented** on branch `channel-priority/s0`, off `e74f005`.
+S1-S5 are design only. Anchors are at `61eae05` on `one-core/phase-1`.
+
+Operator decisions taken, numbered as the Open questions section below: **Q1 — collapse** the
+two live per-lane channel orders to ONE (the model has one rank per channel). **Q2 — no**, a
+Paused channel still runs a MANUAL run; paused gates the sync scheduler, *Sync all* and the
+four auto lanes, nothing else. **Q3 — resolved by design**, by the per-operation override map
+(see the Model): all 15 migrate to a `sync` override and no lane's membership moves. **Q5 —
+no**, activating a focus never changes any lane's `enabled`. Q4 is still open and does not
+affect S0.
The ask: *"focus on one group of channels (e.g. jeralyzer) and pause all others until the
priority channels are done … rip out the current enable/disable feature on the channels page
@@ -107,13 +116,17 @@ runner's channel list, not a tree shape.**
30 s TTL (`CHANNEL_LIST_TTL_MS`, `:141`). Filtering Paused there makes a paused channel
invisible to all four lanes, catch-all included, in one predicate.
-4. **`excludeFromSync` dissolves into the Paused tier; `excludeFromBuild` stays.** They are
- different axes: one is scheduling, one is publishing, and the brief's own rule is that the
- lowest tier must not gate export. The `/channels` row keeps its Build toggle and loses its
- Sync toggle.
+4. **`excludeFromSync` dissolves into a `sync` OVERRIDE, not into the Paused tier;
+ `excludeFromBuild` stays.** The three are different axes: one is the sync cadence, one is
+ dispatch, one is publishing, and the brief's own rule is that the lowest tier must not gate
+ export. A per-operation override says "stop syncing, keep everything else" exactly, so the
+ migration moves no lane's membership. The `/channels` row keeps its Build toggle and loses
+ its Sync toggle.
-5. **No drag-and-drop, no per-lane overrides.** Tier is a `<select>`; intra-tier order is an
- optional integer `rank` (migration seeds it; ties fall back to slug order).
+5. **No drag-and-drop, and no per-lane RANK.** Tier is a `<select>`; intra-tier order is an
+ optional integer `rank` (migration seeds it; ties fall back to slug order), and there is
+ ONE rank per channel. Per-operation *tier* overrides do exist (above) — they change which
+ lane a channel is on, never its order within one.
## The model
@@ -124,6 +137,13 @@ bars `lib → controller|jobs`).
```ts
export const CHANNEL_TIERS = ["focus", "normal", "low", "paused"] as const;
export type ChannelTier = (typeof CHANNEL_TIERS)[number];
+// What may be STORED on a channel — "focus" is a compiled POSITION, never a stored tier.
+export const STORED_CHANNEL_TIERS = ["normal", "low", "paused"] as const;
+
+// The operations a tier can be pinned to: the four lanes plus `sync` (which is
+// not a lane — it is a per-channel cadence, the answer pauseLaneFor already gives it).
+export const PRIORITY_OPERATIONS = ["sync", ...LANES] as const;
+export type PriorityOperation = (typeof PRIORITY_OPERATIONS)[number];
export type ChannelFocus =
| { kind: "none" }
@@ -133,10 +153,42 @@ export type ChannelFocus =
export type ChannelPriority = {
focus: ChannelFocus;
// Only channels that differ from the default appear. Absent slug = normal, no rank.
- channels: Record<string, { tier: ChannelTier; rank?: number }>;
+ channels: Record<string, {
+ tier: StoredChannelTier;
+ rank?: number;
+ // PER-OPERATION OVERRIDES of the base tier. Only operations that DIFFER appear.
+ overrides?: Partial<Record<PriorityOperation, StoredChannelTier>>;
+ }>;
};
```
+**Per-operation overrides are the advanced half, and they are what makes the migration
+lossless.** The operator's case: *"I find the download on channels that sync to be an issue
+(e.g. omnibased)"* — a channel must be able to keep its playlist and metadata current while
+its download lane is parked, and in general any operation may sit at a different tier than
+the channel's base. That is also precisely what `excludeFromSync` meant, read the other way
+round, so the flag this model replaces becomes `{tier: "normal", overrides: {sync: "paused"}}`
+and nothing else moves.
+
+- `effectiveTier(model, slug, op)` = `overrides?.[op] ?? tier`, and **every dispatch-side
+ question asks it**: the compiler per lane, `listChannelMeta` for the lane it is listing,
+ the sync scheduler for `"sync"`. `tierOf` stays the BASE tier and is the display answer.
+- **The focus set stays ONE, corpus-wide.** A focused channel whose override for some
+ operation is `paused` is simply not drawn for that operation — focus wins over the stored
+ tier, paused wins over focus, per operation.
+- **Two presets, each the other's inverse.** `{tier:"normal", overrides:{sync:"paused"}}` is
+ "everything but sync"; `{tier:"paused", overrides:{sync:"normal"}}` is "sync only".
+- **Sanitizer normalisation:** an unknown operation key is dropped; an unknown tier VALUE is
+ dropped rather than coerced (coercing to `normal` would silently unpause an operation on a
+ paused channel — an override's job is to differ from the base, so a junk one falls back to
+ the base); an override equal to the base is normalised away, and an empty map with it; a
+ `rank` on a channel paused for *every* operation is dropped, while a "sync only" channel
+ keeps one because the sync scheduler still orders it. Keys are emitted in
+ `PRIORITY_OPERATIONS` order and slugs sorted, so the on-disk document has a stable diff.
+- **The four compiled trees are identical unless an override moves a channel.** One rank per
+ channel means the only thing that can differ between lanes is membership, and the only
+ thing that changes membership is an override.
+
- `focus: {kind:"site"}` is the first-class answer to *"focus = the channels of site X"*, and
it is resolved at compile time against `transcripts/sites/<id>/site.json`'s `channels[]`, so
it tracks membership rather than freezing a list. `{kind:"channels"}` backs "Focus these".
@@ -194,9 +246,11 @@ noise. `flattenLeaves`/`hasWork` walk ~69 nodes per pick.
for the banner, and `channelPriorityFromLegacy(configs, autoQueue)` for the migration.
2. **`common/controller/autoRunner.ts:288-296`** — `listChannelMeta` drops
- `tierOf(priority, slug) === "paused"`. One predicate; both the runner loop and
- `computeLeafPending` inherit it, and the 30 s TTL (`:141`) is the re-evaluation clock.
- *Nothing else in dispatch changes.*
+ `isChannelPaused(priority, slug, lane)` — the EFFECTIVE tier for the lane being listed,
+ so a channel paused for `download` and normal for `transcription` is absent from one and
+ present in the other. One predicate; both the runner loop and `computeLeafPending`
+ inherit it, and the 30 s TTL (`:141`) is the re-evaluation clock. *Nothing else in
+ dispatch changes.*
3. **A new idle reason is NOT added.** A lane whose focus group holds the rest is not idle —
it is dispatching focus work. When the whole tree is empty the existing `no-pending`
@@ -206,12 +260,15 @@ noise. `flattenLeaves`/`hasWork` walk ~69 nodes per pick.
4. **Sync** — `common/jobs/syncScheduler.ts`:
- `selectDueChannels` takes the model. The `excludeFromSync` skip at `:99` becomes
- `tierOf(priority, slug) === "paused"`, with the same silent `continue`.
+ `isChannelPaused(priority, slug, "sync")` — the effective tier for the `sync`
+ operation, which is exactly what the flag meant — with the same silent `continue`.
- The sort at `:125` becomes **tier rank, then rank, then `overdueMs` descending**:
- `focus < normal < low`. Most-overdue-first survives *within* a tier, so a focus channel
+ `focus < normal < low`, all read through `effectiveTier(·, "sync")`. Most-overdue-first
+ survives *within* a tier, so a focus channel
due by a minute outranks a low channel due by a day, and the tick's cap
(`editor/app/scheduler/runTick.ts:127-131`) therefore spends its slots on focus first.
- `autoSyncEligible` (`:193-197`) follows the same predicate.
+ - `tierOrder(tier)` in `channelPriority.ts` is the one sortable form of the tier order.
- `syncAllChannelsAction` (`editor/app/channels/actions.ts:466-472`) swaps its
`excludeFromSync` skip for the same one and sorts the candidate list the same way.
@@ -223,6 +280,10 @@ noise. `flattenLeaves`/`hasWork` walk ~69 nodes per pick.
and `BulkCadenceBar.tsx:37,41` verbatim rather than inventing an idiom. Bulk actions:
**Set tier**, **Focus these**, and a **Focus site: `<id>`** menu built from
`listSites()` (no selection needed).
+ - **Advanced: per-operation selects behind a disclosure**, beside the tier control, plus a
+ **Sync only** preset (and its inverse, which is what every migrated `excludeFromSync`
+ channel already carries). The row shows the base tier and a marker when any operation is
+ pinned; the disclosure is where the five operations are set.
- Sort key `sync` (`ChannelsTable.tsx:54-63,129-163`) becomes `tier` (tier order, then
rank, then slug). Row dimming (`:406-411`) keys off `tier === "paused"`.
- `channelGroupSections.ts:124`'s excluded-from-sync section becomes the Paused section.
@@ -264,20 +325,28 @@ Live data: **15 of 68 channels carry `excludeFromSync: true`** — `community-no
`exclusively-games`, `rcflightschool`, `rcspotlight`, `teamrcn`,
`the-incredible-salt-mine`, `steven-crowder`, `midwestly`, `redbar` — and **zero** carry
`excludeFromBuild`. One of the 15, `omnivods-odysee`, is also the 8th ranked leaf of the live
-download tree: today it is excluded from sync yet still drawn by the download lane. **Paused
-wins** — that is the whole point of a unified model — so migrating it is a real behaviour
-change for that one channel and must be named in the commit. `community-notes` is a jeralyzer
-channel, so a jeralyzer focus will not resurrect it either: paused is applied before the tree
-is consulted.
+download tree: today it is excluded from sync yet still drawn by the download lane.
+
+**The mapping is LOSSLESS, and that is what the override map is for.** `excludeFromSync`
+meant "stop syncing", not "stop everything", so it becomes
+`{tier: <base>, overrides: {sync: "paused"}}` — the base tier stays `normal` and the rank
+(if the channel has one) stands. `omnivods-odysee` keeps downloading exactly as it does
+today; **no lane's membership moves**, which makes the migration a settings rewrite rather
+than a behaviour change. An operator who wants one of the 15 paused outright sets its base
+tier afterwards, deliberately, on `/channels`.
- **Pure function**, `channelPriorityFromLegacy(configs, autoQueue): ChannelPriority`, in
`common/lib/channelPriority.ts`, in the `laneMigration.ts` style (no I/O, idempotent over its
own output, asserted through `sanitizeChannelPriority`):
- - `config.excludeFromSync === true` → `{tier: "paused"}`, checked **first**, so a paused
- channel that also has a lane leaf (`omnivods-odysee`) is paused and keeps no rank.
+ - `config.excludeFromSync === true` → `overrides: {sync: "paused"}` on the channel's entry,
+ never a base tier. A channel that also has a lane leaf (`omnivods-odysee`) keeps its rank
+ and every lane it was drawn by.
- A channel named by a bare channel leaf in **either** the download or the transcription root
- → `{tier: "normal", rank: <the lower of its two leaf indices>}`. Channels ranked in one
- lane only keep that lane's index.
+ → `{tier: "normal", rank: <the lower of its two indices>}`, where the index is its
+ position among that root's **bare channel leaves** (a dense order; identical to the leaf
+ index when every leaf is bare, which all 22 live leaves are — so a bucket or operation
+ leaf the model cannot express leaves no hole). Channels ranked in one lane only keep that
+ lane's index.
- Everything else → absent (normal, unranked).
- `focus: {kind: "none"}`.
- **It is NOT run from `getSettings`.** That function is synchronous and reads one file; the
@@ -285,9 +354,9 @@ is consulted.
`common/bin/migrate-channel-priority.ts`, run offline with `tsx` (never a second editor), that
reads the configs and `settings.json`, writes `channelPriority`, recompiles the four roots and
prints a diff. Idempotent: a second run is a no-op because the document already exists.
-- **The collapse is a behaviour change and must be measured.** Six channels are ranked in one
- lane only, and the three Quartering channels are in different orders in the two lanes; after
- the migration both lanes get one order. Gate it with `plans/tools/phase1-numbers.ts`
+- **The order collapse is the migration's ONE behaviour change, and must be measured.** Six
+ channels are ranked in one lane only, and the three Quartering channels are in different
+ orders in the two lanes; after the migration both lanes get one order. Gate it with `plans/tools/phase1-numbers.ts`
before/after over the live corpus, the way every Phase 1 slice was gated — the `*_leaves`
lines will move by construction, so the number that must not move is each lane's total
pending count.
@@ -308,7 +377,9 @@ is consulted.
corpus); channel focus keeps order and drops unknown slugs.
3. `compileLaneRoot`: group order focus → normal → low → catch-all-last; paused slugs never
appear; rank ordering with unranked tail; ids are `prio-*`; an empty focus emits no focus
- group; the output survives `sanitizeAutoQueue` unchanged.
+ group; a per-operation override moves a channel on **one** lane's tree and not the others.
+ The half that must import the engine — *the output survives `sanitizeAutoQueue`
+ unchanged*, with no id reassigned — is `jobs/channelPrioritySanitize.test.ts`.
4. **The focus/hold/done transition, asserted through the real engine**: build two
`ChannelWork` objects, compile a tree with channel A focused, and drive `buildPendingByLeaf`
+ `selectNextWork` — every pick is A's while A has work; the first pick after A's list is
@@ -317,7 +388,9 @@ is consulted.
in `lib/`, because it must import `jobs/autoQueuePolicy` and `architecture.test.ts` forbids
`lib/ → jobs/` — the same reason `laneMigration.test.ts` sits in `jobs/`.
5. `channelPriorityFromLegacy`: the live shape (9+9 leaves, 6 one-lane channels, 3 reordered);
- an `excludeFromSync` config; a file with the document already present (untouched).
+ an `excludeFromSync` config becoming a `sync` override only; and **the 15-channel case
+ round-tripping with no lane change** — every channel still on every lane's compiled tree,
+ only the `sync` operation losing anyone.
6. Sync ordering: `selectDueChannels` with one focus channel 1 min overdue and one low channel
1 day overdue returns the focus channel first; a paused channel is never due.
@@ -347,14 +420,20 @@ entry may be added**), and the named spec files.
## Slices
**S0 — the contract (lands first, small, blocks everything).** Branch `channel-priority/s0`.
-Files: `common/lib/channelPriority.ts` (all types, sanitizer, resolver, compiler, legacy
-function — the compiler may return a placeholder only if fully typed), the sanitizer wired into
+Files: `common/lib/channelPriority.ts` (all types including the per-operation overrides, the
+sanitizer, `effectiveTier`/`isChannelPaused`/`channelsForOperation`, the focus resolver, the
+compiler, `focusSummary` and the legacy function), the sanitizer wired into
`getSettings` (`settings.ts:~1396`) and `saveSettings` (`:1613-1620`), `SiteSettings` gains
-`channelPriority`, plus **empty-but-exported stubs** for the files S3/S4 import:
-`editor/app/channels/components/ChannelTierSelect.tsx` and
-`editor/app/channels/components/FocusBanner.tsx`. Tests 1-3 and 5. One commit. Nothing reads the
+`channelPriority` (and `editor/app/settings/actions.ts` PRESERVES it, the way it preserves
+`autoQueue` — rebuilding it would undo a focus), plus **empty-but-exported stubs** for the
+files S3/S4 import: `editor/app/channels/components/ChannelTierSelect.tsx` and
+`editor/app/channels/components/FocusBanner.tsx`. Tests 1-3 and 5. Nothing reads the
model yet, so the live tree is untouched and no number moves.
+The sanitizer round-trip half of test 3 lives in **`common/jobs/channelPrioritySanitize.test.ts`**,
+not in `lib/`: `architecture.test.ts` scans test files like any other and forbids `lib/ -> jobs/`,
+and its ALLOWED list may only shrink. Same reason `laneMigration.test.ts` sits in `jobs/`.
+
**S1 — dispatch.** Branch `channel-priority/s1`. Only `common/controller/autoRunner.ts`
(`listChannelMeta` paused filter) + `common/jobs/channelPriorityCompile.test.ts` (test 4).
@@ -389,26 +468,28 @@ plus `channels*.spec.ts`, and the full suite runs once at the merge.
## Out of scope
-Per-lane priority overrides; drag-and-drop ordering; time-boxed focus ("focus until Friday");
+Per-lane *rank* (a channel has one order, not four); drag-and-drop ordering; time-boxed focus ("focus until Friday");
auto-ending a focus when its work hits zero (the banner reports it, the operator ends it);
`excludeFromBuild` and `excludeFromCleanup`; the site-membership editor; any change to
`operationBatch`, `laneLimit`, `pauseGates`, the `held` keys or `.auto-queue/state.json`.
## Open questions for the operator
-1. **The two live trees disagree on six channels and on the Quartering ordering.** Collapse to
- one order (recommended — it is the point of the feature), or keep a per-lane `rank`?
-2. **Does Paused stop a *manual* run?** Recommended: **no**. Paused gates the sync scheduler,
+1. ~~**The two live trees disagree on six channels and on the Quartering ordering.** Collapse to
+ one order, or keep a per-lane `rank`?~~ **DECIDED: collapse.** One rank per channel.
+2. **Does Paused stop a *manual* run? DECIDED: no.** Paused gates the sync scheduler,
*Sync all*, and all four auto lanes; the per-video and per-channel Run buttons
(`runDigestChannelJob`, `runBackfillChannelJob`, the pipeline actions) still work, with a
"Paused — automatic work is off for this channel" badge on the channel page.
-3. **Are all 15 `excludeFromSync` channels really meant to be Paused everywhere?** Under the
- new model they stop being downloaded and transcribed too, not just synced. If some of them
- were only meant to stop *syncing* while still finishing a backlog, they want `low`, not
- `paused`, and the migration needs that list.
+3. ~~**Are all 15 `excludeFromSync` channels really meant to be Paused everywhere?**~~
+ **RESOLVED BY DESIGN (per-operation overrides).** The question only existed because the
+ first model could not say "stop syncing, keep everything else". It can now: all 15 migrate
+ to `overrides: {sync: "paused"}` with their base tier and rank untouched, no lane's
+ membership moves, and no list from the operator is needed. Pausing one outright is a
+ deliberate later edit on `/channels`.
4. **Do the 5 channels in no site belong in a tier by default?** They are normal today; a
site-scoped focus will hold them like any other non-focus channel.
-5. **Should a Focus also raise the lane's `enabled`?** Recommended: no — focusing must not
+5. **Should a Focus also raise the lane's `enabled`? DECIDED: no** — focusing must not
start a stopped lane, for the same reason `saveAutoQueueAction` refuses to unhold one.
## Roadmap placement — a recommendation, not applied here