Archilyzer · Source

archilyzer

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

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:
Acommon/bin/migrate-channel-priority.ts | 280+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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); +});