Archilyzer · Source

archilyzer

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

commit b0a902e068f3eca8f3241abc55a95de7280f5cd6
parent dd464eb13679b9ee3a829af493003952776355aa
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 14 Sep 2026 17:08:16 -0400

views: the workers payload, out of the component that renders it

WorkersPayload / WorkerView / WorkerTask were declared inside the
"use client" WorkersView.tsx and the SERVER builder imported them back
out of it — the renderer owning the wire shape of /api/workers, so every
route and widget that reads a worker list depended on a React file to
say what a worker is. They move to common/views/workers.ts, beside
buildWorkersPayload, which is now a pure function of its arguments: the
registry's list, the pool's read half, the settings the download gate
lives in, and the persisted launch default.

readWorkerDefaults is a synchronous fs read, so it stays in the shell
and arrives as `workerDefaults`. editor/app/workers/buildWorkers.ts
keeps the old path and the old exported name and returns the same
values; WorkersView.tsx imports the three types from the view and
re-exports them so its nine importers keep compiling until slice 1D
repoints them.

Five tests the builder could not have before: every input is a literal
now, so tasks-grouped-by-workerId (running transcribe tasks only),
canStopPartial per worker, defaultEnabledIds null vs [] vs a list, and
paused vs downloadsPaused as two independent gates are all pinnable
without a pool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Acommon/views/workers.test.ts | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/views/workers.ts | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/workers/buildWorkers.ts | 70++++++++++++++++------------------------------------------------------
Meditor/app/workers/components/WorkersView.tsx | 49+++++++++++--------------------------------------
4 files changed, 330 insertions(+), 92 deletions(-)

