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:
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