commit 78e4ce5e48fa9a8e01d9ebde243b138d5c52d8f9
parent 69d0e9a6ca1495b8a4d01c6e5afe665ebf308b1a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 7 Sep 2026 15:49:48 -0400
common: the layering is a test, and the auto-queue types move down to it
`common/architecture.test.ts` greps every static import in common/ and fails
on a back-edge: lib/ may not import controller/ or jobs/, jobs/ may not import
controller/, components/ may not import controller/, jobs/ or ytdlp/. Modelled
on controller/noCorpusWalkInRenderPaths.test.ts — the one architecture guard
the repo already had, and the one that works.
The allow-list can only SHRINK. A back-edge not on it fails; an entry on it
that is no longer a back-edge fails too, so the ledger cannot rot. Verified
both directions: adding a probe import to lib/paths.ts fails test 1.
Back-edges before: 17 import lines / 16 distinct edges. After: 13 / 12.
Burned down, all type-only, nothing on disk or on the wire moves:
new common/lib/autoQueueTypes.ts holds the persisted auto-queue SHAPE
(AutoQueueMode/MatchType/Order/Reach/Match/Leaf/Group/Node/Policy/Settings,
isGroup, AutoQueueKind). jobs/autoQueuePolicy.ts and jobs/autoQueueState.ts
re-export every name, so no import site outside common/lib changed.
- lib/settings.ts -> jobs/autoQueuePolicy (the AutoQueueSettings re-export)
- lib/pauseGates.ts -> jobs/autoQueueState (AutoQueueKind)
- lib/operations.ts -> jobs/autoQueueState (AutoQueueKind)
- lib/sweepPlan.ts -> jobs/autoQueuePolicy (AutoQueueOrder)
Left on the allow-list, each with its reason in the file:
lib/settings.ts -> jobs/autoQueuePolicy 4 sanitizers = the auto-queue's
half of the settings schema; moves with phase 3 slice 4, not by rename.
lib/transcriptionApps.ts -> jobs/progressParsers 3 parser factories; the
app descriptor should name a parser the runner resolves (phase 1).
lib/operations.ts -> controller/{diarizeOne,attributionTarget,attributeOne,
normalizeTranscript} (+3 in operations.test.ts) the registry's run()
closures call the engines; inverts when descriptors take a run hook
(phase 1 step 4). This is THE structural back-edge.
lib/sweepPlan.ts -> controller/planOrder resolved when the four sweep
planners become one (phase 1 step 3).
jobs/snapshotScheduler.ts -> controller/channelSnapshot,
jobs/workerPool.ts -> controller/remoteCapacity schedulers importing the
work they schedule; inverts when dispatch is one scheduler (phase 1).
components/StreamActionLog.tsx -> jobs/streamCommand StreamActionResult
transitively needs JobStatus, so not a one-line move: it becomes a
view-model in common/views/ (phase 3 slice 1).
common's `test` glob gained `*.test.ts` so a top-level test file runs.
Verified: `pnpm --filter yt-dlp-transcript-common test` 876 tests, 876 pass,
0 fail (was 874 + the 2 new). `tsc --noEmit` clean in common, editor, export,
mcp, homepage, umtool.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
9 files changed, 373 insertions(+), 129 deletions(-)
diff --git a/common/architecture.test.ts b/common/architecture.test.ts
@@ -0,0 +1,183 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readdir, readFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common test
+//
+// THE LAYERING, ENFORCED. `plans/one-core.md` names six core layers with
+// dependency strictly downward:
+//
+// model (lib/) -> corpus -> operations -> dispatch (jobs/, controller/)
+// -> publish -> views -> ui (components/)
+//
+// Layering that exists only by convention is layering that has already been
+// broken — the survey behind that plan counted sixteen back-edges nobody meant
+// to add. This test is the same trick as ./controller/noCorpusWalkInRenderPaths.
+// test.ts, which is the one architecture guard the repo already had and which
+// works: a context-blind grep, cheap enough to run on every commit.
+//
+// THE ALLOW-LIST CAN ONLY SHRINK. A back-edge that is not on it fails the
+// build; an entry on it that is no longer a back-edge ALSO fails the build, so
+// the list cannot rot into a description of a tree that moved on. Deleting an
+// entry is the deliverable of a later slice — never add one to get green.
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+
+// Which directory may not import which. Read as "lib/ may not import
+// controller/ or jobs/".
+const FORBIDDEN: Record<string, readonly string[]> = {
+ lib: ["controller", "jobs"],
+ jobs: ["controller"],
+ components: ["controller", "jobs", "ytdlp"],
+};
+
+// The directories walked. `bin/` and `social/` are scanned so a back-edge cannot
+// hide behind an entry point, even though nothing forbids their imports yet.
+const ROOTS = ["lib", "controller", "jobs", "components", "social", "ytdlp", "bin"];
+
+// 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.
+ "lib/transcriptionApps.ts -> jobs/progressParsers":
+ "createTranscribeProgressParser and friends; the descriptor should name a parser instead (phase 1)",
+
+ // The operation registry's `run(unit)` closures call the controllers that do
+ // the work. This is THE structural back-edge the plan's layering fixes: an
+ // OperationDescriptor should carry a run hook the dispatch layer supplies,
+ // not import the engine. Phase 1 step 4 (one operationBatch) is where it goes.
+ "lib/operations.ts -> controller/diarizeOne":
+ "registry run() closure; inverts when descriptors take a run hook (phase 1)",
+ "lib/operations.ts -> controller/attributionTarget":
+ "registry run() closure; inverts when descriptors take a run hook (phase 1)",
+ "lib/operations.ts -> controller/attributeOne":
+ "registry run() closure; inverts when descriptors take a run hook (phase 1)",
+ "lib/operations.ts -> controller/normalizeTranscript":
+ "registry state() freshness check; inverts with the run hook (phase 1)",
+ "lib/operations.test.ts -> controller/attributeOne":
+ "test of the above; moves with it",
+ "lib/operations.test.ts -> controller/backfillBatch":
+ "test of the above; moves with it",
+ "lib/operations.test.ts -> controller/normalizeTranscript":
+ "test of the above; moves with it",
+
+ // One planner calling the recency ordering, which is cached against LMDB.
+ // Phase 1 step 3 folds sweepPreview.ts and sweepRecency.ts into sweepPlan.ts
+ // and decides which side of the line the result lands on.
+ "lib/sweepPlan.ts -> controller/planOrder":
+ "orderPlanByRecency; resolved when the four sweep planners become one (phase 1 step 3)",
+
+ // Two schedulers reaching into the controller for the thing they schedule.
+ // Real work, not a type: the snapshot builder and the remote-capacity probe.
+ "jobs/snapshotScheduler.ts -> controller/channelSnapshot":
+ "schedules the snapshot build it imports; inverts when dispatch is one scheduler (phase 1)",
+ "jobs/workerPool.ts -> controller/remoteCapacity":
+ "probes remote worker capacity; same (phase 1)",
+
+ // A UI component naming a job result. The type transitively needs JobStatus
+ // from the job registry, so it is not a one-line type move: the fix is a
+ // view-model in common/views/ (phase 3 slice 1).
+ "components/StreamActionLog.tsx -> jobs/streamCommand":
+ "StreamActionResult type; becomes a view-model in common/views/ (phase 3)",
+};
+
+// Static `import ... from "x"`, `export ... from "x"` and bare `import "x"`.
+// Deliberately simple, exactly like the guard next door: a regex that reads the
+// specifier is enough, and a dynamic import that dodges it is a bigger problem
+// than this test.
+const IMPORT_RE =
+ /(?:^|\n)\s*(?:import|export)\s[^;]*?from\s*["']([^"']+)["']|(?:^|\n)\s*import\s*["']([^"']+)["']/g;
+
+async function walk(dir: string): Promise<string[]> {
+ const out: string[] = [];
+ let entries;
+ try {
+ entries = await readdir(dir, { withFileTypes: true });
+ } catch {
+ return out;
+ }
+ for (const e of entries) {
+ const full = path.join(dir, e.name);
+ if (e.isDirectory()) {
+ if (e.name === "node_modules" || e.name === ".next") continue;
+ out.push(...(await walk(full)));
+ } else if (/\.tsx?$/.test(e.name)) {
+ out.push(full);
+ }
+ }
+ return out;
+}
+
+const topDir = (rel: string) => rel.split(path.sep)[0];
+
+async function backEdges(): Promise<string[]> {
+ const found: string[] = [];
+ for (const root of ROOTS) {
+ for (const file of await walk(path.join(HERE, root))) {
+ const rel = path.relative(HERE, file);
+ const from = topDir(rel);
+ const forbidden = FORBIDDEN[from];
+ if (!forbidden) continue;
+ const source = await readFile(file, "utf8");
+ IMPORT_RE.lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = IMPORT_RE.exec(source))) {
+ const spec = m[1] ?? m[2];
+ // Only relative specifiers can cross a layer inside this package.
+ if (!spec.startsWith(".")) continue;
+ const target = path.relative(
+ HERE,
+ path.resolve(path.dirname(file), spec),
+ );
+ const to = topDir(target);
+ if (to === from) continue;
+ if (!forbidden.includes(to)) continue;
+ const edge = `${rel} -> ${target}`;
+ if (!found.includes(edge)) found.push(edge);
+ }
+ }
+ }
+ return found.sort();
+}
+
+test("no new back-edges between common's layers", async () => {
+ const found = await backEdges();
+ // Guard the guard: an empty walk would make every assertion below vacuous.
+ assert.ok(
+ found.length > 0 || Object.keys(ALLOWED).length === 0,
+ "the scan found nothing at all — did the directory layout move?",
+ );
+
+ const unexpected = found.filter((e) => !(e in ALLOWED));
+ assert.deepEqual(
+ unexpected,
+ [],
+ `NEW back-edge(s) in common/. lib/ may not import controller/ or jobs/; ` +
+ `jobs/ may not import controller/; components/ may not import ` +
+ `controller/, jobs/ or ytdlp/. Move the type or the function down a ` +
+ `layer instead of adding it to ALLOWED. Found: ${unexpected.join(", ")}`,
+ );
+});
+
+test("the back-edge allow-list has no stale entries", async () => {
+ const found = await backEdges();
+ const stale = Object.keys(ALLOWED).filter((e) => !found.includes(e));
+ assert.deepEqual(
+ stale,
+ [],
+ `these are no longer back-edges — delete them from ALLOWED so the list ` +
+ `keeps meaning what it says: ${stale.join(", ")}`,
+ );
+});
diff --git a/common/jobs/autoQueuePolicy.ts b/common/jobs/autoQueuePolicy.ts
@@ -1,5 +1,36 @@
import type { Platform } from "../lib/platform";
+// The persisted tree/settings SHAPE lives in the model layer — lib/ may not
+// import jobs/, and settings.ts has to name an AutoQueueSettings. Re-exported
+// here so every existing `from "../jobs/autoQueuePolicy"` import still resolves.
+import type {
+ AutoQueueGroup,
+ AutoQueueLeaf,
+ AutoQueueMatch,
+ AutoQueueMatchType,
+ AutoQueueMode,
+ AutoQueueNode,
+ AutoQueueOrder,
+ AutoQueuePolicy,
+ AutoQueueReach,
+ AutoQueueSettings,
+} from "../lib/autoQueueTypes";
+import { isGroup } from "../lib/autoQueueTypes";
+export type {
+ AutoQueueGroup,
+ AutoQueueLeaf,
+ AutoQueueMatch,
+ AutoQueueMatchType,
+ AutoQueueMode,
+ AutoQueueNode,
+ AutoQueueOrder,
+ AutoQueuePolicy,
+ AutoQueueReach,
+ AutoQueueSettings,
+};
+export { isGroup };
+
+
// Pure, side-effect-free policy engine for the automatic priority queue. It
// decides WHICH pending video to process next, across all channels, from a
// configurable tree. The runner (common/controller/autoRunner.ts) does the I/O
@@ -14,9 +45,6 @@ import type { Platform } from "../lib/platform";
// worker count at its `maxWorkers` ceiling) reads as "no work" and the algorithm
// falls through to the next-priority sibling — exactly like HTB's class ceil.
-// --- Tree types -------------------------------------------------------------
-
-export type AutoQueueMode = "strict" | "round-robin" | "weighted-fair";
export const AUTO_QUEUE_MODES: ReadonlyArray<AutoQueueMode> = [
"strict",
@@ -24,43 +52,12 @@ export const AUTO_QUEUE_MODES: ReadonlyArray<AutoQueueMode> = [
"weighted-fair",
];
-export type AutoQueueMatchType = "channel" | "platform" | "all";
-
-// How videos are ordered WITHIN a rule, across every channel and bucket that
-// rule claims. "listed" is the historical behaviour: whatever order
-// buildPendingByLeaf accumulated, which is bucket order over channel order over
-// each bucket array's own order (channelSnapshot sorts most buckets
-// lexicographically by video id, and leaves undownloadedIds in playlist order).
-// "newest"/"oldest" sort each leaf's claimed list by an upload-date key supplied
-// by the caller as a comparator — the engine stays pure and never reads disk.
-//
-// This deliberately does NOT reorder RULES: the tree is what expresses
-// priority. A newest-first archive is one catch-all rule with order "newest".
-export type AutoQueueOrder = "listed" | "newest" | "oldest";
-
export const AUTO_QUEUE_ORDERS: ReadonlyArray<AutoQueueOrder> = [
"listed",
"newest",
"oldest",
];
-// How far an order REACHES.
-//
-// "channel" — sort each channel's own candidates (today, and the default). A
-// corpus-wide sweep still visits channels heaviest-first, so a video
-// uploaded this morning waits for its channel's turn.
-// "corpus" — additionally order the CHANNELS by their freshest (or oldest)
-// pending video, so the channel holding the newest work goes first.
-//
-// It is a separate axis from AutoQueueOrder rather than two more enum members
-// because it is meaningless without one: reach only says how widely an order
-// applies, and "listed" has no order to apply. The console disables the control
-// while "listed" is selected for exactly that reason.
-//
-// This deliberately does NOT interleave individual videos across channels —
-// that would break one-job-per-channel, and 77,000 single-video jobs would evict
-// the registry's 100 records.
-export type AutoQueueReach = "channel" | "corpus";
export const AUTO_QUEUE_REACHES: ReadonlyArray<AutoQueueReach> = [
"channel",
@@ -81,90 +78,7 @@ export function sanitizeAutoQueueOrder(value: unknown): AutoQueueOrder {
return value === "newest" || value === "oldest" ? value : "listed";
}
-export type AutoQueueMatch = {
- type: AutoQueueMatchType;
- // Channel slug (type=channel) or platform name (type=platform). Ignored for
- // type=all. A type=channel leaf with no value matches nothing.
- value?: string;
- // Optional snapshot bucket this leaf draws from, narrowing the default for the
- // runner kind (transcription → downloadedNoTranscript, download →
- // undownloadedIds). E.g. bucket="failedListed" prioritizes retries.
- bucket?: string;
- // Optional OPERATION this leaf draws from — a registered backfill kind id, or
- // "digest". Same meaning as `bucket` one level up: it narrows what the leaf
- // claims, and it draws from ChannelWork.operations rather than
- // ChannelWork.buckets.
- //
- // It lives on the MATCH, beside `bucket`, and not on the node. A field on the
- // node would need group inheritance — "this group is the digest subtree" —
- // and inheritance is resolution logic buildPendingByLeaf does not have. Here
- // it needs exactly one sanitizer and exactly one claiming path.
- //
- // It is a SEPARATE id space from `bucket`, and the sanitizer enforces that a
- // leaf names at most one of the two (operation wins): `defaultBuckets` is a
- // priority-ordered union, so a name that meant a bucket to one leaf and an
- // operation to another would silently mix two id spaces, and
- // selectableBucketsForKind feeds the editor's bucket dropdown, where an
- // operation must not appear as a bucket.
- operation?: string;
-};
-
-export type AutoQueueLeaf = {
- id: string;
- match: AutoQueueMatch;
- // Relative share under a weighted-fair parent. Default 1. Ignored otherwise.
- weight?: number;
- // Optional ceiling on concurrent in-flight workers drawn from this leaf.
- maxWorkers?: number | null;
-};
-
-export type AutoQueueGroup = {
- id: string;
- mode: AutoQueueMode;
- children: AutoQueueNode[];
- weight?: number;
- maxWorkers?: number | null;
-};
-
-export type AutoQueueNode = AutoQueueLeaf | AutoQueueGroup;
-export function isGroup(node: AutoQueueNode): node is AutoQueueGroup {
- return Array.isArray((node as AutoQueueGroup).children);
-}
-
-// --- Settings (persisted in settings.json under `autoQueue`) ----------------
-
-export type AutoQueuePolicy = {
- // Master switch for this runner (transcription / download independently).
- enabled: boolean;
- // Overall ceiling on concurrent in-flight workers for this runner. null = no
- // runner-level cap (the worker pool / platform queues are the real throttle).
- maxWorkers: number | null;
- // Opt in to the lowest-priority "replace YouTube auto-captions" lane: append
- // this kind's opt-in buckets (autoSubsOnly / downloadedAutoSubsOnly) to the
- // tail of the default union, so videos whose only transcript is YouTube ASR
- // get re-done with our own engine whenever nothing more important is pending.
- // Default false — the corpus-wide cost is large (an audio download plus a
- // transcription per video). A leaf can also target the bucket by name for
- // per-channel opt-in without flipping this switch. Optional: settings written
- // before this field existed lack it; the sanitizer defaults it to false.
- replaceAutoSubs?: boolean;
- // Ordering within each rule (see AutoQueueOrder). Optional exactly like
- // replaceAutoSubs: settings files written before this field existed lack it,
- // and the sanitizer defaults them to "listed" (today's behaviour).
- order?: AutoQueueOrder;
- // Epoch ms until which this runner idles WITHOUT stopping: next() returns null
- // so the loop stays up, re-reads settings each iteration, and resumes by
- // itself when the moment passes. null/absent/past = not snoozed. Survives a
- // restart because it lives in settings.json, not in runner memory.
- snoozeUntil?: number | null;
- root: AutoQueueGroup;
-};
-
-export type AutoQueueSettings = {
- transcription: AutoQueuePolicy;
- download: AutoQueuePolicy;
-};
// 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
diff --git a/common/jobs/autoQueueState.ts b/common/jobs/autoQueueState.ts
@@ -20,7 +20,10 @@ import {
// I/O + types — the selection logic lives in autoQueuePolicy.ts and the
// orchestration in autoRunner.ts. Mirrors syncSchedulerState.ts.
-export type AutoQueueKind = "transcription" | "download";
+// Defined in the model layer (lib/ may not import jobs/); re-exported here so
+// every existing `from "./autoQueueState"` import still resolves.
+export type { AutoQueueKind } from "../lib/autoQueueTypes";
+import type { AutoQueueKind } from "../lib/autoQueueTypes";
// One recorded grant, newest-first, for the status panel.
export type AutoQueuePick = {
diff --git a/common/lib/autoQueueTypes.ts b/common/lib/autoQueueTypes.ts
@@ -0,0 +1,138 @@
+// The auto-queue's PERSISTED SHAPE: the rule tree as it is written to
+// settings.json, and nothing that acts on it.
+//
+// These types live in lib/ (the model layer) rather than beside the engine in
+// jobs/autoQueuePolicy.ts because lib/ may not import jobs/ — settings.ts,
+// pauseGates.ts, operations.ts and sweepPlan.ts all need to NAME an auto-queue
+// policy, and only the runner needs to run one. The engine re-exports every
+// name below, so no import site had to change.
+//
+// Enforced by ../architecture.test.ts.
+
+// --- Tree types -------------------------------------------------------------
+
+export type AutoQueueMode = "strict" | "round-robin" | "weighted-fair";
+
+export type AutoQueueMatchType = "channel" | "platform" | "all";
+
+// How videos are ordered WITHIN a rule, across every channel and bucket that
+// rule claims. "listed" is the historical behaviour: whatever order
+// buildPendingByLeaf accumulated, which is bucket order over channel order over
+// each bucket array's own order (channelSnapshot sorts most buckets
+// lexicographically by video id, and leaves undownloadedIds in playlist order).
+// "newest"/"oldest" sort each leaf's claimed list by an upload-date key supplied
+// by the caller as a comparator — the engine stays pure and never reads disk.
+//
+// This deliberately does NOT reorder RULES: the tree is what expresses
+// priority. A newest-first archive is one catch-all rule with order "newest".
+export type AutoQueueOrder = "listed" | "newest" | "oldest";
+
+// How far an order REACHES.
+//
+// "channel" — sort each channel's own candidates (today, and the default). A
+// corpus-wide sweep still visits channels heaviest-first, so a video
+// uploaded this morning waits for its channel's turn.
+// "corpus" — additionally order the CHANNELS by their freshest (or oldest)
+// pending video, so the channel holding the newest work goes first.
+//
+// It is a separate axis from AutoQueueOrder rather than two more enum members
+// because it is meaningless without one: reach only says how widely an order
+// applies, and "listed" has no order to apply. The console disables the control
+// while "listed" is selected for exactly that reason.
+//
+// This deliberately does NOT interleave individual videos across channels —
+// that would break one-job-per-channel, and 77,000 single-video jobs would evict
+// the registry's 100 records.
+export type AutoQueueReach = "channel" | "corpus";
+
+export type AutoQueueMatch = {
+ type: AutoQueueMatchType;
+ // Channel slug (type=channel) or platform name (type=platform). Ignored for
+ // type=all. A type=channel leaf with no value matches nothing.
+ value?: string;
+ // Optional snapshot bucket this leaf draws from, narrowing the default for the
+ // runner kind (transcription → downloadedNoTranscript, download →
+ // undownloadedIds). E.g. bucket="failedListed" prioritizes retries.
+ bucket?: string;
+ // Optional OPERATION this leaf draws from — a registered backfill kind id, or
+ // "digest". Same meaning as `bucket` one level up: it narrows what the leaf
+ // claims, and it draws from ChannelWork.operations rather than
+ // ChannelWork.buckets.
+ //
+ // It lives on the MATCH, beside `bucket`, and not on the node. A field on the
+ // node would need group inheritance — "this group is the digest subtree" —
+ // and inheritance is resolution logic buildPendingByLeaf does not have. Here
+ // it needs exactly one sanitizer and exactly one claiming path.
+ //
+ // It is a SEPARATE id space from `bucket`, and the sanitizer enforces that a
+ // leaf names at most one of the two (operation wins): `defaultBuckets` is a
+ // priority-ordered union, so a name that meant a bucket to one leaf and an
+ // operation to another would silently mix two id spaces, and
+ // selectableBucketsForKind feeds the editor's bucket dropdown, where an
+ // operation must not appear as a bucket.
+ operation?: string;
+};
+
+export type AutoQueueLeaf = {
+ id: string;
+ match: AutoQueueMatch;
+ // Relative share under a weighted-fair parent. Default 1. Ignored otherwise.
+ weight?: number;
+ // Optional ceiling on concurrent in-flight workers drawn from this leaf.
+ maxWorkers?: number | null;
+};
+
+export type AutoQueueGroup = {
+ id: string;
+ mode: AutoQueueMode;
+ children: AutoQueueNode[];
+ weight?: number;
+ maxWorkers?: number | null;
+};
+
+export type AutoQueueNode = AutoQueueLeaf | AutoQueueGroup;
+
+export function isGroup(node: AutoQueueNode): node is AutoQueueGroup {
+ return Array.isArray((node as AutoQueueGroup).children);
+}
+
+// --- Settings (persisted in settings.json under `autoQueue`) ----------------
+
+export type AutoQueuePolicy = {
+ // Master switch for this runner (transcription / download independently).
+ enabled: boolean;
+ // Overall ceiling on concurrent in-flight workers for this runner. null = no
+ // runner-level cap (the worker pool / platform queues are the real throttle).
+ maxWorkers: number | null;
+ // Opt in to the lowest-priority "replace YouTube auto-captions" lane: append
+ // this kind's opt-in buckets (autoSubsOnly / downloadedAutoSubsOnly) to the
+ // tail of the default union, so videos whose only transcript is YouTube ASR
+ // get re-done with our own engine whenever nothing more important is pending.
+ // Default false — the corpus-wide cost is large (an audio download plus a
+ // transcription per video). A leaf can also target the bucket by name for
+ // per-channel opt-in without flipping this switch. Optional: settings written
+ // before this field existed lack it; the sanitizer defaults it to false.
+ replaceAutoSubs?: boolean;
+ // Ordering within each rule (see AutoQueueOrder). Optional exactly like
+ // replaceAutoSubs: settings files written before this field existed lack it,
+ // and the sanitizer defaults them to "listed" (today's behaviour).
+ order?: AutoQueueOrder;
+ // Epoch ms until which this runner idles WITHOUT stopping: next() returns null
+ // so the loop stays up, re-reads settings each iteration, and resumes by
+ // itself when the moment passes. null/absent/past = not snoozed. Survives a
+ // restart because it lives in settings.json, not in runner memory.
+ snoozeUntil?: number | null;
+ root: AutoQueueGroup;
+};
+
+export type AutoQueueSettings = {
+ transcription: AutoQueuePolicy;
+ download: AutoQueuePolicy;
+};
+
+// --- Runner kinds -----------------------------------------------------------
+
+// The two auto-queue runners. Here rather than in jobs/autoQueueState.ts (which
+// persists their fairness memory) for the same reason as the tree above:
+// pauseGates.ts and operations.ts name a kind without running one.
+export type AutoQueueKind = "transcription" | "download";
diff --git a/common/lib/operations.ts b/common/lib/operations.ts
@@ -85,10 +85,10 @@ import {
DIGEST_REMOTE_QUEUE,
TRANSCRIPTION_QUEUE,
} from "./queueKeys";
-// TYPE-ONLY, and that is what keeps this safe: the import is erased at compile
-// time, so naming the runner union here cannot create a cycle no matter what
-// jobs/autoQueueState.ts imports.
-import type { AutoQueueKind } from "../jobs/autoQueueState";
+// The runner union, from the model layer. It used to come from
+// jobs/autoQueueState.ts as a type-only import; it now lives in lib/ outright,
+// which is both cycle-free and legal under the layering guard.
+import type { AutoQueueKind } from "./autoQueueTypes";
import {
DIGEST_FILENAME,
digestSectionStates,
diff --git a/common/lib/pauseGates.ts b/common/lib/pauseGates.ts
@@ -1,5 +1,5 @@
import type { SiteSettings } from "./settings";
-import type { AutoQueueKind } from "../jobs/autoQueueState";
+import type { AutoQueueKind } from "./autoQueueTypes";
import { operationCatalog } from "./operations";
import {
BACKFILL_QUEUE,
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -19,10 +19,16 @@ import {
sanitizeWorkers,
validateWorkers,
} from "./workers";
+import type {
+ AutoQueueOrder,
+ AutoQueueReach,
+ AutoQueueSettings,
+} from "./autoQueueTypes";
+// 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.
import {
- type AutoQueueOrder,
- type AutoQueueReach,
- type AutoQueueSettings,
defaultAutoQueue,
sanitizeAutoQueue,
sanitizeAutoQueueOrder,
@@ -58,7 +64,7 @@ import {
} from "./digest";
export type { Worker } from "./workers";
-export type { AutoQueueSettings } from "../jobs/autoQueuePolicy";
+export type { AutoQueueSettings } from "./autoQueueTypes";
// Transcribe placeholder/arg helpers now live with the whisper-cpp app in
// transcriptionApps.ts. Re-exported here so existing import sites keep working.
diff --git a/common/lib/sweepPlan.ts b/common/lib/sweepPlan.ts
@@ -26,7 +26,7 @@
// is being built to show.
import { orderPlanByRecency } from "../controller/planOrder";
-import type { AutoQueueOrder } from "../jobs/autoQueuePolicy";
+import type { AutoQueueOrder } from "./autoQueueTypes";
// One operation's outstanding work on one channel.
//
diff --git a/common/package.json b/common/package.json
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
- "test": "tsx --test \"{lib,controller,jobs,social,ytdlp,components}/*.test.ts\""
+ "test": "tsx --test \"*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components}/*.test.ts\""
},
"dependencies": {
"@sindresorhus/slugify": "^3.0.0",