Archilyzer · Source

archilyzer

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

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:
Mcommon/architecture.test.ts | 163++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcommon/components/StreamActionLog.tsx | 4++--
Mcommon/controller/noCorpusWalkInRenderPaths.test.ts | 32+++++++++++++++++++++++++-------
Mcommon/package.json | 3++-
Acommon/views/inputs.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/views/streamAction.test.ts | 22++++++++++++++++++++++
Acommon/views/streamAction.ts | 28++++++++++++++++++++++++++++
Meditor/app/api/pulse/route.ts | 16+++++-----------
Aeditor/app/lib/liveInputs.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; + } +}