diff --git a/common/views/workers.test.ts b/common/views/workers.test.ts @@ -0,0 +1,191 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { defaultSiteSettings, type SiteSettings } from "../lib/settings"; +import { withGateHeld } from "../lib/pauseGates"; +import type { JobRecord, JobTask } from "../jobs/registry"; +import type { WorkerSummary } from "../jobs/workerPool"; +import type { RegistryReader, PoolReader } from "./inputs"; +import { buildWorkersPayload, type WorkersInputs } from "./workers"; + +// Run with: node_modules/.bin/tsx --test common/views/workers.test.ts +// +// The point of the move is that this file exists at all: the payload used to +// need a worker pool, a job registry, a settings read and a synchronous disk +// read to say anything, so nothing pinned it. Every input below is a literal. + +function worker(over: Partial<WorkerSummary> = {}): WorkerSummary { + return { + id: "w1", + name: "Worker 1", + kind: "local", + priority: 0, + busy: false, + state: "enabled", + degraded: false, + enabled: true, + ...over, + }; +} + +function task(over: Partial<JobTask> = {}): JobTask { + return { + id: "v1", + label: "A video", + kind: "transcribe", + startedAt: 1_000, + ...over, + }; +} + +function job(over: Partial<JobRecord> = {}): JobRecord { + return { + id: "j1", + kind: "transcribe", + queueKey: "transcribe", + status: "running", + queuedAt: 0, + logPath: "/dev/null", + ...over, + }; +} + +function inputs(over: { + jobs?: JobRecord[]; + workers?: WorkerSummary[]; + paused?: boolean; + canStopPartial?: (id: string) => boolean; + settings?: SiteSettings; + workerDefaults?: WorkersInputs["workerDefaults"]; +}): WorkersInputs { + const jobs = over.jobs ?? []; + const registry: RegistryReader = { + list: () => jobs, + get: (id: string) => jobs.find((j) => j.id === id), + }; + const pool: PoolReader = { + summary: () => over.workers ?? [], + isPaused: () => over.paused ?? false, + canStopPartial: over.canStopPartial ?? (() => false), + }; + return { + registry, + pool, + settings: over.settings ?? defaultSiteSettings(), + workerDefaults: over.workerDefaults ?? null, + }; +} + +test("tasks are grouped by workerId, and only running transcribe tasks count", () => { + const payload = buildWorkersPayload( + inputs({ + workers: [worker({ id: "a" }), worker({ id: "b", name: "Worker 2" })], + jobs: [ + job({ + id: "j1", + channelSlug: "chan", + tasks: [ + task({ id: "v1", workerId: "a" }), + task({ id: "v2", workerId: "b" }), + // Same worker as the first: both land in a's list. + task({ id: "v3", workerId: "a", fraction: 0.5 }), + // Not a transcription — a download task carries no worker. + task({ id: "v4", kind: "download", workerId: "a" }), + // A transcribe task with no worker yet (queued behind a slot). + task({ id: "v5" }), + ], + }), + // A queued job's tasks are not in flight; a done job's are stale. + job({ + id: "j2", + status: "queued", + tasks: [task({ id: "v6", workerId: "b" })], + }), + job({ + id: "j3", + status: "done", + tasks: [task({ id: "v7", workerId: "b" })], + }), + ], + }), + ); + + const byId = new Map(payload.workers.map((w) => [w.id, w])); + assert.deepEqual( + byId.get("a")?.tasks.map((t) => t.id), + ["v1", "v3"], + ); + assert.deepEqual( + byId.get("b")?.tasks.map((t) => t.id), + ["v2"], + ); + // The channel comes off the JOB, not the task — that is the only reason the + // fold walks jobs rather than a flat task list. + assert.equal(byId.get("a")?.tasks[0].channelSlug, "chan"); + assert.equal(byId.get("a")?.tasks[1].fraction, 0.5); +}); + +test("a worker with nothing running gets an empty list, not undefined", () => { + const payload = buildWorkersPayload(inputs({ workers: [worker()] })); + assert.deepEqual(payload.workers[0].tasks, []); +}); + +test("canStopPartial is asked per worker", () => { + const asked: string[] = []; + const payload = buildWorkersPayload( + inputs({ + workers: [worker({ id: "a" }), worker({ id: "b" })], + canStopPartial: (id) => { + asked.push(id); + return id === "b"; + }, + }), + ); + assert.deepEqual(asked, ["a", "b"]); + assert.equal(payload.workers[0].canStopPartial, false); + assert.equal(payload.workers[1].canStopPartial, true); +}); + +test("defaultEnabledIds is null until a default has been saved", () => { + // Null and [] are different answers: null is "no default has ever been + // saved" (the button says Set as default), [] is "the saved default is + // nothing enabled". + assert.equal( + buildWorkersPayload(inputs({ workerDefaults: null })).defaultEnabledIds, + null, + ); + assert.deepEqual( + buildWorkersPayload(inputs({ workerDefaults: { enabledWorkerIds: [] } })) + .defaultEnabledIds, + [], + ); + assert.deepEqual( + buildWorkersPayload( + inputs({ workerDefaults: { enabledWorkerIds: ["a", "b"] } }), + ).defaultEnabledIds, + ["a", "b"], + ); +}); + +test("paused is the live pool; downloadsPaused is the persisted download gate", () => { + const free = buildWorkersPayload(inputs({ paused: true })); + assert.equal(free.paused, true); + assert.equal(free.downloadsPaused, false); + + const held = buildWorkersPayload( + inputs({ + paused: false, + settings: withGateHeld(defaultSiteSettings(), "download", true), + }), + ); + assert.equal(held.paused, false); + assert.equal(held.downloadsPaused, true); + + // The two gates are independent, and holding the TRANSCRIPTION lane must not + // read as downloads paused. + const other = buildWorkersPayload( + inputs({ + settings: withGateHeld(defaultSiteSettings(), "transcription", true), + }), + ); + assert.equal(other.downloadsPaused, false); +}); diff --git a/common/views/workers.ts b/common/views/workers.ts @@ -0,0 +1,112 @@ +import { isGateHeld } from "../lib/pauseGates"; +import type { WorkerRuntimeState } from "../jobs/workerPool"; +import type { WorkerDefaults } from "../jobs/workerDefaults"; +import type { LiveInputs } from "./inputs"; + +// THE WORKERS PAYLOAD, AND WHERE ITS TYPES USED TO LIVE. +// +// These three types were declared inside the `"use client"` WorkersView.tsx and +// the SERVER builder imported them back out of it — a payload shape owned by +// the component that renders it, which meant every route and widget that reads +// a worker list depended on a React file to say what a worker is. They are the +// wire shape of /api/workers; they belong beside the function that builds them. +// The component now imports them from here (and re-exports them until slice 1D +// repoints its importers). + +export type WorkerTask = { + id: string; + label: string; + fraction?: number; + detail?: string; + startedAt: number; + channelSlug?: string; +}; + +export type WorkerView = { + id: string; + name: string; + kind: "local" | "remote" | "llm"; + appId?: string; + priority: number; + busy: boolean; + state: WorkerRuntimeState; + degraded: boolean; + enabled: boolean; + // Capability tags (see lib/workers.ts workerMatches). Absent = takes anything. + tags?: string[]; + // True when the in-flight transcription can be stopped into a partial result. + canStopPartial: boolean; + tasks: WorkerTask[]; +}; + +export type WorkersPayload = { + paused: boolean; + // Persisted global downloads pause (settings.json downloadsPaused). Separate + // from `paused` (live transcription-worker pause). Surfaced on the same poll + // so the dashboard/widget controls reflect both without a second request. + downloadsPaused: boolean; + workers: WorkerView[]; + // Worker ids saved as the launch default ("Set as default"), or null when no + // default has been saved yet. Drives the button label and per-worker marker. + defaultEnabledIds: string[] | null; +}; + +// What this view needs, and nothing else: the pool's read half, the registry's +// list, the settings the download gate is stored in, and the persisted launch +// default — the last one READ BY THE SHELL, because `readWorkerDefaults` is a +// synchronous `fs` read and a view never touches the disk. +export type WorkersInputs = Pick< + LiveInputs, + "registry" | "pool" | "settings" +> & { + workerDefaults: WorkerDefaults | null; +}; + +// Builds the Workers screen payload: the pool's per-worker slot/state summary +// joined with the registry's in-flight transcribe tasks (which carry workerId) +// so each worker shows what it's currently running. Shared by the server- +// rendered page (initial state), the /api/workers poll route, and the read-only +// monitor widget. +export function buildWorkersPayload(i: WorkersInputs): WorkersPayload { + const summary = i.pool.summary(); + + const byWorker = new Map<string, WorkerTask[]>(); + for (const job of i.registry.list()) { + if (job.status !== "running" || !job.tasks) continue; + for (const t of job.tasks) { + if (t.kind !== "transcribe" || !t.workerId) continue; + const list = byWorker.get(t.workerId) ?? []; + list.push({ + id: t.id, + label: t.label, + fraction: t.fraction, + detail: t.detail, + startedAt: t.startedAt, + channelSlug: job.channelSlug, + }); + byWorker.set(t.workerId, list); + } + } + + const workers = summary.map((w) => ({ + ...w, + tasks: byWorker.get(w.id) ?? [], + // True when the in-flight transcription can be stopped into a partial result + // (parakeet). Drives the "Stop & keep partial" button on the Workers page. + canStopPartial: i.pool.canStopPartial(w.id), + })); + + // The saved launch default (Set as default), or null if none has been saved. + // Drives the button label ("Set as default" vs "Update default") and the + // per-worker "default" marker. + const defaultEnabledIds = i.workerDefaults?.enabledWorkerIds ?? null; + + return { + paused: i.pool.isPaused(), + // Persisted global downloads pause (settings.json). Surfaced here so the + // dashboard/widget controls can read both pause states from one poll. + downloadsPaused: isGateHeld(i.settings, "download"), + workers, + defaultEnabledIds, + }; +} diff --git a/editor/app/workers/buildWorkers.ts b/editor/app/workers/buildWorkers.ts @@ -1,58 +1,20 @@ -import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; -import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { readWorkerDefaults } from "yt-dlp-transcript-common/jobs/workerDefaults"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { getSettings } from "yt-dlp-transcript-common/lib/settings"; -import { isGateHeld } from "yt-dlp-transcript-common/lib/pauseGates"; -import type { WorkersPayload, WorkerTask } from "./components/WorkersView"; +import { + buildWorkersPayload as build, + type WorkersPayload, +} from "yt-dlp-transcript-common/views/workers"; +import { liveInputs } from "../lib/liveInputs"; -// Builds the Workers screen payload: the pool's per-worker slot/state summary -// joined with the registry's in-flight transcribe tasks (which carry workerId) -// so each worker shows what it's currently running. Shared by the server- -// rendered page (initial state), the /api/workers poll route, and the read-only -// monitor widget. +// THE SHELL. The payload is `common/views/workers.ts`, a pure function of its +// arguments; this is the editor half that fetches them — the singletons through +// `liveInputs()`, and the persisted launch default off disk (a synchronous read +// a view is not allowed to make). export function buildWorkersPayload(): WorkersPayload { - const pool = getWorkerPool(); - const summary = pool.summary(); - - const byWorker = new Map<string, WorkerTask[]>(); - for (const job of getRegistry().list()) { - if (job.status !== "running" || !job.tasks) continue; - for (const t of job.tasks) { - if (t.kind !== "transcribe" || !t.workerId) continue; - const list = byWorker.get(t.workerId) ?? []; - list.push({ - id: t.id, - label: t.label, - fraction: t.fraction, - detail: t.detail, - startedAt: t.startedAt, - channelSlug: job.channelSlug, - }); - byWorker.set(t.workerId, list); - } - } - - const workers = summary.map((w) => ({ - ...w, - tasks: byWorker.get(w.id) ?? [], - // True when the in-flight transcription can be stopped into a partial result - // (parakeet). Drives the "Stop & keep partial" button on the Workers page. - canStopPartial: pool.canStopPartial(w.id), - })); - - // The saved launch default (Set as default), or null if none has been saved. - // Drives the button label ("Set as default" vs "Update default") and the - // per-worker "default" marker. - const defaultEnabledIds = - readWorkerDefaults(getPaths())?.enabledWorkerIds ?? null; - - return { - paused: pool.isPaused(), - // Persisted global downloads pause (settings.json). Surfaced here so the - // dashboard/widget controls can read both pause states from one poll. - downloadsPaused: isGateHeld(getSettings(), "download"), - workers, - defaultEnabledIds, - }; + const i = liveInputs(); + return build({ + registry: i.registry, + pool: i.pool, + settings: i.settings, + workerDefaults: readWorkerDefaults(i.paths), + }); } diff --git a/editor/app/workers/components/WorkersView.tsx b/editor/app/workers/components/WorkersView.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { useCallback, useEffect, useState, useTransition } from "react"; import { formatDuration } from "yt-dlp-transcript-common/lib/format"; -import type { WorkerRuntimeState } from "yt-dlp-transcript-common/jobs/workerPool"; import { disableWorkerAction, drainWorkerAction, @@ -12,15 +11,18 @@ import { stopWorkerPartialAction, } from "../actions"; import { PauseLaneButton } from "../../components/lanes/pauseControl"; +import type { + WorkersPayload, + WorkerTask, + WorkerView, +} from "yt-dlp-transcript-common/views/workers"; -export type WorkerTask = { - id: string; - label: string; - fraction?: number; - detail?: string; - startedAt: number; - channelSlug?: string; -}; +// The payload shape used to be DECLARED here, in a `"use client"` file, and +// the server builder imported it back out — the renderer owning the wire +// shape. It lives in `common/views/workers.ts` now, beside the function that +// builds it. Re-exported so the current importers keep compiling; slice 1D +// repoints them at the view and deletes these three lines. +export type { WorkersPayload, WorkerTask, WorkerView }; // Live wall-clock that re-renders once a second; null until mounted so SSR and // the first client render agree (no Date.now() hydration mismatch). @@ -34,35 +36,6 @@ function useNow(): number | null { return now; } -export type WorkerView = { - id: string; - name: string; - kind: "local" | "remote" | "llm"; - appId?: string; - priority: number; - busy: boolean; - state: WorkerRuntimeState; - degraded: boolean; - enabled: boolean; - // Capability tags (see lib/workers.ts workerMatches). Absent = takes anything. - tags?: string[]; - // True when the in-flight transcription can be stopped into a partial result. - canStopPartial: boolean; - tasks: WorkerTask[]; -}; - -export type WorkersPayload = { - paused: boolean; - // Persisted global downloads pause (settings.json downloadsPaused). Separate - // from `paused` (live transcription-worker pause). Surfaced on the same poll - // so the dashboard/widget controls reflect both without a second request. - downloadsPaused: boolean; - workers: WorkerView[]; - // Worker ids saved as the launch default ("Set as default"), or null when no - // default has been saved yet. Drives the button label and per-worker marker. - defaultEnabledIds: string[] | null; -}; - const POLL_MS = 1000; export function WorkersView({ initial }: { initial: WorkersPayload }) {