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:
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 }) {