Archilyzer · Source

archilyzer

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

commit baa444159ba09c567401f2df981ba35d1cd8f714
parent eabbce526a21fa2b58c696c4f2f7e69bf24ea352
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 23 Sep 2026 19:22:03 -0400

settings: the auto-queue sanitizer moves to lib/autoQueueSchema.ts; zod joins common

one-core phase 3 slice 4a, commit 1. The auto-queue defaults, clamps, tree
normalisation and lane gate move from jobs/autoQueuePolicy.ts to
lib/autoQueueSchema.ts beside a zod `autoQueueSchema` seam; the picker stays
and re-exports every moved name. lib/settings.ts no longer imports jobs/, so
the allow-list entry `lib/settings.ts -> jobs/autoQueuePolicy` is burned
(10 -> 9). autoQueuePolicy.test.ts is repointed, not rewritten: 59/59.

zod ^4.3.6 added to common (already resolved in the lockfile; +3 lines).
Also adds plans/tools/phase3-settings-numbers.ts, the before/after
getSettings() measurement for this slice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/architecture.test.ts | 7-------
Mcommon/jobs/autoQueuePolicy.test.ts | 12+++++++++---
Mcommon/jobs/autoQueuePolicy.ts | 262++++++-------------------------------------------------------------------------
Acommon/lib/autoQueueSchema.ts | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/settings.ts | 9++++-----
Mcommon/package.json | 3++-
Aplans/tools/phase3-settings-numbers.ts | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpnpm-lock.yaml | 3+++
8 files changed, 466 insertions(+), 261 deletions(-)

