commit b0cca40e63435b4b7447dc1a7c7c9f2ff2b913ed
parent 89a009d84143e0357808f5d6a46dad9987f4df6d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 14 Sep 2026 16:49:03 -0400
views: the layer exists, and the guard can see it
One-core phase 3 slice 1, sub-slice A1. `common/views/` is the view-model
layer: pure functions that fold live state into a payload, taking their
singletons as ARGUMENTS. This commit makes the directory real, teaches the
layer guard about it, and burns the one allow-list entry that was waiting for
it.
Registration is explicit twice, so both are here: an exports entry
(`"./views/*"`, a string — an array would be a subpath-pattern condition map,
not a target) and `views` added to both brace lists of the test glob.
`ROOTS` in the layer guard is the third: a directory it does not name is a
directory it does not walk, which is why both new tests guard the guard by
asserting the views scan saw a file at all.
Two rules the existing back-edge scan cannot express, because it only reads
RELATIVE specifiers:
- `BARE_FORBIDDEN` / `bareEdges()` — react, next, server-only, client-only,
`node:*` and THIS PACKAGE'S OWN NAME. The last one is the load-bearing
entry: a file moved into views/ that still imports
`yt-dlp-transcript-common/controller/channels` resolves fine, compiles
fine, and is invisible to the layer guard, so a forgotten rewrite would
silently exempt itself. `*.test.ts` is skipped (tests import `node:test`);
the fix for a test is never to loosen the regex.
- a textual ban on the sixteen names that construct a lazy singleton, read
disk, or read the clock — the noCorpusWalkInRenderPaths trick, for the same
reason: the property is about what a call graph reaches, which no import
list shows. Context-blind by design, so a comment that mentions
`getRegistry` with its parenthesis fails too; reword the comment.
`views/inputs.ts` is the port those rules leave room for: `LiveInputs` for a
server render, `ObserveInputs` for the pulse poll, `RegistryReader` and
`PoolReader` narrowed to the reads a view may make. `now` is a function so a
test can freeze it. Types only.
`views/streamAction.ts` burns the allow-list entry (11 → 10).
`StreamActionLog` named `StreamActionResult`, whose `done:
Promise<JobDoneResult>` drags the registry's `JobStatus` into a component that
reads ok/error/info/jobId/stream and stops. `StreamActionView` is those four
fields with zero imports, so the edge disappears rather than moves;
`views/streamAction.test.ts` asserts at compile time that the richer dispatch
type stays assignable to it. The stale-entry half of the guard is what forces
this into the same commit as the registration.
`editor/app/lib/liveInputs.ts` is the one constructor: the only editor file
that calls all five getters for a view, plus `observeInputs()` reading the
globals directly for the poll that must never construct. Per-view extras are
assembled in each noun's shell, so B and C never edit this file.
The pulse route's `declare global` block moves out — and does not need to be
restated, because `common/jobs/{registry,scheduler,workerPool}.ts` already
declare those vars themselves (registry.ts:401, scheduler.ts:199,
workerPool.ts:687); the route's copy was a duplicate. Its three getter imports
existed only to spell `ReturnType<typeof …>` inside that block and go with it.
`noCorpusWalkInRenderPaths` now walks `common/views` as well as `editor/app`:
views/ is a render path that happens to live in this package, and walking only
the editor would let a builder dodge the ban by moving down a layer. The
converse test stays editor-only on purpose — those are readers, and a view is
forbidden to call one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
9 files changed, 378 insertions(+), 34 deletions(-)
diff --git a/common/architecture.test.ts b/common/architecture.test.ts
@@ -35,14 +35,29 @@ const FORBIDDEN: Record<string, readonly string[]> = {
// searchQuery for a mode union. All four inverted — the pipeline and the two
// types moved down, and the fetch and the memo are now injected — so the
// guard turns on with nothing added to the allow-list to pay for it.
- lib: ["controller", "jobs", "components"],
- jobs: ["controller"],
+ lib: ["controller", "jobs", "components", "views"],
+ jobs: ["controller", "views"],
+ controller: ["views"],
components: ["controller", "jobs", "ytdlp"],
+ // `views/` is the view-model layer (one-core phase 3 slice 1): pure functions
+ // that fold live state into a payload. It sits ABOVE dispatch and BELOW ui,
+ // so it may read lib/, controller/ and jobs/ for pure helpers and types, and
+ // may not reach up into components/ or sideways into the entry points.
+ views: ["components", "ytdlp", "bin", "social"],
};
// 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"];
+const ROOTS = [
+ "lib",
+ "controller",
+ "jobs",
+ "components",
+ "social",
+ "ytdlp",
+ "bin",
+ "views",
+];
// Today's back-edges, `<file> -> <imported module>`, each with why it is still
// here. THIS LIST IS A DEBT LEDGER, not a policy.
@@ -83,12 +98,6 @@ const ALLOWED: Record<string, string> = {
"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"`.
@@ -162,10 +171,11 @@ test("no new back-edges between common's layers", async () => {
assert.deepEqual(
unexpected,
[],
- `NEW back-edge(s) in common/. lib/ may not import controller/, jobs/ or ` +
- `components/; ` +
- `jobs/ may not import controller/; components/ may not import ` +
- `controller/, jobs/ or ytdlp/. Move the type or the function down a ` +
+ `NEW back-edge(s) in common/. lib/ may not import controller/, jobs/, ` +
+ `components/ or views/; ` +
+ `jobs/ and controller/ may not import views/; components/ may not ` +
+ `import controller/, jobs/ or ytdlp/; views/ may not import ` +
+ `components/. Move the type or the function down a ` +
`layer instead of adding it to ALLOWED. Found: ${unexpected.join(", ")}`,
);
});
@@ -180,3 +190,130 @@ test("the back-edge allow-list has no stale entries", async () => {
`keeps meaning what it says: ${stale.join(", ")}`,
);
});
+
+// ── THE VIEW LAYER'S TWO EXTRA RULES ────────────────────────────────────────
+//
+// The back-edge scan above only looks at RELATIVE specifiers (`:137`), because
+// only a relative specifier can cross a layer inside this package. That is
+// exactly the wrong shape for `views/`, whose whole promise is negative: a
+// view-model is a pure function of its arguments, so what matters is what it
+// CANNOT reach — the framework, the filesystem, the singletons and the clock.
+// Two cheap checks buy that promise.
+
+// 1. No bare specifier a pure view has no business naming.
+//
+// react/next/server-only/client-only would make a view a component; `node:*`
+// would make it a reader and would drag `node:fs` into a `"use client"` graph
+// the moment a client component imported it. The PACKAGE'S OWN NAME is on the
+// list too, and that one is not paranoia: a file moved here that still says
+// `yt-dlp-transcript-common/controller/channels` resolves fine, compiles fine,
+// and is invisible to the relative-specifier scan above — so a forgotten
+// rewrite would silently exempt itself from the layer guard. Here it fails
+// loudly instead.
+const BARE_FORBIDDEN: Record<string, RegExp> = {
+ views:
+ /^(react|react-dom|next|server-only|client-only)(\/|$)|^node:|^yt-dlp-transcript-common(\/|$)/,
+};
+
+async function bareEdges(): Promise<{ edges: string[]; scanned: number }> {
+ const edges: string[] = [];
+ let scanned = 0;
+ for (const root of Object.keys(BARE_FORBIDDEN)) {
+ const re = BARE_FORBIDDEN[root];
+ for (const file of await walk(path.join(HERE, root))) {
+ // Tests legitimately import node:test and node:assert. Never loosen the
+ // regex to accommodate them — skip the files instead.
+ if (/\.test\.tsx?$/.test(file)) continue;
+ scanned++;
+ const rel = path.relative(HERE, file);
+ 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];
+ if (spec.startsWith(".")) continue;
+ if (!re.test(spec)) continue;
+ const edge = `${rel} -> ${spec}`;
+ if (!edges.includes(edge)) edges.push(edge);
+ }
+ }
+ }
+ return { edges: edges.sort(), scanned };
+}
+
+test("views/ imports nothing from react, next, node: or the package's own name", async () => {
+ const { edges, scanned } = await bareEdges();
+ // Guard the guard: ROOTS and BARE_FORBIDDEN are both explicit lists, so a
+ // directory that was never added scans zero files and passes vacuously.
+ assert.ok(
+ scanned > 0,
+ "the views/ scan found no files — is common/views/ still there, and is it in ROOTS?",
+ );
+ assert.deepEqual(
+ edges,
+ [],
+ `a view-model may not import the framework, the runtime or this package by ` +
+ `name (imports inside common/ are relative — a package-name import hides ` +
+ `from the layer guard). Found: ${edges.join(", ")}`,
+ );
+});
+
+// 2. No singleton getter, no disk reader, no clock.
+//
+// This is the noCorpusWalkInRenderPaths trick, and for the same reason: the
+// property is about what a call GRAPH reaches, which no import list shows.
+// Every name below either CONSTRUCTS a lazy singleton, reads the corpus, or
+// makes the function non-deterministic. All of them are injected instead —
+// `views/inputs.ts` is the shape they arrive in, `Date.now` as `now()`.
+//
+// THE MATCH IS TEXTUAL AND CONTEXT-BLIND, so a comment that merely mentions one
+// of these with its parenthesis fails too. That is deliberate: a guard that
+// skipped comments is a guard a real call can hide behind, and the cost of the
+// false positive is one reworded comment.
+const VIEW_BANNED = [
+ "getRegistry(",
+ "getScheduler(",
+ "getWorkerPool(",
+ "getPaths(",
+ "getSettings(",
+ "getAutoRunnerStatus(",
+ "readChannelStat(",
+ "readJobMeta(",
+ "listChannelBriefs(",
+ "listChannelConfigs(",
+ "readSchedulerState(",
+ "readAutoQueueState(",
+ "computeLeafPending(",
+ "readWorkerDefaults(",
+ "diskGate(",
+ "Date.now(",
+];
+
+test("views/ never calls a singleton getter, a disk reader or the clock", async () => {
+ const files = (await walk(path.join(HERE, "views"))).filter(
+ (f) => !/\.test\.tsx?$/.test(f),
+ );
+ assert.ok(
+ files.length > 0,
+ "the views/ scan found no files — is common/views/ still there?",
+ );
+
+ const offenders: string[] = [];
+ for (const file of files) {
+ const source = await readFile(file, "utf8");
+ for (const banned of VIEW_BANNED) {
+ if (source.includes(banned)) {
+ offenders.push(`${banned} in ${path.relative(HERE, file)}`);
+ }
+ }
+ }
+
+ assert.deepEqual(
+ offenders.sort(),
+ [],
+ `a view-model takes its live state as an argument. These construct a ` +
+ `singleton, read disk or read the clock — pass the value in through ` +
+ `views/inputs.ts instead, and have the editor's shell do the reading. ` +
+ `Found: ${offenders.join(", ")}`,
+ );
+});
diff --git a/common/components/StreamActionLog.tsx b/common/components/StreamActionLog.tsx
@@ -2,11 +2,11 @@
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
-import type { StreamActionResult } from "../jobs/streamCommand";
+import type { StreamActionView } from "../views/streamAction";
import { Button } from "./ui/button";
type Props = {
- trigger: () => Promise<StreamActionResult>;
+ trigger: () => Promise<StreamActionView>;
buttonLabel: string;
runningLabel?: string;
/**
diff --git a/common/controller/noCorpusWalkInRenderPaths.test.ts b/common/controller/noCorpusWalkInRenderPaths.test.ts
@@ -25,7 +25,14 @@ import { fileURLToPath } from "node:url";
// same ChannelStat shape, projected from snapshots) — all in ./channels.
const HERE = path.dirname(fileURLToPath(import.meta.url));
-const EDITOR_APP = path.resolve(HERE, "..", "..", "editor", "app");
+const REPO = path.resolve(HERE, "..", "..");
+const EDITOR_APP = path.resolve(REPO, "editor", "app");
+// The view layer renders too. `common/views/` holds the payload builders the
+// editor's pages fold their live state through (one-core phase 3 slice 1) —
+// which is to say it is a render path that happens to live in this package, so
+// the same ban applies to it. Walking only `editor/app` would let a builder
+// dodge this guard simply by moving down a layer.
+const VIEWS = path.resolve(REPO, "common", "views");
// EVERY identifier that reaches the corpus walk, not just the walk itself.
//
@@ -62,13 +69,20 @@ async function walk(dir: string): Promise<string[]> {
}
test("the corpus walk never reaches a render path", async () => {
- const files = await walk(EDITOR_APP);
- // Guard the guard: if the traversal finds nothing, the assertion below would
- // pass while checking zero files.
+ const editorFiles = await walk(EDITOR_APP);
+ const viewFiles = await walk(VIEWS);
+ // Guard the guard: if either traversal finds nothing, the assertion below
+ // would pass while checking zero files. The roots are explicit, so a
+ // directory nobody added is a directory nobody scans.
+ assert.ok(
+ editorFiles.length > 100,
+ `expected to scan the editor app tree, found ${editorFiles.length} files under ${EDITOR_APP}`,
+ );
assert.ok(
- files.length > 100,
- `expected to scan the editor app tree, found ${files.length} files under ${EDITOR_APP}`,
+ viewFiles.length > 0,
+ `expected to scan the view layer, found ${viewFiles.length} files under ${VIEWS}`,
);
+ const files = [...editorFiles, ...viewFiles];
const offenders: string[] = [];
await Promise.all(
@@ -76,7 +90,7 @@ test("the corpus walk never reaches a render path", async () => {
const source = await readFile(file, "utf8");
for (const banned of BANNED) {
if (source.includes(banned)) {
- offenders.push(`${banned} in ${path.relative(EDITOR_APP, file)}`);
+ offenders.push(`${banned} in ${path.relative(REPO, file)}`);
}
}
}),
@@ -94,6 +108,10 @@ test("the cheap channel readers are the ones the editor actually uses", async ()
// The converse check: if someone "fixes" the test above by inlining a readdir
// loop instead, the named readers would quietly stop being used. This asserts
// the intended replacements are still wired in.
+ //
+ // Editor-only on purpose: these are READERS, and a view-model is forbidden to
+ // call one (common/architecture.test.ts). They are called by the shells,
+ // which stay in editor/app.
const files = await walk(EDITOR_APP);
const sources = await Promise.all(files.map((f) => readFile(f, "utf8")));
const joined = sources.join("\n");
diff --git a/common/package.json b/common/package.json
@@ -34,6 +34,7 @@
"./lib/*": "./lib/*.ts",
"./controller/*": "./controller/*.ts",
"./jobs/*": "./jobs/*.ts",
+ "./views/*": "./views/*.ts",
"./social/*": "./social/*.ts",
"./ytdlp/*": "./ytdlp/*.ts",
"./bin/*": "./bin/*.ts",
@@ -41,7 +42,7 @@
"./styles/*": "./styles/*.ts"
},
"scripts": {
- "test": "tsx --test \"*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components}/*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components}/*/*.test.ts\""
+ "test": "tsx --test \"*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components,views}/*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components,views}/*/*.test.ts\""
},
"dependencies": {
"@sindresorhus/slugify": "^3.0.0",
diff --git a/common/views/inputs.ts b/common/views/inputs.ts
@@ -0,0 +1,65 @@
+// THE PORT. Every view-model in this directory takes its live state as an
+// ARGUMENT, and this file is the shape of that argument.
+//
+// Why it exists (one-core phase 3 slice 1): the editor's payload builders used
+// to reach for the registry, scheduler and worker-pool getters themselves.
+// Those are LAZY SINGLETONS — calling one CREATES it — so a
+// builder that constructs is a builder that cannot be called from a status
+// poll, cannot be unit-tested without a real pool, and quietly re-seeds state
+// that `/api/test/invalidate-cache` had just cleared between e2e specs. See
+// `editor/app/api/pulse/route.ts` for the incident that rule is written from.
+//
+// Injecting the readers makes the rule structural rather than remembered: a
+// view here CANNOT construct, because it never imports a getter. The editor
+// has exactly one place allowed to call all five — `editor/app/lib/liveInputs.ts`
+// — and each noun's shell adds its own extras on top of these.
+//
+// TYPES ONLY. This file must never grow a value export.
+
+import type { Paths } from "../lib/paths";
+import type { SiteSettings } from "../lib/settings";
+import type { Scheduler } from "../jobs/scheduler";
+import type { WorkerPool } from "../jobs/workerPool";
+import type { getRegistry } from "../jobs/registry";
+
+// `JobRegistry` is not exported as a class, so name it through its getter the
+// way the pulse route already does. Narrowed to the two reads a view is allowed
+// to make: listing jobs and fetching one. Nothing here may register, cancel or
+// mutate a job.
+export type RegistryReader = Pick<ReturnType<typeof getRegistry>, "list" | "get">;
+
+// The pool's read half. `summary()` is the per-worker snapshot, `isPaused()`
+// the transcription gate, `canStopPartial(id)` whether a running worker can be
+// drained rather than killed.
+export type PoolReader = Pick<
+ WorkerPool,
+ "summary" | "isPaused" | "canStopPartial"
+>;
+
+// What a server-rendered view gets. `now` is a function and not a number so a
+// test can freeze the clock; `Date.now` is banned inside views by the layer
+// guard for exactly that reason.
+export type LiveInputs = {
+ paths: Paths;
+ settings: SiteSettings;
+ registry: RegistryReader;
+ scheduler: Scheduler;
+ pool: PoolReader;
+ now: () => number;
+};
+
+// What the OBSERVER gets — the pulse poll. Every singleton is nullable because
+// the observer reads the globals directly and "not created yet" is a legitimate
+// answer meaning "nothing to report": a pulse must be able to say the system is
+// idle without making a system to ask. The two mtimes and the generation
+// counter arrive as plain numbers so nothing here stats a file, and `digest`
+// is injected so nothing here imports `node:crypto`.
+export type ObserveInputs = {
+ registry: RegistryReader | null;
+ scheduler: Pick<Scheduler, "queues"> | null;
+ pool: PoolReader | null;
+ snapshotGeneration: number;
+ settingsMtime: number;
+ changelogMtime: number;
+ digest: (s: string) => string;
+};
diff --git a/common/views/streamAction.test.ts b/common/views/streamAction.test.ts
@@ -0,0 +1,22 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import type { StreamActionResult } from "../jobs/streamCommand";
+import type { StreamActionView } from "./streamAction";
+
+// THE ASSIGNABILITY, ASSERTED AT COMPILE TIME.
+//
+// `StreamActionLog` takes a `() => Promise<StreamActionView>` while every
+// caller hands it a server action returning `StreamActionResult`. That only
+// keeps working while the richer dispatch-layer type stays assignable to the
+// narrower view. If someone renames a field on either side, `tsc --noEmit`
+// fails HERE, naming both types, instead of failing at the twenty-odd call
+// sites with a wall of structural mismatch.
+//
+// tsc sees this file; the runtime assertion below only exists so the file is a
+// test rather than a lint the suite would skip.
+type AssignableTo<A, B> = A extends B ? true : never;
+const _resultIsAView: AssignableTo<StreamActionResult, StreamActionView> = true;
+
+test("StreamActionResult is assignable to StreamActionView", () => {
+ assert.equal(_resultIsAView, true);
+});
diff --git a/common/views/streamAction.ts b/common/views/streamAction.ts
@@ -0,0 +1,28 @@
+// What a streaming action looks like TO THE UI, and nothing more.
+//
+// `StreamActionLog` used to name `StreamActionResult` from
+// `jobs/streamCommand` — the one component→jobs back-edge the layer guard
+// carried on its allow-list. The type is not the problem; its `done` field is,
+// because `Promise<JobDoneResult>` drags the job registry's `JobStatus` into
+// the browser's type graph for a component that never reads it (see
+// `StreamActionLog.tsx:140-147` — it reads ok/error/info/jobId/stream and
+// stops).
+//
+// So the view keeps the four fields it renders. `StreamActionResult` stays
+// where the dispatch layer needs it and remains ASSIGNABLE to this — the
+// server hands back the richer object, the component sees the narrower one.
+// `views/streamAction.test.ts` asserts that at compile time.
+//
+// ZERO IMPORTS, deliberately: this is what makes the edge disappear rather
+// than move.
+
+export type StreamActionView =
+ | {
+ ok: true;
+ jobId: string;
+ stream: ReadableStream<string>;
+ }
+ // `info: true` marks a non-error outcome that started no job (e.g. a
+ // re-derived bucket that's currently empty) so the UI can show it neutrally
+ // rather than as a red failure.
+ | { ok: false; error: string; info?: boolean };
diff --git a/editor/app/api/pulse/route.ts b/editor/app/api/pulse/route.ts
@@ -2,9 +2,6 @@ import { createHash } from "node:crypto";
import { statSync } from "node:fs";
import { NextResponse } from "next/server";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
-import { getScheduler } from "yt-dlp-transcript-common/jobs/scheduler";
-import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool";
import { cleanableTotalBytes } from "../../cleanup/lib/loadCleanup";
export const dynamic = "force-dynamic";
@@ -51,14 +48,11 @@ export type PulsePayload = {
// Everything here is either in-memory or a stat(). NO readdir, no corpus
// contact, no large JSON parse — asserted by e2e/pulse.spec.ts, because the
// entire point of this endpoint is that it is cheap enough to poll forever.
-declare global {
- // eslint-disable-next-line no-var
- var __yttJobRegistry__: ReturnType<typeof getRegistry> | undefined;
- // eslint-disable-next-line no-var
- var __yttWorkerPool__: ReturnType<typeof getWorkerPool> | undefined;
- // eslint-disable-next-line no-var
- var __yttScheduler__: ReturnType<typeof getScheduler> | undefined;
-}
+// (The three globals read below are declared by the modules that own them —
+// common/jobs/{registry,scheduler,workerPool}.ts — so this file no longer
+// restates them. `editor/app/lib/liveInputs.ts` is where the observer's reads
+// are being gathered; phase 3 slice 1 C4 moves the body of computeRev() into
+// common/views/pulse.ts and leaves this route as GET plus the idle fast path.)
function computeRev(): { rev: string; activeJobs: number; runningJobs: number; busy: boolean } {
const paths = getPaths();
diff --git a/editor/app/lib/liveInputs.ts b/editor/app/lib/liveInputs.ts
@@ -0,0 +1,79 @@
+import { createHash } from "node:crypto";
+import { statSync } from "node:fs";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import { getScheduler } from "yt-dlp-transcript-common/jobs/scheduler";
+import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool";
+import type {
+ LiveInputs,
+ ObserveInputs,
+} from "yt-dlp-transcript-common/views/inputs";
+
+// THE ONE CONSTRUCTOR.
+//
+// Every view-model lives in `common/views/` and takes its live state as an
+// argument (`views/inputs.ts` is the shape). Something has to actually fetch
+// that state, and this is the only file in the editor allowed to fetch all of
+// it at once. Concentrating it here is the point: the getters below are LAZY
+// SINGLETONS — calling one CREATES it — so "who may construct?" is a question
+// worth being able to answer by reading one file.
+//
+// Per-view extras (a disk gate, a log tail, a channel-brief list) are assembled
+// in each noun's own shell next to the page that needs them, never here.
+
+export function liveInputs(): LiveInputs {
+ return {
+ paths: getPaths(),
+ settings: getSettings(),
+ registry: getRegistry(),
+ scheduler: getScheduler(),
+ pool: getWorkerPool(),
+ // A function, not a timestamp: a view that folds "is this cooldown over?"
+ // should read the clock when it looks, and a test should be able to freeze
+ // it. `Date.now` is banned inside views/ so this is the only way in.
+ now: Date.now,
+ };
+}
+
+// THE OBSERVER'S HALF, AND WHY IT IS DIFFERENT.
+//
+// `/api/pulse` runs every few seconds on every open tab and must never CREATE
+// anything: `/api/test/invalidate-cache` clears exactly these globals between
+// e2e specs, and a poll landing a moment later used to rebuild them — re-seeding
+// a worker pool from settings mid-reset, which took out ~16 unrelated specs in
+// non-deterministic combinations. So read the globals directly and treat "not
+// created yet" as "nothing to report": a pulse must be able to say the system
+// is idle without making a system to ask.
+//
+// (The globals are declared by the modules that own them —
+// `common/jobs/{registry,scheduler,workerPool,snapshotScheduler}.ts`.)
+//
+// Everything gathered here is either in-memory or a stat(). No readdir, no
+// corpus contact, no large JSON parse — asserted by e2e/pulse.spec.ts.
+export function observeInputs(): ObserveInputs {
+ const paths = getPaths();
+ return {
+ registry: globalThis.__yttJobRegistry__ ?? null,
+ scheduler: globalThis.__yttScheduler__ ?? null,
+ pool: globalThis.__yttWorkerPool__ ?? null,
+ // One integer meaning "a report was rewritten", so the pages whose counts
+ // come from snapshots notice without this endpoint stat-ing 65 files. The
+ // scheduler's state type is not exported, so read the number off it here
+ // rather than passing the object down.
+ snapshotGeneration: globalThis.__yttSnapshotScheduler__?.generation ?? 0,
+ // Files the layout renders from. mtime only — neither is read.
+ settingsMtime: mtime(paths.settingsFile),
+ changelogMtime: mtime(paths.editorChangelogFile),
+ // Injected so the view never imports node:crypto.
+ digest: (s: string) => createHash("sha1").update(s).digest("base64url"),
+ };
+}
+
+function mtime(file: string): number {
+ try {
+ return statSync(file).mtimeMs;
+ } catch {
+ return 0;
+ }
+}