commit f38ebca213269686ff379b3afb3791bc39a3ee80
parent b17245851c63e34ed68f24c45644d171c427717e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 12:30:41 -0400
bin: the one-shot channel-priority migration
Three legacy facts into one document: `excludeFromSync: true` becomes
`overrides: {sync:"paused"}` (never a base tier — it meant "stop syncing", not
"stop everything"), and a bare channel leaf in either ranked lane's stored
root becomes a rank. Then the four lane roots are compiled from the result and
`excludeFromSync` is cleared from every config.json, because the field is
deleted from the schema in this slice.
IDEMPOTENT TWICE OVER: `channelPriorityFromLegacy` returns the stored document
untouched once any lane root carries a `prio-*` id, and a config with no
`excludeFromSync` key is left alone rather than rewritten. The config rewrite
is a raw JSON key delete, not a readChannelConfig/writeChannelConfig round
trip — that round trip runs the allow-list parser and would drop every other
key a hand-edited or older config carries.
--dry-run writes NOTHING — not settings.json, not a config, not a temp file —
and prints the document, the per-channel table (slug, tier, rank, overrides,
and WHICH legacy fact produced each, by lane and position) and the compiled
group shape. Verified against the live corpus through `SETTINGS_FILE` pointed
at a read-only copy: 68 channels, 14 ranked, 15 sync-pinned, 28 entries, no
focus, and not one config.json or settings.json mtime moved.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
1 file changed, 280 insertions(+), 0 deletions(-)
diff --git a/common/bin/migrate-channel-priority.ts b/common/bin/migrate-channel-priority.ts
@@ -0,0 +1,280 @@
+#!/usr/bin/env tsx
+import path from "node:path";
+import { readFile, rename, writeFile } from "node:fs/promises";
+import { getPaths } from "../lib/paths";
+import { getSettings, writeSettings } from "../lib/settings";
+import { listChannelConfigs } from "../controller/channels";
+import { siteChannelIndex } from "../lib/site";
+import {
+ channelPriorityFromLegacy,
+ compileLanes,
+ hasCompiledLaneRoots,
+ isDefaultChannelPriority,
+ overridesOf,
+ rankOf,
+ resolveFocusSlugs,
+ tierOf,
+ type ChannelPriority,
+} from "../lib/channelPriority";
+import { LANES } from "../lib/autoQueueTypes";
+import { parseFlags } from "./_parseFlags";
+
+// THE ONE-SHOT CHANNEL-PRIORITY MIGRATION (plans/channel-priority.md, S5).
+//
+// Usage:
+// migrate-channel-priority.ts --dry-run # print, write nothing
+// migrate-channel-priority.ts # write
+//
+// It turns three legacy facts into one document:
+//
+// 1. `excludeFromSync: true` on a channel config -> `overrides: {sync:"paused"}`
+// 2. a bare channel leaf in autoQueue.transcription.root -> a rank
+// 3. a bare channel leaf in autoQueue.download.root -> a rank
+//
+// ...then compiles the four `autoQueue[lane].root` trees from the result and
+// clears `excludeFromSync` from every config.json, because the field is
+// deleted from the schema in the same slice.
+//
+// WHY A SCRIPT AND NOT `getSettings`. The derivation needs the 68 channel
+// configs; `getSettings` is synchronous and reads one file. Running it from
+// there would put 68 reads on every settings read in the process.
+//
+// IDEMPOTENT, TWICE OVER. `channelPriorityFromLegacy` returns the STORED
+// document untouched once any lane root carries a `prio-*` id — compiled
+// channel leaves are bare channel leaves, so a second run would otherwise
+// re-derive the ranks from its own output and collapse the hand-made order
+// 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`.
+//
+// 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
+// in this slice's record was produced:
+//
+// SETTINGS_FILE=/tmp/copy.json TRANSCRIPTS_DIR=/path/to/transcripts \
+// tsx common/bin/migrate-channel-priority.ts --dry-run
+
+const flags = parseFlags(process.argv.slice(2));
+const dryRun = flags["dry-run"] === "true";
+const paths = getPaths();
+
+// Which legacy fact produced this channel's entry, for the table. The point of
+// the column is that an operator can check the migration against the two lists
+// they wrote by hand, so it names the LANE and the position, not just "a rank".
+function provenance(
+ slug: string,
+ excluded: boolean,
+ transcription: readonly string[],
+ download: readonly string[],
+): string {
+ const parts: string[] = [];
+ const t = transcription.indexOf(slug);
+ const d = download.indexOf(slug);
+ if (t !== -1) parts.push(`transcription#${t}`);
+ if (d !== -1) parts.push(`download#${d}`);
+ if (excluded) parts.push("excludeFromSync");
+ return parts.join(" + ") || "-";
+}
+
+// The bare channel leaves of one stored root, in depth-first order — the same
+// reading `channelPriorityFromLegacy` does, repeated here only so the table can
+// say WHERE a rank came from.
+function bareLeaves(node: unknown): string[] {
+ const out: string[] = [];
+ const walk = (n: unknown): void => {
+ if (!n || typeof n !== "object") return;
+ const rec = n as Record<string, unknown>;
+ if (Array.isArray(rec.children)) {
+ for (const c of rec.children) walk(c);
+ return;
+ }
+ const match = rec.match as Record<string, unknown> | undefined;
+ if (!match || match.type !== "channel") return;
+ if (match.bucket || match.operation) return;
+ const value = typeof match.value === "string" ? match.value.trim() : "";
+ if (value && !out.includes(value)) out.push(value);
+ };
+ walk(node);
+ return out;
+}
+
+function pad(s: string, n: number): string {
+ return s.length >= n ? s : s + " ".repeat(n - s.length);
+}
+
+function printTable(
+ model: ChannelPriority,
+ slugs: readonly string[],
+ excludedSlugs: ReadonlySet<string>,
+ transcription: readonly string[],
+ download: readonly string[],
+): void {
+ const rows = slugs.map((slug) => {
+ const rank = rankOf(model, slug);
+ const overrides = overridesOf(model, slug);
+ const pinned = Object.entries(overrides)
+ .map(([op, tier]) => `${op}=${tier}`)
+ .join(",");
+ return [
+ slug,
+ tierOf(model, slug),
+ rank === null ? "-" : String(rank),
+ pinned || "-",
+ provenance(slug, excludedSlugs.has(slug), transcription, download),
+ ];
+ });
+ const head = ["slug", "tier", "rank", "overrides", "from"];
+ const width = head.map((h, i) =>
+ Math.max(h.length, ...rows.map((r) => r[i].length)),
+ );
+ const line = (cells: string[]) =>
+ cells.map((c, i) => pad(c, width[i])).join(" ");
+ console.log(line(head));
+ console.log(width.map((w) => "-".repeat(w)).join(" "));
+ // Ranked channels first, in rank order — the migration's whole output is an
+ // ORDER, and an alphabetical table hides whether it came out right.
+ const ranked = rows.filter((r) => r[2] !== "-");
+ const rest = rows.filter((r) => r[2] === "-");
+ ranked.sort((a, b) => Number(a[2]) - Number(b[2]));
+ for (const r of [...ranked, ...rest]) console.log(line(r));
+}
+
+// Rewrite one config.json with the `excludeFromSync` key REMOVED, preserving
+// every other key exactly as it is on disk.
+//
+// Deliberately not `readChannelConfig` + `writeChannelConfig`: that round trip
+// runs the allow-list parser, which would also drop any other key a hand-edited
+// or older config carries. A migration should change the one thing it is about.
+async function clearExcludeFromSync(slug: string): Promise<boolean> {
+ const file = path.join(paths.channelsDir, slug, "config.json");
+ let raw: string;
+ try {
+ raw = await readFile(file, "utf8");
+ } catch {
+ return false;
+ }
+ let parsed: Record<string, unknown>;
+ try {
+ parsed = JSON.parse(raw) as Record<string, unknown>;
+ } catch {
+ console.warn(` ! ${slug}: config.json is not valid JSON — left alone`);
+ return false;
+ }
+ if (!("excludeFromSync" in parsed)) return false;
+ delete parsed.excludeFromSync;
+ const tmp = `${file}.tmp-${process.pid}`;
+ await writeFile(tmp, JSON.stringify(parsed, null, 2) + "\n");
+ await rename(tmp, file);
+ return true;
+}
+
+async function main(): Promise<void> {
+ const settings = getSettings();
+ const configs = await listChannelConfigs(paths);
+ const slugs = configs.map((c) => c.slug);
+ const transcription = bareLeaves(settings.autoQueue.transcription?.root);
+ const download = bareLeaves(settings.autoQueue.download?.root);
+ const excludedSlugs = new Set(
+ configs
+ .filter((c) => (c.config as { excludeFromSync?: boolean }).excludeFromSync)
+ .map((c) => c.slug),
+ );
+
+ console.log(`settings: ${paths.settingsFile}`);
+ console.log(`channels: ${paths.channelsDir} (${slugs.length})`);
+ console.log(
+ `legacy: transcription ${transcription.length} ranked, ` +
+ `download ${download.length} ranked, ` +
+ `${excludedSlugs.size} excludeFromSync`,
+ );
+
+ const alreadyCompiled = hasCompiledLaneRoots(settings.autoQueue);
+ if (alreadyCompiled) {
+ console.log(
+ "\nThe stored lane roots already carry compiled `prio-*` ids, so this " +
+ "corpus has been migrated. The document below is the STORED one — " +
+ "nothing is re-derived from a compiled tree.",
+ );
+ }
+
+ const model = channelPriorityFromLegacy(
+ configs,
+ settings.autoQueue,
+ settings.channelPriority,
+ );
+
+ console.log("");
+ printTable(model, slugs, excludedSlugs, transcription, download);
+
+ const focusSlugs = resolveFocusSlugs(model, siteChannelIndex(paths), slugs);
+ const ranked = slugs.filter((s) => rankOf(model, s) !== null).length;
+ const pinned = slugs.filter(
+ (s) => Object.keys(overridesOf(model, s)).length > 0,
+ ).length;
+ console.log("");
+ console.log(`focus: ${JSON.stringify(model.focus)} (${focusSlugs.length} channels)`);
+ console.log(`entries: ${Object.keys(model.channels).length}`);
+ console.log(`ranked: ${ranked}`);
+ console.log(`pinned: ${pinned} channel(s) with a per-operation override`);
+ console.log(
+ `tiers: ` +
+ ["normal", "low", "paused"]
+ .map((t) => `${t} ${slugs.filter((s) => tierOf(model, s) === t).length}`)
+ .join(", "),
+ );
+
+ if (dryRun) {
+ console.log("\n--- channelPriority (would be written) ---");
+ console.log(JSON.stringify(model, null, 2));
+ const roots = isDefaultChannelPriority(model)
+ ? null
+ : compileLanes(model, slugs, focusSlugs);
+ if (roots) {
+ console.log("\n--- compiled lane roots (would be written) ---");
+ for (const lane of LANES) {
+ const groups = roots[lane].children.map((c) =>
+ "children" in c ? `${c.id}(${c.children.length})` : c.id,
+ );
+ console.log(` ${lane}: ${groups.join(" > ")}`);
+ }
+ } else {
+ console.log(
+ "\nThe document is empty, so nothing would be compiled and the " +
+ "stored trees would stand — which is also what the runner does.",
+ );
+ }
+ console.log(
+ `\nwould clear excludeFromSync from ${excludedSlugs.size} config.json file(s)`,
+ );
+ console.log("\nDRY RUN — nothing was written.");
+ return;
+ }
+
+ const autoQueue = { ...settings.autoQueue };
+ if (!isDefaultChannelPriority(model)) {
+ 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}`);
+
+ let cleared = 0;
+ for (const slug of slugs) {
+ if (await clearExcludeFromSync(slug)) cleared++;
+ }
+ console.log(`cleared excludeFromSync from ${cleared} config.json file(s)`);
+ console.log(
+ "\nRestart the editor: the runner reads the priority document on its " +
+ "next tick, but the worker pool and the heartbeat are armed at boot.",
+ );
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});