#!/usr/bin/env tsx import path from "node:path"; import { readdir, readFile, writeFile } from "node:fs/promises"; import { writeJsonAtomic } from "../lib/jsonFile-server"; import { getPaths } from "../lib/paths"; import { getSettings, writeSettings } from "../lib/settings"; 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. // // WHY IT READS THE CONFIGS RAW, and not through `listChannelConfigs`. // `excludeFromSync` is DELETED from `ChannelConfig` in the same slice, and // `parseChannelConfig` is allow-list style — so the parser now drops the very // key this migration exists to read. The raw file is the only place the flag // still exists, which is also why the clearing pass below is a raw key delete // rather than a parse-and-rewrite. // // 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. // // 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 // 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(); // One channel, as the migration needs it: the slug and the RAW parsed JSON of // its config.json. A directory with no readable config.json is skipped, the // same channels `listChannelConfigs` would have returned. type RawChannel = { slug: string; raw: Record }; async function readRawChannels(): Promise { const entries = await readdir(paths.channelsDir, { withFileTypes: true, }).catch(() => []); const out: RawChannel[] = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const file = path.join(paths.channelsDir, entry.name, "config.json"); let raw: unknown; try { raw = JSON.parse(await readFile(file, "utf8")); } catch { continue; } if (!raw || typeof raw !== "object") continue; out.push({ slug: entry.name, raw: raw as Record }); } return out.sort((a, b) => a.slug.localeCompare(b.slug)); } // 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; if (Array.isArray(rec.children)) { for (const c of rec.children) walk(c); return; } const match = rec.match as Record | 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, 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 { const file = path.join(paths.channelsDir, slug, "config.json"); let raw: string; try { raw = await readFile(file, "utf8"); } catch { return false; } let parsed: Record; try { parsed = JSON.parse(raw) as Record; } catch { console.warn(` ! ${slug}: config.json is not valid JSON — left alone`); return false; } if (!("excludeFromSync" in parsed)) return false; delete parsed.excludeFromSync; await writeJsonAtomic(file, parsed); return true; } async function main(): Promise { const settings = getSettings(); const channels = await readRawChannels(); const slugs = channels.map((c) => c.slug); // `LegacyChannelRow` is structural and names only the one key, so the raw // objects satisfy it directly. const configs = channels.map(({ slug, raw }) => ({ slug, config: { excludeFromSync: raw.excludeFromSync === true }, })); const transcription = bareLeaves(settings.autoQueue.transcription?.root); const download = bareLeaves(settings.autoQueue.download?.root); const excludedSlugs = new Set( configs.filter((c) => c.config.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; } // 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 }; // 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(`wrote ${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); });