// THE AUTO-QUEUE'S HALF OF THE SETTINGS SCHEMA. // // Four lane policies live under `settings.autoQueue`, and until one-core phase 3 // slice 4a their defaults and their sanitizer lived in `jobs/autoQueuePolicy.ts` // beside the PICKER that reads them. That was the one back-edge `lib/settings.ts` // still carried (`common/architecture.test.ts`'s allow-list entry // `lib/settings.ts -> jobs/autoQueuePolicy`, now deleted): the model layer // importing dispatch to learn the shape of its own file. // // The split is by ROLE, not by size. What is here is everything that turns a // raw JSON value into a legal `AutoQueueSettings` — the defaults, the clamps, // the tree normalisation, the lane gate. What stays in `jobs/autoQueuePolicy.ts` // is everything that CHOOSES with it: bucket lists, pending-set construction and // the SWRR resolver. `autoQueuePolicy.ts` re-exports every name moved here, so // no existing import site changed. // // It never throws. Every function is total over `unknown`, because its input is // a file an operator may have hand-edited and a settings read may not fail. // // NO ZOD HERE, deliberately. Four `"use client"` forms import constants from // this module through `jobs/autoQueuePolicy` (LadderRung reads // AUTO_QUEUE_MODES), so anything this file imports can land in a browser // bundle. The zod field seam that wraps `sanitizeAutoQueue` lives in // `lib/settingsFieldSchemas.ts`, which only server code imports. import { type AutoQueueGroup, type AutoQueueKind, type AutoQueueMatch, type AutoQueueMatchType, type AutoQueueMode, type AutoQueueNode, type AutoQueueOrder, type AutoQueuePolicy, type AutoQueueSettings, LANES, isGroup, } from "./autoQueueTypes"; export const AUTO_QUEUE_MODES: ReadonlyArray = [ "strict", "round-robin", "weighted-fair", ]; export const AUTO_QUEUE_ORDERS: ReadonlyArray = [ "listed", "newest", "oldest", "cheapest", ]; // Coerce a stored/raw value to a legal order. Anything unrecognised — including // a missing field on a settings file written before the field existed — means // "listed", i.e. today's behaviour. One sanitizer, because the same enum is // stored on all four lane policies — it was stored in three MORE places before // slice 1.3 folded digest.recencyOrder and backfill.order into them — and copies // of this line would eventually disagree about what an absent field means. export function sanitizeAutoQueueOrder(value: unknown): AutoQueueOrder { return value === "newest" || value === "oldest" || value === "cheapest" ? value : "listed"; } export const AUTO_QUEUE_MAX_WORKERS_MAX = 64; // --- Defaults + sanitization (defensive, like sanitizeSyncScheduler) -------- function clampMaxWorkers(value: unknown): number | null { if (value == null) return null; if (typeof value !== "number" || !Number.isFinite(value)) return null; const n = Math.floor(value); if (n < 1) return null; return Math.min(n, AUTO_QUEUE_MAX_WORKERS_MAX); } function clampWeight(value: unknown): number { if (typeof value !== "number" || !Number.isFinite(value)) return 1; const n = Math.floor(value); return n < 1 ? 1 : Math.min(n, AUTO_QUEUE_MAX_WORKERS_MAX); } function sanitizeMatch(value: unknown): AutoQueueMatch { const r = (value ?? {}) as Record; const type: AutoQueueMatchType = r.type === "channel" || r.type === "platform" || r.type === "all" ? r.type : "all"; const out: AutoQueueMatch = { type }; if (typeof r.value === "string" && r.value.trim()) out.value = r.value.trim(); const operation = typeof r.operation === "string" && r.operation.trim() ? r.operation.trim() : ""; if (operation) { // Coerce-to-legal, this file's existing style: a leaf naming BOTH an // operation and a bucket is ambiguous, so the stored tree is not allowed to // express it. Operation wins and the bucket is dropped, rather than the // pair being kept and resolved differently by whichever reader looks first. out.operation = operation; return out; } if (typeof r.bucket === "string" && r.bucket.trim()) { out.bucket = r.bucket.trim(); } return out; } // Coerce a raw node, assigning a unique id (provided id preserved when valid and // not already taken, so persisted fairness state survives an unrelated edit). function sanitizeNode(value: unknown, seen: Set): AutoQueueNode { const r = (value ?? {}) as Record; const id = takeId(r.id, seen); const weight = clampWeight(r.weight); const maxWorkers = clampMaxWorkers(r.maxWorkers); if (Array.isArray(r.children)) { const mode: AutoQueueMode = AUTO_QUEUE_MODES.includes(r.mode as AutoQueueMode) ? (r.mode as AutoQueueMode) : "strict"; return { id, mode, weight, maxWorkers, children: r.children.map((c) => sanitizeNode(c, seen)), }; } return { id, match: sanitizeMatch(r.match), weight, maxWorkers }; } let idCounter = 0; function takeId(raw: unknown, seen: Set): string { let id = typeof raw === "string" && raw.trim() ? raw.trim() : ""; if (!id || seen.has(id)) { do { id = `node-${++idCounter}`; } while (seen.has(id)); } seen.add(id); return id; } function sanitizeRoot(value: unknown, seen: Set): AutoQueueGroup { const node = sanitizeNode( value && typeof value === "object" ? value : { mode: "strict", children: [] }, seen, ); if (isGroup(node)) return node; // A root that deserialized as a leaf is meaningless — wrap into an empty group. return { id: node.id, mode: "strict", weight: 1, maxWorkers: null, children: [] }; } function emptyRoot(): AutoQueueGroup { return { id: "root", mode: "strict", weight: 1, maxWorkers: null, children: [] }; } // THE DEFAULT TREE FOR A LANE, and the two answers are different on purpose. // // The runner lanes default to an EMPTY root: they have shipped that way since // the auto-queue existed, an empty tree dispatches nothing, and a settings file // that omits a root must keep meaning exactly that. // // The digest and backfill lanes default to one catch-all leaf, because their // work list is an operation's `ids` and a lane with no leaf at all could never // draw it. The leaf is inert while `enabled` is false — which is how they // default, and what keeps gate B (never enable the backfill lane against // ~66,540 missingInput videos by accident) a decision an operator still has to // take. function defaultRootFor(lane: AutoQueueKind): AutoQueueGroup { if (lane === "transcription" || lane === "download") return emptyRoot(); return { id: "root", mode: "strict", weight: 1, maxWorkers: null, children: [{ id: "all", match: { type: "all" }, weight: 1, maxWorkers: null }], }; } // The digest lane's historical ordering is SHORTEST-FIRST, and it is not // cosmetic: a 12-minute video is one chunk and a four-hour stream is thirty, so // draining the cheap end first is what makes a multi-week sweep show progress. // Defaulting the lane to "cheapest" is how that survives the move from the // sweep to the tree. The comparator arrives with the runner (slice 1.2); until // then next() has none for this order and falls back to today's. function defaultOrderFor(lane: AutoQueueKind): AutoQueueOrder { return lane === "digest" ? "cheapest" : "listed"; } // THE DEFAULT GATE FOR A LANE, and only one lane ships held. // // It is not a new policy — it is the reading the four retired pause fields gave // a file that named no gate, preserved. `transcriptionsPaused`, // `downloadsPaused` and `digest.digestsPaused` all defaulted false (free); // `backfill.enabled` defaulted FALSE and was INVERTED, so the backfill lane has // shipped HELD since it existed. S0-pause deleted the fields, which is what // makes defaulting this key correct — and required, because from slice 1.4 until // S0-pause an absent `held` had somewhere else to ask, and now it has not. // // The backfill lane is therefore off twice over on a fresh install: unarmed // (`enabled: false`) and held. That is gate B — never enable the backfill lane // against ~66,540 missingInput videos by accident — kept as two deliberate acts. function defaultHeldFor(lane: AutoQueueKind): boolean { return lane === "backfill"; } export function defaultAutoQueuePolicy( lane: AutoQueueKind = "transcription", ): AutoQueuePolicy { return { enabled: false, maxWorkers: null, replaceAutoSubs: false, order: defaultOrderFor(lane), snoozeUntil: null, held: defaultHeldFor(lane), root: defaultRootFor(lane), }; } export function defaultAutoQueue(): AutoQueueSettings { return Object.fromEntries( LANES.map((lane) => [lane, defaultAutoQueuePolicy(lane)]), ) as AutoQueueSettings; } // A snooze that has already lapsed is not a snooze: normalizing it to null here // means every reader (runner, status payload, UI) can treat "non-null" as "still // snoozed" without repeating the clock comparison. Re-sanitized on every read of // settings.json, so a stale value self-clears without anyone writing. function sanitizeSnooze(value: unknown): number | null { if (typeof value !== "number" || !Number.isFinite(value)) return null; const at = Math.floor(value); return at > Date.now() ? at : null; } // A LANE THAT IS NOT IN THE FILE IS THE LANE'S DEFAULT, not an empty object. // // Every settings.json in existence carries exactly two lanes, so the digest and // backfill blocks arrive `undefined` on every read until something writes them. // Coercing that to `sanitizePolicy({})` would give them an empty root — a lane // that can never draw anything even once an operator enables it — so the whole // default policy is the fallback, and only the fields the file actually names // override it. function sanitizePolicy(value: unknown, lane: AutoQueueKind): AutoQueuePolicy { if (value == null) return defaultAutoQueuePolicy(lane); const r = (value ?? {}) as Record; const seen = new Set(); return { enabled: r.enabled === true, maxWorkers: clampMaxWorkers(r.maxWorkers), // Opt-in only: anything but an explicit `true` (including a missing field on // a pre-existing settings.json) leaves the lane off. replaceAutoSubs: r.replaceAutoSubs === true, // Anything unrecognised (including a missing field) means the lane's own // default — "listed" for the runner lanes, "cheapest" for digest. order: r.order === undefined ? defaultOrderFor(lane) : sanitizeAutoQueueOrder(r.order), snoozeUntil: sanitizeSnooze(r.snoozeUntil), // THE LANE'S PAUSE GATE, and the only spelling of one since S0-pause deleted // the four legacy fields it migrated from. DEFAULTED, which it deliberately // was not while those fields existed: an absent key used to mean "ask the // retired field", so filling it in here would have read a paused corpus as // running. There is nothing left to ask, and `defaultHeldFor` is the // reading those fields gave a file that named no gate — free everywhere // except backfill, whose field was inverted and defaulted to held. held: typeof r.held === "boolean" ? r.held : defaultHeldFor(lane), root: r.root === undefined ? defaultRootFor(lane) : sanitizeRoot(r.root, seen), }; } export function sanitizeAutoQueue(value: unknown): AutoQueueSettings { if (!value || typeof value !== "object") return defaultAutoQueue(); const r = value as Record; return Object.fromEntries( LANES.map((lane) => [lane, sanitizePolicy(r[lane], lane)]), ) as AutoQueueSettings; }