diff --git a/common/architecture.test.ts b/common/architecture.test.ts @@ -62,13 +62,6 @@ const ROOTS = [ // Today's back-edges, `<file> -> <imported module>`, each with why it is still // here. THIS LIST IS A DEBT LEDGER, not a policy. const ALLOWED: Record<string, string> = { - // The four auto-queue SANITIZERS are the auto-queue's half of the settings - // schema. They belong in lib/ with the rest of it; that move is phase 3 slice - // 4 (one schema library, one writer), not a rename. The tree/settings TYPES - // already moved (lib/autoQueueTypes.ts). - "lib/settings.ts -> jobs/autoQueuePolicy": - "defaultAutoQueue + three sanitizers; moves with the settings schema in phase 3", - // Three progress-parser factories for the transcription apps. Parsing a // subprocess's stdout is dispatch's job, not the model's; the fix is for the // app descriptor to name a parser the runner resolves, which is phase 1 work. diff --git a/common/jobs/autoQueuePolicy.test.ts b/common/jobs/autoQueuePolicy.test.ts @@ -9,7 +9,6 @@ import { bucketIdsFrom, bucketLaneWorkIds, bucketsForKind, - defaultAutoQueue, defaultBucketsForPolicy, defaultDrawsForPolicy, isGroup, @@ -18,10 +17,17 @@ import { emptyAutoQueueRuntime, flattenLeaves, policyDrawsBucket, - sanitizeAutoQueue, - sanitizeAutoQueueOrder, selectNextWork, } from "./autoQueuePolicy"; +// REPOINTED, NOT REWRITTEN (one-core phase 3 slice 4a). The defaults and the +// sanitizer moved to lib/autoQueueSchema.ts; every assertion below is the one it +// was, which is the point — this file is the proof that the move changed no +// answer, from the `held` defaults to the tree normalisation. +import { + defaultAutoQueue, + sanitizeAutoQueue, + sanitizeAutoQueueOrder, +} from "../lib/autoQueueSchema"; import { bucketLaneOperationId } from "../lib/operations"; // Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test common/jobs/autoQueuePolicy.test.ts diff --git a/common/jobs/autoQueuePolicy.ts b/common/jobs/autoQueuePolicy.ts @@ -30,6 +30,23 @@ export type { }; export { LANES, isGroup }; +// THE SANITIZER MOVED DOWN A LAYER (one-core phase 3 slice 4a). Everything that +// turns a raw settings.json value into a legal `AutoQueueSettings` now lives in +// `lib/autoQueueSchema.ts`, with the rest of the settings schema — which is what +// let `lib/settings.ts` stop importing this file. Re-exported here so every +// existing `from "../jobs/autoQueuePolicy"` import still resolves, exactly as +// the TYPES above are. +export { + AUTO_QUEUE_MODES, + AUTO_QUEUE_ORDERS, + AUTO_QUEUE_MAX_WORKERS_MAX, + autoQueueSchema, + defaultAutoQueue, + defaultAutoQueuePolicy, + sanitizeAutoQueue, + sanitizeAutoQueueOrder, +} from "../lib/autoQueueSchema"; + // Pure, side-effect-free policy engine for the automatic priority queue. It // decides WHICH pending video to process next, across all channels, from a @@ -46,34 +63,6 @@ export { LANES, isGroup }; // falls through to the next-priority sibling — exactly like HTB's class ceil. -export const AUTO_QUEUE_MODES: ReadonlyArray<AutoQueueMode> = [ - "strict", - "round-robin", - "weighted-fair", -]; - -export const AUTO_QUEUE_ORDERS: ReadonlyArray<AutoQueueOrder> = [ - "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"; -} - - - // Buckets each runner kind draws from, in priority order. A leaf with no // explicit bucket draws from the whole list (union, deduped); the list order is // its internal priority. Single source of truth for the runner, the pending- @@ -263,7 +252,6 @@ export function policyDrawsBucket( ); } -export const AUTO_QUEUE_MAX_WORKERS_MAX = 64; // --- Runtime fairness state (persisted best-effort by autoQueueState.ts) ---- @@ -545,219 +533,3 @@ export function selectNextWork( return pick(root, pending, runtime, active, []); } -// --- 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<string, unknown>; - 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<string>): AutoQueueNode { - const r = (value ?? {}) as Record<string, unknown>; - 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>): 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<string>): 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<string, unknown>; - const seen = new Set<string>(); - 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<string, unknown>; - return Object.fromEntries( - LANES.map((lane) => [lane, sanitizePolicy(r[lane], lane)]), - ) as AutoQueueSettings; -} diff --git a/common/lib/autoQueueSchema.ts b/common/lib/autoQueueSchema.ts @@ -0,0 +1,291 @@ +// 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. + +import { z } from "zod"; +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<AutoQueueMode> = [ + "strict", + "round-robin", + "weighted-fair", +]; + +export const AUTO_QUEUE_ORDERS: ReadonlyArray<AutoQueueOrder> = [ + "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<string, unknown>; + 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<string>): AutoQueueNode { + const r = (value ?? {}) as Record<string, unknown>; + 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>): 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<string>): 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<string, unknown>; + const seen = new Set<string>(); + 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<string, unknown>; + return Object.fromEntries( + LANES.map((lane) => [lane, sanitizePolicy(r[lane], lane)]), + ) as AutoQueueSettings; +} + +// THE LANE POLICIES AS ONE SCHEMA FIELD. +// +// `sanitizeAutoQueue` above IS the parser — it is total, it never throws, and it +// is what fifteen hundred lines of tests pin. The schema is the seam that lets +// `lib/settingsSchema.ts` compose it with the other thirty fields without +// knowing any of that: zod supplies the plumbing (a key, a strip, a `.catch`), +// the sanitizer supplies the arithmetic. Same shape as `workersSchema` and +// `channelPrioritySchema`. +export const autoQueueSchema = z + .unknown() + .catch(undefined) + .transform((value): AutoQueueSettings => sanitizeAutoQueue(value)); diff --git a/common/lib/settings.ts b/common/lib/settings.ts @@ -33,14 +33,13 @@ import { type StorageSettings, type StorageVolume, } from "./storageLocations"; -// The four SANITIZERS still come from the engine. They are the auto-queue's -// half of the settings schema and belong in lib/ with the rest of it, but that -// move is phase 3 slice 4 (one schema, one writer) — not a rename. Recorded in -// ../architecture.test.ts's allow-list until then. +// The auto-queue's half of the settings schema, in lib/ with the rest of it +// since one-core phase 3 slice 4a. It used to come from `jobs/autoQueuePolicy`, +// which was the last back-edge on ../architecture.test.ts's allow-list. import { defaultAutoQueue, sanitizeAutoQueue, -} from "../jobs/autoQueuePolicy"; +} from "./autoQueueSchema"; import { DEFAULT_DIARIZATION_ENGINE, DEFAULT_DIARIZATION_THRESHOLD, diff --git a/common/package.json b/common/package.json @@ -65,7 +65,8 @@ "recharts": "2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.3.6" }, "peerDependencies": { "next": "16.2.3", diff --git a/plans/tools/phase3-settings-numbers.ts b/plans/tools/phase3-settings-numbers.ts @@ -0,0 +1,140 @@ +#!/usr/bin/env tsx +// The one-core Phase 3 slice 4a measurement: what `getSettings()` ANSWERS, for +// every settings.json this repo can point at, printed deterministically so two +// runs can be diffed. +// +// WHY THIS IS A SCRIPT AND NOT A TEST, same as phase1-numbers.ts next door. +// Slice 4a replaces ten hand-written sanitizers with one zod schema. The claim +// it makes is "nothing an operator has configured reads differently", and the +// only way to check that is to parse the REAL files — the live corpus's +// settings.json, the shipped example, the e2e fixture — before and after, and +// diff two files. A test would have to carry the operator's configuration. +// +// STRICTLY READ-ONLY. It opens each file and writes nothing anywhere. It must +// never be pointed at a settings.json through a writer. +// +// NEVER BOOT AN EDITOR FOR THIS. `getSettings` is called in-process, offline; +// instrumentation.ts is not loaded, so no runner, sweep or scheduler is armed. +// +// ONE PROCESS PER FILE, because `getPaths()` memoises its answer at module +// scope: `SETTINGS_FILE` has to be set before `lib/settings.ts` is imported, so +// a second file needs a second process. The parent below spawns itself once per +// target; the child prints one block. +// +// Usage, from the repo root: +// node_modules/.bin/tsx plans/tools/phase3-settings-numbers.ts +// node_modules/.bin/tsx plans/tools/phase3-settings-numbers.ts live=/abs/settings.json +// +// Each argv entry is `label=path`. Given any, they REPLACE the default list; +// the label is what the output names, so a file copied elsewhere (an archived +// "before" example, say) can still be diffed against its original line for line. + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(HERE, "..", ".."); + +type Target = { label: string; file: string }; + +// THE LIVE CORPUS'S settings.json, not this checkout's. A worktree carries its +// own copy, and the file that matters is the one the editor actually runs on. +// Overridable so the script is not pinned to one machine's layout. +function liveSettingsFile(): string { + return ( + process.env.LIVE_SETTINGS_FILE ?? + path.join( + path.dirname(REPO), + "yt-dlp-transcript-browser", + "settings.json", + ) + ); +} + +function defaultTargets(): Target[] { + const out: Target[] = [ + { label: "live", file: liveSettingsFile() }, + { label: "example", file: path.join(REPO, "settings.json.example") }, + ]; + const fixtures = path.join(REPO, "editor", "e2e", "fixtures"); + for (const name of fs.readdirSync(fixtures).sort()) { + if (!/settings.*\.json$/i.test(name)) continue; + out.push({ label: `fixture:${name}`, file: path.join(fixtures, name) }); + } + return out; +} + +// Sort every object's keys so the output is diffable regardless of the order a +// sanitizer (or a schema) happens to build its result in. Arrays keep their +// order — in settings.json order IS data (workers are priority-ordered, and an +// auto-queue tree's children compete in the order they are listed). +function sortedKeys(_key: string, value: unknown): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + const src = value as Record<string, unknown>; + const out: Record<string, unknown> = {}; + for (const k of Object.keys(src).sort()) out[k] = src[k]; + return out; +} + +async function child(file: string): Promise<void> { + process.env.SETTINGS_FILE = file; + const { getSettings } = await import("../../common/lib/settings"); + console.log(JSON.stringify(getSettings(), sortedKeys, 2)); +} + +function parent(targets: Target[]): void { + console.log("# one-core phase 3 slice 4a — getSettings() over every settings file"); + console.log(""); + for (const { label, file } of targets) { + console.log(`## ${label}`); + if (!fs.existsSync(file)) { + console.log("MISSING"); + console.log(""); + continue; + } + const res = spawnSync( + process.execPath, + [ + path.join(REPO, "node_modules", "tsx", "dist", "cli.mjs"), + fileURLToPath(import.meta.url), + ], + { + cwd: REPO, + encoding: "utf8", + env: { + ...process.env, + PHASE3_SETTINGS_TARGET: file, + // The snooze sanitizer compares against the clock, and a storage + // probe would shell out. Neither is settings data; neither is read + // here. (`sanitizeSnooze` self-clears a lapsed snooze, which is + // stable as long as nothing is snoozed — noted, not worked around.) + TZ: "UTC", + }, + }, + ); + if (res.status !== 0) { + console.log(`FAILED status=${res.status}`); + console.log(res.stderr.trim()); + } else { + console.log(res.stdout.trimEnd()); + } + console.log(""); + } +} + +const target = process.env.PHASE3_SETTINGS_TARGET; +if (target) { + await child(target); +} else { + const args = process.argv.slice(2); + const targets: Target[] = args.length + ? args.map((a) => { + const eq = a.indexOf("="); + if (eq < 0) return { label: path.basename(a), file: path.resolve(a) }; + return { label: a.slice(0, eq), file: path.resolve(a.slice(eq + 1)) }; + }) + : defaultTargets(); + parent(targets); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@types/d3-scale': specifier: ^4.0.9