Archilyzer · Source

archilyzer

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

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:
Acommon/architecture.test.ts | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/jobs/autoQueuePolicy.ts | 148+++++++++++++++++--------------------------------------------------------------
Mcommon/jobs/autoQueueState.ts | 5++++-
Acommon/lib/autoQueueTypes.ts | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/operations.ts | 8++++----
Mcommon/lib/pauseGates.ts | 2+-
Mcommon/lib/settings.ts | 14++++++++++----
Mcommon/lib/sweepPlan.ts | 2+-
Mcommon/package.json | 2+-
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",