// THE RETIRED FIELDS, ON READ. A settings key that used to live somewhere else // is filled in from the field it replaced, in getSettings, when — and only when // — the new spelling is absent. What survives here is slice 1.3's: the sweeps' // scope becoming a lane's tree. // // SLICE 1.4's PAUSE MIGRATION USED TO LIVE AT THE FOOT OF THIS FILE and is // gone (slice S0-pause). It copied `transcriptionsPaused`, `downloadsPaused`, // `digest.digestsPaused` and the inverted `backfill.enabled` onto // `autoQueue[lane].held` for one release; those four fields no longer exist in // `SiteSettings`, so there is nothing left to copy and `isGateHeld` reads the // key alone. A settings.json still spelling one is read past and loses it on // the next write. // // THE SWEEPS' LAST ACT: their persisted scope becomes a lane's tree. // // Two sweeps armed themselves through ten fields on `settings.digest` and // `settings.backfill` — a flag, a channel list, an operation list and an // order apiece. Slice 1.3 deletes all of that: a lane is armed by // `autoQueue[lane].enabled` and scoped by `autoQueue[lane].root`, the same two // things that have armed and scoped the transcription and download lanes since // the auto-queue existed. // // So the fields have to land somewhere on the way out, and this is the pure // function that lands them. It runs on READ (getSettings), not as a one-shot // script, for the reason every migration in this file's neighbourhood does: the // editor is not the only reader of settings.json — bin/ scripts, the MCP server // and the export build all call getSettings — and a migration that only ran // when someone opened a page would give two readers two different answers about // what is armed. // // THREE RULES, each of which is a bug it is written to avoid: // // 1. IT NEVER ENABLES A LANE THE FLAG DID NOT. `sweepEnabled: false` becomes // `enabled: false`. The backfill lane as configured on the live corpus // would re-fetch audio for ~66,540 videos; an off-by-one in a migration is // not an acceptable way to find that out. // 2. IT ONLY APPLIES WHEN THE LANE BLOCK IS ABSENT FROM THE FILE. A file that // already carries `autoQueue.digest` has been through here (or has been // hand-edited, which is the same claim) and is returned untouched — so the // function is idempotent by construction rather than by a version marker // nobody would maintain. // 3. AN EMPTY CHANNEL LIST IS ONE `{type:"all"}` LEAF, because that is what an // empty `sweepChannels` meant: `startDigestSweep` passes // `scope.length > 0 ? scope : undefined` and an absent scope is the whole // corpus. A leaf per channel would have been the same thing today and a // different thing the day a channel is added. // // It lives in lib/ and imports nothing from jobs/: it produces RAW nodes, which // `sanitizeAutoQueue` then normalizes exactly as it normalizes a hand-edited // file. That is deliberate — a migration that built already-sanitized objects // would be a second implementation of the sanitizer, and the two would drift. import { type AutoQueueGroup, type AutoQueueLeaf, type AutoQueueMatch, } from "./autoQueueTypes"; // A lane's scope in the terms the sweeps used: some channels, some operations. // Both empty means "everything", which is what an unscoped sweep meant. export type LaneScope = { channels?: readonly string[] | undefined; operations?: readonly string[] | undefined; // Every operation the lane currently dispatches, when the caller knows it. // // NAMING ALL OF THEM IS NOT THE SAME AS NAMING TODAY'S THREE: a leaf that // names none draws the lane's whole union, so it TRACKS THE REGISTRY and an // operation enabled later is picked up rather than silently excluded forever. // So a scope covering everything collapses to no operation at all, and only a // strict subset reaches the leaves. // // Optional because the settings migration's caller (getSettings) cannot ask // the registry — `lib/operations.ts` imports the controller layer, and pulling // that into every reader of settings.json to tidy one list would be a much // larger change than the tidying is worth. Absent means "no collapse", which // is a no-op on every settings file in existence: the live `sweepKinds` is // empty, and an empty list already means the union. available?: readonly string[] | undefined; }; function slugs(value: unknown): string[] { if (!Array.isArray(value)) return []; const out: string[] = []; for (const v of value) { if (typeof v !== "string") continue; const s = v.trim(); if (s && !out.includes(s)) out.push(s); } return out; } // THE ONE LEAF BUILDER, shared by the migration below and by the editor's // `armLaneAction`. That sharing is the point: "arming a lane from the console // produces the tree the migration would have produced" is a property worth // having, and the cheapest way to have it is for there to be one function. // // Ids are DERIVED, not generated: `digest-teamrcn`, `backfill-all-diarization`. // The sanitizer would happily assign `node-7`, but a lane's fairness memory and // its pick log are keyed by leaf id, so a stable id means re-arming the same // scope keeps the ledger it had. export function laneRootFromScope( lane: string, scope: LaneScope, ): AutoQueueGroup { const channels = slugs(scope.channels); const operations = collapseFullScope( slugs(scope.operations), slugs(scope.available), ); const targets: AutoQueueMatch[] = channels.length > 0 ? channels.map((value) => ({ type: "channel" as const, value })) : [{ type: "all" as const }]; const leaf = (match: AutoQueueMatch): AutoQueueLeaf => ({ id: [lane, match.value ?? "all", match.operation].filter(Boolean).join("-"), match, weight: 1, maxWorkers: null, }); const children: AutoQueueLeaf[] = operations.length > 0 ? targets.flatMap((match) => operations.map((operation) => leaf({ ...match, operation })), ) : targets.map((match) => leaf(match)); return { id: `${lane}-root`, // STRICT, like every tree an operator has ever been handed: the sweeps // walked their scope in order and never interleaved, and round-robin would // be a dispatch change dressed up as a migration. mode: "strict", weight: 1, maxWorkers: null, children, }; } // A scope that names every operation the lane has means the same thing as a // scope that names none — see LaneScope.available. ONE definition, called by the // migration and by the editor's arm action through this one builder, so the two // cannot answer it differently. function collapseFullScope( operations: readonly string[], available: readonly string[], ): string[] { if (operations.length === 0 || available.length === 0) return [...operations]; const covers = operations.length >= available.length && available.every((id) => operations.includes(id)); return covers ? [] : [...operations]; } type RawRecord = Record; function asRecord(value: unknown): RawRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as RawRecord) : {}; } // The `autoQueue` object to hand `sanitizeAutoQueue`, with the two operation // lanes filled in from the retired sweep fields when — and only when — the file // does not already spell them. // // Takes the PARSED FILE, not the merged settings object, because "absent from // the file" is the whole trigger and a merged object has already had the // defaults folded in. Returns a new object; the input is never mutated. export function migrateSweepsToLanes( parsed: unknown, // The operations each lane dispatches, when the caller can ask the registry. // See LaneScope.available for why this is optional and why absent is a no-op // on every settings file that exists. laneOperations: Partial> = {}, ): RawRecord { const file = asRecord(parsed); const autoQueue = { ...asRecord(file.autoQueue) }; if (autoQueue.digest === undefined) { const digest = asRecord(file.digest); autoQueue.digest = { enabled: digest.sweepEnabled === true, // NO `order`. The lane's default is `cheapest`, which is the composition // the digest batch actually ran — duration inside a day, newest day // first — and `digest.recencyOrder` was only ever the DATE HALF of it. // Migrating it onto `order` would replace the composition with one of its // two terms and silently drop the other; the runner fixes the date half // at newest-first (the live value) instead. A digest lane that wants pure // recency sets `order: "newest"` and gets no duration term at all. root: laneRootFromScope("digest", { channels: slugs(digest.sweepChannels), }), }; } if (autoQueue.backfill === undefined) { const backfill = asRecord(file.backfill); const order = backfill.order; autoQueue.backfill = { enabled: backfill.sweepEnabled === true, // Carried, unlike digest's: the backfill lane's order was a plain // recency order with no second term to lose, and the runner reads the // same enum. An unrecognised value is the sanitizer's problem. ...(order === undefined ? {} : { order }), root: laneRootFromScope("backfill", { channels: slugs(backfill.sweepChannels), // `sweepKinds` was the operation scope, and empty meant "every enabled // operation on the lane" — which is exactly what a leaf naming no // operation draws. So an empty list produces plain channel leaves and a // non-empty one produces a leaf per (channel, operation). operations: slugs(backfill.sweepKinds), available: laneOperations.backfill, }), }; } return autoQueue; }