Archilyzer · Source

archilyzer

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

commit b352dde505625cf8fd21b3dff8d7d25e4af1ff4f
parent 43417f039a1e8af571d4fe480e01cc42cf7c2a55
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 29 Aug 2026 18:11:36 -0400

workers: the worker list is configured where the workers are

/workers showed the live pool and sent you to /settings to change it; /settings
showed the list with a link back. The list is a form on /workers now, under the
workers it describes, with its own Save — and WorkersField moves with it.

saveWorkersAction is the one writer of settings.workers: read the file, replace
that block, preserve every other verbatim, then reconfigure the live pool with
applyEnabled, exactly as the whole-object form did. Nothing else on the page is
a form, so a save here cannot read another block's checkbox as off — the
structural property slice 3 bought for the operation blocks.

The tag vocabulary is built here now and FILTERED ON `scope === "video"`: a tag
names a per-video operation a delegate can take a unit of, and no worker will
ever be handed a channel. That is the first consumer to use the field for the
reason it exists.

Every worker aria-label is byte-identical. The three list tests move from
settings.spec.ts to workers.spec.ts; the migration and parakeet specs point at
/workers.

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

Diffstat:
Meditor/app/settings/actions.ts | 25++-----------------------
Meditor/app/settings/components/SettingsForm.tsx | 31+------------------------------
Deditor/app/settings/components/WorkersField.tsx | 556-------------------------------------------------------------------------------
Meditor/app/settings/page.tsx | 21+++++++--------------
Meditor/app/workers/actions.ts | 57++++++++++++++++++++++++++++++++++++++++++++++++++-------
Aeditor/app/workers/components/WorkersConfigForm.tsx | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/workers/components/WorkersField.tsx | 560+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/workers/page.tsx | 38+++++++++++++++++++++++++-------------
Meditor/e2e/parakeet-partial.spec.ts | 13++++++-------
Meditor/e2e/settings.spec.ts | 69---------------------------------------------------------------------
Meditor/e2e/transcription-app-migration.spec.ts | 6+++---
Meditor/e2e/workers.spec.ts | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
12 files changed, 809 insertions(+), 723 deletions(-)

diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -28,11 +28,6 @@ import { isCookieMode, } from "yt-dlp-transcript-common/lib/cookiePolicy"; import { isDownloadFormatPreset } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; -import { - sanitizeWorkers, - validateWorkers, -} from "yt-dlp-transcript-common/lib/workers"; -import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; export type SaveResult = { ok: true } | { ok: false; error: string }; @@ -85,18 +80,6 @@ export async function saveSettingsAction( if (!adminTitle) return { ok: false, error: "Admin title is required" }; - // Workers are submitted as a JSON array by the WorkersField client component. - // Sanitize + validate here for a friendly error; writeSettings re-validates. - let workersInput: unknown; - try { - workersInput = JSON.parse(String(formData.get("workersJson") ?? "[]")); - } catch { - return { ok: false, error: "Workers payload is malformed" }; - } - const workers = sanitizeWorkers(workersInput); - const workersErr = validateWorkers(workers); - if (workersErr) return { ok: false, error: workersErr }; - const parsed = Number.parseInt(maxBytesRaw, 10); if (!Number.isFinite(parsed)) { return { ok: false, error: "maxTranscriptPageBytes must be a number" }; @@ -223,7 +206,8 @@ export async function saveSettingsAction( // is vestigial (kept only for rollback) so pass the default. transcriptionApp: DEFAULT_TRANSCRIPTION_APP_ID, transcriptionApps: {}, - workers, + // workers: edited on /workers, beside the live list, by saveWorkersAction. + workers: getSettings().workers, cookiesFromBrowser, cookieMode, sleepBetweenDownloadsSeconds: sleepParsed, @@ -270,11 +254,6 @@ export async function saveSettingsAction( } catch (e) { return { ok: false, error: (e as Error).message }; } - // Apply the saved worker list to the live pool immediately so the Workers page - // and the next batch see it without waiting for a batch-start reconfigure. - // applyEnabled: a saved Enabled change is an explicit intent and takes effect now. - getWorkerPool().reconfigure(next.workers, { applyEnabled: true }); revalidatePath("/settings"); - revalidatePath("/workers"); return { ok: true }; } diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx @@ -10,24 +10,18 @@ import { DOWNLOAD_FORMAT_LABELS, DOWNLOAD_FORMAT_PRESETS, } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; -import type { TranscriptionAppDescriptor } from "yt-dlp-transcript-common/lib/transcriptionApps"; import { Field } from "../../components/forms/Field"; import { SocialLinksField, toSocialRow, type SocialRow, } from "../../components/SocialLinksField"; -import { WorkersField } from "./WorkersField"; type Props = { initial: SiteSettings; - apps: TranscriptionAppDescriptor[]; - // Known worker-tag vocabulary (operation ids + resources), built on the - // server for the same bundle reason — the catalog lives in operations.ts. - workerTags?: string[]; }; -export function SettingsForm({ initial, apps, workerTags }: Props) { +export function SettingsForm({ initial }: Props) { const [state, formAction] = useActionState<SaveResult | undefined, FormData>( saveSettingsAction, undefined, @@ -59,29 +53,6 @@ export function SettingsForm({ initial, apps, workerTags }: Props) { hint="Bytes per generated transcript page (256 KB – 20 MB). Affects pagination next build." type="number" /> - <fieldset className="flex flex-col gap-3 border border-border rounded p-3"> - <legend className="px-1 text-sm font-medium"> - Transcription workers - </legend> - <p className="text-xs text-muted-foreground"> - Each worker is one processing slot — one transcription at a time. To run - several in parallel on the same engine, add more workers (use{" "} - <strong>Copy</strong> to duplicate one). List order is priority (top = - preferred); each video goes to the highest-priority free worker, so a - fast GPU worker and a CPU worker run side by side. Enable, disable, and - drain individual workers live on the{" "} - <a href="/workers" className="underline"> - Workers - </a>{" "} - page — handy for freeing one core/GPU while the rest keep going. - </p> - <WorkersField - initial={initial.workers} - apps={apps} - name="workersJson" - knownTags={workerTags} - /> - </fieldset> <Field label="Cookies from browser" name="cookiesFromBrowser" diff --git a/editor/app/settings/components/WorkersField.tsx b/editor/app/settings/components/WorkersField.tsx @@ -1,556 +0,0 @@ -"use client"; - -import { useId, useState } from "react"; -import type { Worker } from "yt-dlp-transcript-common/lib/workers"; -import type { TranscriptionAppDescriptor } from "yt-dlp-transcript-common/lib/transcriptionApps"; - -// Editor for the transcription worker list. Manages an ordered array of workers -// (list order == priority, top = highest) and serializes it to a hidden JSON -// input the save action reads (mirrors SocialLinksField/socialLinksJson). The -// server re-sanitizes and validates, so this component only needs to produce a -// reasonable shape. - -type Row = Worker & { _key: number }; - -let keyCounter = 0; -function withKeys(workers: Worker[]): Row[] { - return workers.map((w) => ({ ...w, _key: keyCounter++ })); -} - -function blankLocal(appId: string): Row { - return { - _key: keyCounter++, - id: "", - name: "", - kind: "local", - enabled: true, - priority: 0, - appId, - config: {}, - }; -} - -function blankRemote(): Row { - return { - _key: keyCounter++, - id: "", - name: "", - kind: "remote", - enabled: true, - priority: 0, - remote: { baseUrl: "" }, - }; -} - -function blankLlm(): Row { - return { - _key: keyCounter++, - id: "", - name: "", - kind: "llm", - enabled: true, - priority: 0, - llm: { baseUrl: "" }, - }; -} - -// Duplicate a worker into a new slot: same config, fresh id, "(copy)" name. -function copyOf(row: Row): Row { - const { _key, id, name, ...rest } = row; - void _key; - void id; - return { - ...rest, - _key: keyCounter++, - id: "", - name: name ? `${name} (copy)` : "", - config: row.config ? { ...row.config } : undefined, - remote: row.remote ? { ...row.remote } : undefined, - llm: row.llm ? { ...row.llm } : undefined, - }; -} - -// Strip the React-only _key and set priority from list order before serializing. -function serialize(rows: Row[]): string { - return JSON.stringify( - rows.map(({ _key, ...w }, i) => { - void _key; - return { ...w, priority: i }; - }), - ); -} - -type Props = { - initial: Worker[]; - apps: TranscriptionAppDescriptor[]; - name: string; - // The known tag vocabulary (operation ids + resource names), built on the - // server: the catalog lives in operations.ts, whose import graph must not - // reach the client bundle. Used only to WARN about a tag the scheduler will - // never match — an unknown tag saves fine. - knownTags?: string[]; -}; - -export function WorkersField({ initial, apps, name, knownTags }: Props) { - const [rows, setRows] = useState<Row[]>(() => withKeys(initial)); - const defaultAppId = apps[0]?.id ?? "whisper-cpp"; - - function update(next: Row[]) { - setRows(next); - } - function patch(key: number, change: Partial<Row>) { - update(rows.map((r) => (r._key === key ? { ...r, ...change } : r))); - } - function patchConfig(key: number, change: Partial<NonNullable<Worker["config"]>>) { - update( - rows.map((r) => - r._key === key ? { ...r, config: { ...r.config, ...change } } : r, - ), - ); - } - function patchRemote(key: number, change: Partial<NonNullable<Worker["remote"]>>) { - update( - rows.map((r) => - r._key === key - ? { ...r, remote: { baseUrl: "", ...r.remote, ...change } } - : r, - ), - ); - } - function patchLlm(key: number, change: Partial<NonNullable<Worker["llm"]>>) { - update( - rows.map((r) => - r._key === key - ? { ...r, llm: { baseUrl: "", ...r.llm, ...change } } - : r, - ), - ); - } - function remove(key: number) { - update(rows.filter((r) => r._key !== key)); - } - function copy(index: number) { - const next = [...rows]; - next.splice(index + 1, 0, copyOf(rows[index])); - update(next); - } - function move(index: number, dir: -1 | 1) { - const j = index + dir; - if (j < 0 || j >= rows.length) return; - const next = [...rows]; - [next[index], next[j]] = [next[j], next[index]]; - update(next); - } - - return ( - <div className="flex flex-col gap-3"> - <input type="hidden" name={name} value={serialize(rows)} readOnly /> - {rows.length === 0 && ( - <p className="text-xs text-destructive"> - No workers defined — add at least one so transcription can run. - </p> - )} - {rows.map((row, i) => ( - <WorkerCard - key={row._key} - row={row} - index={i} - total={rows.length} - apps={apps} - knownTags={knownTags} - onPatch={(c) => patch(row._key, c)} - onPatchConfig={(c) => patchConfig(row._key, c)} - onPatchRemote={(c) => patchRemote(row._key, c)} - onPatchLlm={(c) => patchLlm(row._key, c)} - onRemove={() => remove(row._key)} - onCopy={() => copy(i)} - onMove={(dir) => move(i, dir)} - /> - ))} - <div className="flex gap-2"> - <button - type="button" - onClick={() => update([...rows, blankLocal(defaultAppId)])} - className="px-2 py-1 rounded border border-border text-xs hover:bg-muted" - > - + Local worker - </button> - <button - type="button" - onClick={() => update([...rows, blankRemote()])} - className="px-2 py-1 rounded border border-border text-xs hover:bg-muted" - > - + Remote worker - </button> - <button - type="button" - onClick={() => update([...rows, blankLlm()])} - className="px-2 py-1 rounded border border-border text-xs hover:bg-muted" - > - + LLM endpoint - </button> - </div> - </div> - ); -} - -function WorkerCard({ - row, - index, - total, - apps, - knownTags, - onPatch, - onPatchConfig, - onPatchRemote, - onPatchLlm, - onRemove, - onCopy, - onMove, -}: { - row: Row; - index: number; - total: number; - apps: TranscriptionAppDescriptor[]; - knownTags?: string[]; - onPatch: (c: Partial<Row>) => void; - onPatchConfig: (c: Partial<NonNullable<Worker["config"]>>) => void; - onPatchRemote: (c: Partial<NonNullable<Worker["remote"]>>) => void; - onPatchLlm: (c: Partial<NonNullable<Worker["llm"]>>) => void; - onRemove: () => void; - onCopy: () => void; - onMove: (dir: -1 | 1) => void; -}) { - const uid = useId(); - const app = apps.find((a) => a.id === row.appId); - const cfg = row.config ?? {}; - const remote = row.remote ?? { baseUrl: "" }; - const llmCfg = row.llm ?? { baseUrl: "" }; - return ( - <div - aria-label={`worker ${index + 1}`} - className="flex flex-col gap-2 rounded border border-border p-3" - > - <div className="flex flex-wrap items-center gap-2"> - <span className="text-xs text-muted-foreground" title="priority (lower = preferred)"> - #{index + 1} - </span> - <input - type="text" - aria-label={`worker ${index + 1} name`} - placeholder="Worker name" - value={row.name} - onChange={(e) => onPatch({ name: e.target.value })} - className="flex-1 min-w-40 rounded border border-border bg-card px-2 py-1 text-sm" - /> - <label className="flex items-center gap-1 text-xs"> - <input - type="checkbox" - checked={row.enabled} - onChange={(e) => onPatch({ enabled: e.target.checked })} - aria-label={`worker ${index + 1} enabled`} - /> - Enabled - </label> - <div className="flex items-center gap-1"> - <button - type="button" - onClick={() => onMove(-1)} - disabled={index === 0} - aria-label={`move worker ${index + 1} up`} - className="px-1.5 py-0.5 rounded border border-border text-xs disabled:opacity-40" - > - ↑ - </button> - <button - type="button" - onClick={() => onMove(1)} - disabled={index === total - 1} - aria-label={`move worker ${index + 1} down`} - className="px-1.5 py-0.5 rounded border border-border text-xs disabled:opacity-40" - > - ↓ - </button> - <button - type="button" - onClick={onCopy} - aria-label={`copy worker ${index + 1}`} - title="Duplicate this worker into another slot" - className="px-1.5 py-0.5 rounded border border-border text-xs hover:bg-muted" - > - Copy - </button> - <button - type="button" - onClick={onRemove} - aria-label={`remove worker ${index + 1}`} - className="px-1.5 py-0.5 rounded border border-destructive/30 text-xs text-destructive hover:bg-destructive-soft" - > - Remove - </button> - </div> - </div> - - <div className="flex flex-wrap items-end gap-3"> - {row.kind === "local" && ( - <label className="flex flex-col gap-1 text-xs"> - <span className="font-medium">Engine</span> - <select - value={row.appId ?? ""} - onChange={(e) => onPatch({ appId: e.target.value })} - aria-label={`worker ${index + 1} engine`} - className="rounded border border-border bg-card px-2 py-1 text-sm" - > - {apps.map((a) => ( - <option key={a.id} value={a.id}> - {a.label} - </option> - ))} - </select> - </label> - )} - <span className="text-xs text-muted-foreground"> - {row.kind === "local" - ? "Local · one slot" - : row.kind === "llm" - ? `LLM endpoint · ${llmCfg.slots ?? 1} slot${(llmCfg.slots ?? 1) === 1 ? "" : "s"}` - : remote.slots !== undefined - ? `Remote · ${remote.slots} slot${remote.slots === 1 ? "" : "s"}` - : "Remote · slots probed from the remote"} - </span> - <TagsField - tags={row.tags ?? []} - knownTags={knownTags} - index={index} - onChange={(tags) => - onPatch({ tags: tags.length > 0 ? tags : undefined }) - } - /> - </div> - - {row.kind === "llm" ? ( - <div className="flex flex-col gap-2 border-t border-border pt-2"> - <CardField - label="Base URL" - value={llmCfg.baseUrl} - onChange={(v) => onPatchLlm({ baseUrl: v })} - hint="A bare ollama endpoint (e.g. http://macbook.lan:11434) — the box runs nothing but `ollama serve`. It must serve the primary's exact model tag; the scheduler checks and skips it if not." - id={`${uid}-llmurl`} - /> - <CardField - label="Slots" - type="number" - value={llmCfg.slots !== undefined ? String(llmCfg.slots) : ""} - onChange={(v) => - onPatchLlm({ slots: v.trim() === "" ? undefined : Number(v) }) - } - hint="Concurrent generations to send this endpoint. Blank = 1." - id={`${uid}-llmslots`} - /> - <p className="text-xs text-muted-foreground"> - Takes digest and speaker-attribution model calls only — never a - transcription. Tags narrow which of those operations it serves; - blank tags mean all of them. - </p> - </div> - ) : row.kind === "local" ? ( - <div className="flex flex-col gap-2 border-t border-border pt-2"> - <CardField - label="Binary" - value={cfg.bin ?? ""} - onChange={(v) => onPatchConfig({ bin: v })} - hint="Path/name of the executable. Blank = the engine's default." - id={`${uid}-bin`} - /> - {app?.fields.model && ( - <CardField - label="Model" - value={cfg.model ?? ""} - onChange={(v) => onPatchConfig({ model: v })} - hint="whisper.cpp: {model}. chough: CHOUGH_MODEL. parakeet: .gguf path. Blank = default." - id={`${uid}-model`} - /> - )} - {app?.fields.remoteUrl && ( - <CardField - label="chough server URL" - value={cfg.remoteUrl ?? ""} - onChange={(v) => onPatchConfig({ remoteUrl: v })} - hint="chough only: transcribe via a chough --server (sets CHOUGH_URL). Point two copies of this worker at the same server for two togglable slots. Blank = run chough locally." - id={`${uid}-remoteurl`} - /> - )} - {app?.fields.device && ( - <CardField - label="Device" - value={cfg.device ?? ""} - onChange={(v) => onPatchConfig({ device: v })} - hint="parakeet compute device (sets PARAKEET_DEVICE). e.g. cpu to force CPU, or CUDA0 / Vulkan1 for a specific GPU. Blank = parakeet-cli's default (first GPU)." - id={`${uid}-device`} - /> - )} - {app?.fields.chunkSize && ( - <CardField - label="Chunk size (seconds)" - type="number" - value={cfg.chunkSize !== undefined ? String(cfg.chunkSize) : ""} - onChange={(v) => - onPatchConfig({ - chunkSize: v.trim() === "" ? undefined : Number(v), - }) - } - hint="parakeet: per-window seconds. chough: -c. Blank = default." - id={`${uid}-chunk`} - /> - )} - {app?.fields.customArgs && ( - <label className="flex flex-col gap-1 text-xs"> - <span className="font-medium">Custom args (one per line)</span> - <textarea - value={(cfg.customArgs ?? []).join("\n")} - onChange={(e) => - onPatchConfig({ - customArgs: e.target.value - .split("\n") - .map((s) => s.trim()) - .filter(Boolean), - }) - } - rows={4} - className="rounded border border-border bg-card px-2 py-1 text-sm font-mono" - /> - <span className="text-muted-foreground"> - Blank = default. Placeholders: <code>{"{audioFile}"}</code>,{" "} - <code>{"{outputBase}"}</code> (both required),{" "} - <code>{"{model}"}</code>. - </span> - </label> - )} - </div> - ) : ( - <div className="flex flex-col gap-2 border-t border-border pt-2"> - <CardField - label="Base URL" - value={remote.baseUrl} - onChange={(v) => onPatchRemote({ baseUrl: v })} - hint="Another instance of this app, e.g. http://gpu-box.lan:3001. It runs its own worker pool." - id={`${uid}-url`} - /> - <CardField - label="Token" - value={remote.token ?? ""} - onChange={(v) => onPatchRemote({ token: v })} - hint="Bearer token sent to the remote (must match its WORKER_TOKEN). Blank if the remote has no token." - id={`${uid}-token`} - /> - <CardField - label="Slots" - type="number" - value={remote.slots !== undefined ? String(remote.slots) : ""} - onChange={(v) => - onPatchRemote({ - slots: v.trim() === "" ? undefined : Number(v), - }) - } - hint="Concurrent units to send this remote. Blank = ask the remote itself (its enabled worker count, re-checked about once a minute)." - id={`${uid}-slots`} - /> - <label className="flex items-center gap-2 text-xs"> - <input - type="checkbox" - checked={remote.sharedFs === true} - onChange={(e) => onPatchRemote({ sharedFs: e.target.checked })} - /> - <span> - Shared filesystem — send the video path instead of uploading audio - (only if the remote mounts the same transcripts dir). - </span> - </label> - </div> - )} - </div> - ); -} - -// Comma-separated capability tags. Local text state so a trailing comma or -// space survives typing; the parsed list is pushed up on every change. Unknown -// tags WARN and still save — the scheduler tolerates them, it just never -// matches them. -function TagsField({ - tags, - knownTags, - index, - onChange, -}: { - tags: string[]; - knownTags?: string[]; - index: number; - onChange: (tags: string[]) => void; -}) { - const [text, setText] = useState(tags.join(", ")); - const unknown = knownTags ? tags.filter((t) => !knownTags.includes(t)) : []; - return ( - <label className="flex flex-col gap-1 text-xs flex-1 min-w-48"> - <span className="font-medium">Tags</span> - <input - type="text" - aria-label={`worker ${index + 1} tags`} - value={text} - placeholder="e.g. cpu, diarization" - onChange={(e) => { - setText(e.target.value); - onChange([ - ...new Set( - e.target.value - .split(",") - .map((s) => s.trim()) - .filter(Boolean), - ), - ]); - }} - className="rounded border border-border bg-card px-2 py-1 text-sm" - /> - <span className="text-muted-foreground"> - Capability routing: operation ids (e.g. <code>diarization</code>) or - resources (<code>gpu</code>, <code>cpu</code>, <code>network</code>), - comma-separated. Blank = this worker takes anything. - </span> - {unknown.length > 0 && ( - <span className="text-warning"> - Unknown tag{unknown.length > 1 ? "s" : ""}: {unknown.join(", ")} — - saved, but the scheduler will never match {unknown.length > 1 ? "them" : "it"}. - </span> - )} - </label> - ); -} - -function CardField({ - label, - value, - onChange, - hint, - type = "text", - id, -}: { - label: string; - value: string; - onChange: (v: string) => void; - hint?: string; - type?: string; - id: string; -}) { - return ( - <label className="flex flex-col gap-1 text-xs" htmlFor={id}> - <span className="font-medium">{label}</span> - <input - id={id} - type={type} - value={value} - onChange={(e) => onChange(e.target.value)} - className="rounded border border-border bg-card px-2 py-1 text-sm" - /> - {hint && <span className="text-muted-foreground">{hint}</span>} - </label> - ); -} diff --git a/editor/app/settings/page.tsx b/editor/app/settings/page.tsx @@ -2,9 +2,6 @@ import type { Metadata } from "next"; import Link from "next/link"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; -import { listTranscriptionApps } from "yt-dlp-transcript-common/lib/transcriptionApps"; -import { operationCatalog } from "yt-dlp-transcript-common/lib/operations"; -import { WORKER_RESOURCE_TAGS } from "yt-dlp-transcript-common/lib/workers"; import { readXSessionStatus } from "yt-dlp-transcript-common/social/xSessionBroker"; import { SettingsForm } from "./components/SettingsForm"; import { XSessionSection } from "./components/XSessionSection"; @@ -77,19 +74,15 @@ export default async function SettingsPage() { <Link href="/operations/sync" className={POINTER_LINK}> Sync </Link>{" "} - carries the whole block, below the schedule it drives. + carries the whole block, below the schedule it drives. The + transcription worker list is configured on{" "} + <Link href="/workers" className={POINTER_LINK}> + Workers + </Link> + , beside the workers it describes. </p> </div> - <SettingsForm - initial={settings} - apps={listTranscriptionApps()} - workerTags={[ - ...new Set([ - ...operationCatalog().map((o) => o.id), - ...WORKER_RESOURCE_TAGS, - ]), - ]} - /> + <SettingsForm initial={settings} /> </section> <section className="flex flex-col gap-3 border-t border-border pt-6"> diff --git a/editor/app/workers/actions.ts b/editor/app/workers/actions.ts @@ -3,19 +3,62 @@ import { revalidatePath } from "next/cache"; import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSettings, writeSettings } from "yt-dlp-transcript-common/lib/settings"; +import { + sanitizeWorkers, + validateWorkers, +} from "yt-dlp-transcript-common/lib/workers"; import { writeWorkerDefaults } from "yt-dlp-transcript-common/jobs/workerDefaults"; -// Runtime worker controls for the Workers page. Most of these are transient -// operator overrides on the live pool — they are NOT written to settings.json -// (a restart returns workers to their configured enabled state). The settings -// form is where the persisted default-enabled state lives. +// Worker controls for the Workers page, of three kinds. // -// The one exception is setDefaultWorkersAction: it snapshots the currently -// enabled workers to a small persisted file (workerDefaults.ts) that the pool -// re-applies on the next launch — "Set as default" on the Workers page. +// Most are transient operator overrides on the LIVE POOL — they are NOT written +// to settings.json (a restart returns workers to their configured enabled +// state). +// +// setDefaultWorkersAction snapshots the currently enabled workers to a small +// persisted file (workerDefaults.ts) that the pool re-applies on the next +// launch — "Set as default" on this page. +// +// saveWorkersAction is the PERSISTED LIST itself, which used to be a fieldset +// in the settings form. It lives here now, beside the workers it describes. export type WorkerActionResult = { ok: boolean; error?: string }; +export type SaveWorkersResult = { ok: true } | { ok: false; error: string }; + +// THE ONE WRITER OF settings.workers. Reads the current file and replaces this +// block only — every other block is preserved verbatim, the contract +// cadence-ui.spec.ts holds every settings writer to. Then the live pool: a +// saved Enabled change is an explicit intent and takes effect now +// (applyEnabled), exactly as the whole-object settings form did before this +// moved here. +export async function saveWorkersAction( + _prev: SaveWorkersResult | undefined, + formData: FormData, +): Promise<SaveWorkersResult> { + // Workers are submitted as a JSON array by the WorkersField client component. + // Sanitize + validate here for a friendly error; writeSettings re-validates. + let workersInput: unknown; + try { + workersInput = JSON.parse(String(formData.get("workersJson") ?? "[]")); + } catch { + return { ok: false, error: "Workers payload is malformed" }; + } + const workers = sanitizeWorkers(workersInput); + const workersErr = validateWorkers(workers); + if (workersErr) return { ok: false, error: workersErr }; + + try { + await writeSettings({ ...getSettings(), workers }); + } catch (e) { + return { ok: false, error: (e as Error).message }; + } + getWorkerPool().reconfigure(workers, { applyEnabled: true }); + revalidatePath("/workers"); + return { ok: true }; +} + function refresh() { revalidatePath("/workers"); } diff --git a/editor/app/workers/components/WorkersConfigForm.tsx b/editor/app/workers/components/WorkersConfigForm.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useActionState } from "react"; +import type { Worker } from "yt-dlp-transcript-common/lib/workers"; +import type { TranscriptionAppDescriptor } from "yt-dlp-transcript-common/lib/transcriptionApps"; +import { saveWorkersAction, type SaveWorkersResult } from "../actions"; +import { WorkersField } from "./WorkersField"; + +// THE PERSISTED WORKER LIST, configured beside the workers it describes. +// +// It was a fieldset inside the one big settings form, which is the shape slice +// 3 spent a whole commit undoing: this form writes ONE block and nothing else +// on this page is a form, which is what lets a save here never read another +// block's checkbox as off. +// +// Both type imports are load-bearing as types: workers.ts reaches node:fs +// through transcriptionApps.ts. The app list and the tag vocabulary are built +// on the server in page.tsx and travel as props. +export function WorkersConfigForm({ + initial, + apps, + knownTags, +}: { + initial: Worker[]; + apps: TranscriptionAppDescriptor[]; + knownTags: string[]; +}) { + const [state, formAction, pending] = useActionState< + SaveWorkersResult | undefined, + FormData + >(saveWorkersAction, undefined); + + return ( + <form + action={formAction} + data-settings-block="workers" + className="flex flex-col gap-3 rounded border border-border p-3" + > + <h2 className="text-sm font-semibold">Configuration</h2> + <p className="text-xs text-muted-foreground"> + Each worker is one processing slot — one transcription at a time. To run + several in parallel on the same engine, add more workers (use{" "} + <strong>Copy</strong> to duplicate one). List order is priority (top = + preferred); each video goes to the highest-priority free worker, so a + fast GPU worker and a CPU worker run side by side. Enable, disable and + drain individual workers live in the list above. + </p> + <WorkersField + initial={initial} + apps={apps} + name="workersJson" + knownTags={knownTags} + /> + <div className="flex items-center gap-3"> + <button + type="submit" + disabled={pending} + className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" + > + {pending ? "Saving…" : "Save workers"} + </button> + {state?.ok === true && ( + <span role="status" className="text-sm text-success"> + Saved. + </span> + )} + {state?.ok === false && ( + <span role="alert" className="text-sm text-destructive"> + {state.error} + </span> + )} + </div> + </form> + ); +} diff --git a/editor/app/workers/components/WorkersField.tsx b/editor/app/workers/components/WorkersField.tsx @@ -0,0 +1,560 @@ +"use client"; + +import { useId, useState } from "react"; +import type { Worker } from "yt-dlp-transcript-common/lib/workers"; +import type { TranscriptionAppDescriptor } from "yt-dlp-transcript-common/lib/transcriptionApps"; + +// Editor for the transcription worker list. Manages an ordered array of workers +// (list order == priority, top = highest) and serializes it to a hidden JSON +// input saveWorkersAction reads (mirrors SocialLinksField/socialLinksJson). The +// server re-sanitizes and validates, so this component only needs to produce a +// reasonable shape. +// +// BOTH IMPORTS BELOW ARE `import type` AND MUST STAY SO: workers.ts reaches +// transcriptionApps.ts, which reaches paths.ts and node:fs. The tag vocabulary +// and the app list are built on the server and passed down as props. + +type Row = Worker & { _key: number }; + +let keyCounter = 0; +function withKeys(workers: Worker[]): Row[] { + return workers.map((w) => ({ ...w, _key: keyCounter++ })); +} + +function blankLocal(appId: string): Row { + return { + _key: keyCounter++, + id: "", + name: "", + kind: "local", + enabled: true, + priority: 0, + appId, + config: {}, + }; +} + +function blankRemote(): Row { + return { + _key: keyCounter++, + id: "", + name: "", + kind: "remote", + enabled: true, + priority: 0, + remote: { baseUrl: "" }, + }; +} + +function blankLlm(): Row { + return { + _key: keyCounter++, + id: "", + name: "", + kind: "llm", + enabled: true, + priority: 0, + llm: { baseUrl: "" }, + }; +} + +// Duplicate a worker into a new slot: same config, fresh id, "(copy)" name. +function copyOf(row: Row): Row { + const { _key, id, name, ...rest } = row; + void _key; + void id; + return { + ...rest, + _key: keyCounter++, + id: "", + name: name ? `${name} (copy)` : "", + config: row.config ? { ...row.config } : undefined, + remote: row.remote ? { ...row.remote } : undefined, + llm: row.llm ? { ...row.llm } : undefined, + }; +} + +// Strip the React-only _key and set priority from list order before serializing. +function serialize(rows: Row[]): string { + return JSON.stringify( + rows.map(({ _key, ...w }, i) => { + void _key; + return { ...w, priority: i }; + }), + ); +} + +type Props = { + initial: Worker[]; + apps: TranscriptionAppDescriptor[]; + name: string; + // The known tag vocabulary (operation ids + resource names), built on the + // server: the catalog lives in operations.ts, whose import graph must not + // reach the client bundle. Used only to WARN about a tag the scheduler will + // never match — an unknown tag saves fine. + knownTags?: string[]; +}; + +export function WorkersField({ initial, apps, name, knownTags }: Props) { + const [rows, setRows] = useState<Row[]>(() => withKeys(initial)); + const defaultAppId = apps[0]?.id ?? "whisper-cpp"; + + function update(next: Row[]) { + setRows(next); + } + function patch(key: number, change: Partial<Row>) { + update(rows.map((r) => (r._key === key ? { ...r, ...change } : r))); + } + function patchConfig(key: number, change: Partial<NonNullable<Worker["config"]>>) { + update( + rows.map((r) => + r._key === key ? { ...r, config: { ...r.config, ...change } } : r, + ), + ); + } + function patchRemote(key: number, change: Partial<NonNullable<Worker["remote"]>>) { + update( + rows.map((r) => + r._key === key + ? { ...r, remote: { baseUrl: "", ...r.remote, ...change } } + : r, + ), + ); + } + function patchLlm(key: number, change: Partial<NonNullable<Worker["llm"]>>) { + update( + rows.map((r) => + r._key === key + ? { ...r, llm: { baseUrl: "", ...r.llm, ...change } } + : r, + ), + ); + } + function remove(key: number) { + update(rows.filter((r) => r._key !== key)); + } + function copy(index: number) { + const next = [...rows]; + next.splice(index + 1, 0, copyOf(rows[index])); + update(next); + } + function move(index: number, dir: -1 | 1) { + const j = index + dir; + if (j < 0 || j >= rows.length) return; + const next = [...rows]; + [next[index], next[j]] = [next[j], next[index]]; + update(next); + } + + return ( + <div className="flex flex-col gap-3"> + <input type="hidden" name={name} value={serialize(rows)} readOnly /> + {rows.length === 0 && ( + <p className="text-xs text-destructive"> + No workers defined — add at least one so transcription can run. + </p> + )} + {rows.map((row, i) => ( + <WorkerCard + key={row._key} + row={row} + index={i} + total={rows.length} + apps={apps} + knownTags={knownTags} + onPatch={(c) => patch(row._key, c)} + onPatchConfig={(c) => patchConfig(row._key, c)} + onPatchRemote={(c) => patchRemote(row._key, c)} + onPatchLlm={(c) => patchLlm(row._key, c)} + onRemove={() => remove(row._key)} + onCopy={() => copy(i)} + onMove={(dir) => move(i, dir)} + /> + ))} + <div className="flex gap-2"> + <button + type="button" + onClick={() => update([...rows, blankLocal(defaultAppId)])} + className="px-2 py-1 rounded border border-border text-xs hover:bg-muted" + > + + Local worker + </button> + <button + type="button" + onClick={() => update([...rows, blankRemote()])} + className="px-2 py-1 rounded border border-border text-xs hover:bg-muted" + > + + Remote worker + </button> + <button + type="button" + onClick={() => update([...rows, blankLlm()])} + className="px-2 py-1 rounded border border-border text-xs hover:bg-muted" + > + + LLM endpoint + </button> + </div> + </div> + ); +} + +function WorkerCard({ + row, + index, + total, + apps, + knownTags, + onPatch, + onPatchConfig, + onPatchRemote, + onPatchLlm, + onRemove, + onCopy, + onMove, +}: { + row: Row; + index: number; + total: number; + apps: TranscriptionAppDescriptor[]; + knownTags?: string[]; + onPatch: (c: Partial<Row>) => void; + onPatchConfig: (c: Partial<NonNullable<Worker["config"]>>) => void; + onPatchRemote: (c: Partial<NonNullable<Worker["remote"]>>) => void; + onPatchLlm: (c: Partial<NonNullable<Worker["llm"]>>) => void; + onRemove: () => void; + onCopy: () => void; + onMove: (dir: -1 | 1) => void; +}) { + const uid = useId(); + const app = apps.find((a) => a.id === row.appId); + const cfg = row.config ?? {}; + const remote = row.remote ?? { baseUrl: "" }; + const llmCfg = row.llm ?? { baseUrl: "" }; + return ( + <div + aria-label={`worker ${index + 1}`} + className="flex flex-col gap-2 rounded border border-border p-3" + > + <div className="flex flex-wrap items-center gap-2"> + <span className="text-xs text-muted-foreground" title="priority (lower = preferred)"> + #{index + 1} + </span> + <input + type="text" + aria-label={`worker ${index + 1} name`} + placeholder="Worker name" + value={row.name} + onChange={(e) => onPatch({ name: e.target.value })} + className="flex-1 min-w-40 rounded border border-border bg-card px-2 py-1 text-sm" + /> + <label className="flex items-center gap-1 text-xs"> + <input + type="checkbox" + checked={row.enabled} + onChange={(e) => onPatch({ enabled: e.target.checked })} + aria-label={`worker ${index + 1} enabled`} + /> + Enabled + </label> + <div className="flex items-center gap-1"> + <button + type="button" + onClick={() => onMove(-1)} + disabled={index === 0} + aria-label={`move worker ${index + 1} up`} + className="px-1.5 py-0.5 rounded border border-border text-xs disabled:opacity-40" + > + ↑ + </button> + <button + type="button" + onClick={() => onMove(1)} + disabled={index === total - 1} + aria-label={`move worker ${index + 1} down`} + className="px-1.5 py-0.5 rounded border border-border text-xs disabled:opacity-40" + > + ↓ + </button> + <button + type="button" + onClick={onCopy} + aria-label={`copy worker ${index + 1}`} + title="Duplicate this worker into another slot" + className="px-1.5 py-0.5 rounded border border-border text-xs hover:bg-muted" + > + Copy + </button> + <button + type="button" + onClick={onRemove} + aria-label={`remove worker ${index + 1}`} + className="px-1.5 py-0.5 rounded border border-destructive/30 text-xs text-destructive hover:bg-destructive-soft" + > + Remove + </button> + </div> + </div> + + <div className="flex flex-wrap items-end gap-3"> + {row.kind === "local" && ( + <label className="flex flex-col gap-1 text-xs"> + <span className="font-medium">Engine</span> + <select + value={row.appId ?? ""} + onChange={(e) => onPatch({ appId: e.target.value })} + aria-label={`worker ${index + 1} engine`} + className="rounded border border-border bg-card px-2 py-1 text-sm" + > + {apps.map((a) => ( + <option key={a.id} value={a.id}> + {a.label} + </option> + ))} + </select> + </label> + )} + <span className="text-xs text-muted-foreground"> + {row.kind === "local" + ? "Local · one slot" + : row.kind === "llm" + ? `LLM endpoint · ${llmCfg.slots ?? 1} slot${(llmCfg.slots ?? 1) === 1 ? "" : "s"}` + : remote.slots !== undefined + ? `Remote · ${remote.slots} slot${remote.slots === 1 ? "" : "s"}` + : "Remote · slots probed from the remote"} + </span> + <TagsField + tags={row.tags ?? []} + knownTags={knownTags} + index={index} + onChange={(tags) => + onPatch({ tags: tags.length > 0 ? tags : undefined }) + } + /> + </div> + + {row.kind === "llm" ? ( + <div className="flex flex-col gap-2 border-t border-border pt-2"> + <CardField + label="Base URL" + value={llmCfg.baseUrl} + onChange={(v) => onPatchLlm({ baseUrl: v })} + hint="A bare ollama endpoint (e.g. http://macbook.lan:11434) — the box runs nothing but `ollama serve`. It must serve the primary's exact model tag; the scheduler checks and skips it if not." + id={`${uid}-llmurl`} + /> + <CardField + label="Slots" + type="number" + value={llmCfg.slots !== undefined ? String(llmCfg.slots) : ""} + onChange={(v) => + onPatchLlm({ slots: v.trim() === "" ? undefined : Number(v) }) + } + hint="Concurrent generations to send this endpoint. Blank = 1." + id={`${uid}-llmslots`} + /> + <p className="text-xs text-muted-foreground"> + Takes digest and speaker-attribution model calls only — never a + transcription. Tags narrow which of those operations it serves; + blank tags mean all of them. + </p> + </div> + ) : row.kind === "local" ? ( + <div className="flex flex-col gap-2 border-t border-border pt-2"> + <CardField + label="Binary" + value={cfg.bin ?? ""} + onChange={(v) => onPatchConfig({ bin: v })} + hint="Path/name of the executable. Blank = the engine's default." + id={`${uid}-bin`} + /> + {app?.fields.model && ( + <CardField + label="Model" + value={cfg.model ?? ""} + onChange={(v) => onPatchConfig({ model: v })} + hint="whisper.cpp: {model}. chough: CHOUGH_MODEL. parakeet: .gguf path. Blank = default." + id={`${uid}-model`} + /> + )} + {app?.fields.remoteUrl && ( + <CardField + label="chough server URL" + value={cfg.remoteUrl ?? ""} + onChange={(v) => onPatchConfig({ remoteUrl: v })} + hint="chough only: transcribe via a chough --server (sets CHOUGH_URL). Point two copies of this worker at the same server for two togglable slots. Blank = run chough locally." + id={`${uid}-remoteurl`} + /> + )} + {app?.fields.device && ( + <CardField + label="Device" + value={cfg.device ?? ""} + onChange={(v) => onPatchConfig({ device: v })} + hint="parakeet compute device (sets PARAKEET_DEVICE). e.g. cpu to force CPU, or CUDA0 / Vulkan1 for a specific GPU. Blank = parakeet-cli's default (first GPU)." + id={`${uid}-device`} + /> + )} + {app?.fields.chunkSize && ( + <CardField + label="Chunk size (seconds)" + type="number" + value={cfg.chunkSize !== undefined ? String(cfg.chunkSize) : ""} + onChange={(v) => + onPatchConfig({ + chunkSize: v.trim() === "" ? undefined : Number(v), + }) + } + hint="parakeet: per-window seconds. chough: -c. Blank = default." + id={`${uid}-chunk`} + /> + )} + {app?.fields.customArgs && ( + <label className="flex flex-col gap-1 text-xs"> + <span className="font-medium">Custom args (one per line)</span> + <textarea + value={(cfg.customArgs ?? []).join("\n")} + onChange={(e) => + onPatchConfig({ + customArgs: e.target.value + .split("\n") + .map((s) => s.trim()) + .filter(Boolean), + }) + } + rows={4} + className="rounded border border-border bg-card px-2 py-1 text-sm font-mono" + /> + <span className="text-muted-foreground"> + Blank = default. Placeholders: <code>{"{audioFile}"}</code>,{" "} + <code>{"{outputBase}"}</code> (both required),{" "} + <code>{"{model}"}</code>. + </span> + </label> + )} + </div> + ) : ( + <div className="flex flex-col gap-2 border-t border-border pt-2"> + <CardField + label="Base URL" + value={remote.baseUrl} + onChange={(v) => onPatchRemote({ baseUrl: v })} + hint="Another instance of this app, e.g. http://gpu-box.lan:3001. It runs its own worker pool." + id={`${uid}-url`} + /> + <CardField + label="Token" + value={remote.token ?? ""} + onChange={(v) => onPatchRemote({ token: v })} + hint="Bearer token sent to the remote (must match its WORKER_TOKEN). Blank if the remote has no token." + id={`${uid}-token`} + /> + <CardField + label="Slots" + type="number" + value={remote.slots !== undefined ? String(remote.slots) : ""} + onChange={(v) => + onPatchRemote({ + slots: v.trim() === "" ? undefined : Number(v), + }) + } + hint="Concurrent units to send this remote. Blank = ask the remote itself (its enabled worker count, re-checked about once a minute)." + id={`${uid}-slots`} + /> + <label className="flex items-center gap-2 text-xs"> + <input + type="checkbox" + checked={remote.sharedFs === true} + onChange={(e) => onPatchRemote({ sharedFs: e.target.checked })} + /> + <span> + Shared filesystem — send the video path instead of uploading audio + (only if the remote mounts the same transcripts dir). + </span> + </label> + </div> + )} + </div> + ); +} + +// Comma-separated capability tags. Local text state so a trailing comma or +// space survives typing; the parsed list is pushed up on every change. Unknown +// tags WARN and still save — the scheduler tolerates them, it just never +// matches them. +function TagsField({ + tags, + knownTags, + index, + onChange, +}: { + tags: string[]; + knownTags?: string[]; + index: number; + onChange: (tags: string[]) => void; +}) { + const [text, setText] = useState(tags.join(", ")); + const unknown = knownTags ? tags.filter((t) => !knownTags.includes(t)) : []; + return ( + <label className="flex flex-col gap-1 text-xs flex-1 min-w-48"> + <span className="font-medium">Tags</span> + <input + type="text" + aria-label={`worker ${index + 1} tags`} + value={text} + placeholder="e.g. cpu, diarization" + onChange={(e) => { + setText(e.target.value); + onChange([ + ...new Set( + e.target.value + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ), + ]); + }} + className="rounded border border-border bg-card px-2 py-1 text-sm" + /> + <span className="text-muted-foreground"> + Capability routing: operation ids (e.g. <code>diarization</code>) or + resources (<code>gpu</code>, <code>cpu</code>, <code>network</code>), + comma-separated. Blank = this worker takes anything. + </span> + {unknown.length > 0 && ( + <span className="text-warning"> + Unknown tag{unknown.length > 1 ? "s" : ""}: {unknown.join(", ")} — + saved, but the scheduler will never match {unknown.length > 1 ? "them" : "it"}. + </span> + )} + </label> + ); +} + +function CardField({ + label, + value, + onChange, + hint, + type = "text", + id, +}: { + label: string; + value: string; + onChange: (v: string) => void; + hint?: string; + type?: string; + id: string; +}) { + return ( + <label className="flex flex-col gap-1 text-xs" htmlFor={id}> + <span className="font-medium">{label}</span> + <input + id={id} + type={type} + value={value} + onChange={(e) => onChange(e.target.value)} + className="rounded border border-border bg-card px-2 py-1 text-sm" + /> + {hint && <span className="text-muted-foreground">{hint}</span>} + </label> + ); +} diff --git a/editor/app/workers/page.tsx b/editor/app/workers/page.tsx @@ -1,6 +1,10 @@ import type { Metadata } from "next"; -import Link from "next/link"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { listTranscriptionApps } from "yt-dlp-transcript-common/lib/transcriptionApps"; +import { operationCatalog } from "yt-dlp-transcript-common/lib/operations"; +import { WORKER_RESOURCE_TAGS } from "yt-dlp-transcript-common/lib/workers"; import { WorkersView } from "./components/WorkersView"; +import { WorkersConfigForm } from "./components/WorkersConfigForm"; import { buildWorkersPayload } from "./buildWorkers"; export const dynamic = "force-dynamic"; @@ -9,26 +13,34 @@ export const metadata: Metadata = { title: "Workers" }; export default async function WorkersPage() { const initial = buildWorkersPayload(); + const settings = getSettings(); + // The tag vocabulary a worker can be matched on: every PER-VIDEO operation (a + // delegate takes a UNIT of one — lanes.ts, runsOnFor) plus the resources. + // `scope` is the filter, not the id: sync is catalogued and channel-scoped, + // and no worker will ever be handed a channel. + const knownTags = [ + ...new Set([ + ...operationCatalog() + .filter((o) => o.scope === "video") + .map((o) => o.id), + ...WORKER_RESOURCE_TAGS, + ]), + ]; return ( <div className="flex flex-col gap-4"> - <div className="flex items-center justify-between"> - <h1 className="text-2xl font-semibold">Workers</h1> - <Link href="/settings" className="text-sm underline"> - Configure workers - </Link> - </div> + <h1 className="text-2xl font-semibold">Workers</h1> <p className="text-sm text-muted-foreground"> Transcription workers — one slot each. Enable, disable, or drain a worker to free up a CPU/GPU for other programs, then turn it back on when done. Disabling every worker pauses running batches (they wait for a worker) - instead of failing. Worker config (engines, priority, copies) lives on - the{" "} - <Link href="/settings" className="underline"> - Settings - </Link>{" "} - page. + instead of failing. </p> <WorkersView initial={initial} /> + <WorkersConfigForm + initial={settings.workers} + apps={listTranscriptionApps()} + knownTags={knownTags} + /> </div> ); } diff --git a/editor/e2e/parakeet-partial.spec.ts b/editor/e2e/parakeet-partial.spec.ts @@ -206,14 +206,13 @@ test("Stop & keep progress on a force-killed parakeet run is NOT recorded as a f test("a parakeet worker's device is configurable and persists", async ({ page, }) => { - await page.goto("/settings"); + await page.goto("/workers"); + const form = page.locator('form[data-settings-block="workers"]'); // Migration makes worker 1 whisper-cpp; switch it to parakeet to reveal Device. - await page.getByLabel("worker 1 engine").selectOption("parakeet"); - await page.getByLabel(/^Device/).fill("CUDA0"); - await page.getByRole("button", { name: /save settings/i }).click(); - await expect( - page.getByRole("status").filter({ hasText: "Saved" }), - ).toBeVisible(); + await form.getByLabel("worker 1 engine").selectOption("parakeet"); + await form.getByLabel(/^Device/).fill("CUDA0"); + await form.getByRole("button", { name: "Save workers" }).click(); + await expect(form.getByRole("status")).toHaveText("Saved."); const saved = await readJson<{ workers?: { appId?: string; config?: { device?: string } }[]; diff --git a/editor/e2e/settings.spec.ts b/editor/e2e/settings.spec.ts @@ -57,75 +57,6 @@ test("saves global default social links", async ({ page }) => { expect(saved.socialLinks?.[0].svg).toContain("currentColor"); }); -test("shows the migrated default workers and persists an added worker", async ({ - page, -}) => { - await page.goto("/settings"); - // An empty settings.json migrates to one whisper-cpp worker PER slot — the old - // default parallelTranscriptions of 2 becomes two enabled whisper workers. - await expect(page.getByLabel("worker 1 name")).toHaveValue(/whisper/i); - await expect(page.getByLabel("worker 2 name")).toHaveValue(/whisper/i); - - // Add a third worker on a different engine. - await page.getByRole("button", { name: "+ Local worker" }).click(); - await page.getByLabel("worker 3 name").fill("CPU chough"); - await page.getByLabel("worker 3 engine").selectOption("chough"); - - await page.getByRole("button", { name: /save settings/i }).click(); - await expect( - page.getByRole("status").filter({ hasText: "Saved" }), - ).toBeVisible(); - - const saved = await readJson<{ - workers?: { name: string; appId?: string; priority: number }[]; - }>("test-settings.json"); - expect(saved.workers).toHaveLength(3); - expect(saved.workers?.[2].name).toBe("CPU chough"); - expect(saved.workers?.[2].appId).toBe("chough"); - // List order is priority. - expect(saved.workers?.[2].priority).toBe(2); - - await page.reload(); - await expect(page.getByLabel("worker 3 name")).toHaveValue("CPU chough"); -}); - -test("copy duplicates a worker into a new slot", async ({ page }) => { - await page.goto("/settings"); - // Configure worker 1 as a chough-server worker, then copy it — the pattern for - // running two togglable slots against one chough --server. - await page.getByLabel("worker 1 engine").selectOption("chough"); - await page.getByLabel("worker 1 name").fill("Chough server"); - await page.getByLabel(/chough server URL/i).fill("http://localhost:8165"); - await page.getByRole("button", { name: "copy worker 1" }).click(); - - await expect(page.getByLabel("worker 2 name")).toHaveValue("Chough server (copy)"); - - await page.getByRole("button", { name: /save settings/i }).click(); - await expect( - page.getByRole("status").filter({ hasText: "Saved" }), - ).toBeVisible(); - - const saved = await readJson<{ - workers?: { appId?: string; config?: { remoteUrl?: string } }[]; - }>("test-settings.json"); - const choughs = (saved.workers ?? []).filter((w) => w.appId === "chough"); - expect(choughs.length).toBe(2); - expect(choughs.every((w) => w.config?.remoteUrl === "http://localhost:8165")).toBe( - true, - ); -}); - -test("rejects saving with no enabled workers", async ({ page }) => { - await page.goto("/settings"); - // Migration produced two enabled workers; disable both. - await page.getByLabel("worker 1 enabled").uncheck(); - await page.getByLabel("worker 2 enabled").uncheck(); - await page.getByRole("button", { name: /save settings/i }).click(); - await expect(page.locator("form").getByRole("alert")).toContainText( - /at least one worker must be enabled/i, - ); -}); - test("rejects out-of-range maxTranscriptPageBytes", async ({ page }) => { await page.goto("/settings"); await page.getByLabel(/max transcript page bytes/i).fill("42"); diff --git a/editor/e2e/transcription-app-migration.spec.ts b/editor/e2e/transcription-app-migration.spec.ts @@ -3,7 +3,7 @@ import { resetData, writeSettings } from "./helpers"; // A pre-multi-app settings.json (transcribeBin/transcribeArgs/transcribeModel, // no transcriptionApp/workers keys) must migrate onto the worker model when -// read, so the Settings page shows a worker on the matching engine rather than +// read, so the Workers page shows a worker on the matching engine rather than // crashing on missing fields. test("legacy whisper settings migrate onto a whisper-cpp worker", async ({ @@ -18,7 +18,7 @@ test("legacy whisper settings migrate onto a whisper-cpp worker", async ({ transcribeModel: "/models/ggml.bin", transcribeArgs: ["-ojf", "-l", "en", "-m", "{model}", "-of", "{outputBase}", "{audioFile}"], }); - await page.goto("/settings"); + await page.goto("/workers"); await expect(page.getByLabel("worker 1 engine")).toHaveValue("whisper-cpp"); }); @@ -34,6 +34,6 @@ test("legacy settings whose binary is chough migrate onto a chough worker", asyn transcribeModel: "", transcribeArgs: ["-f", "json", "-o", "{outputBase}", "{audioFile}"], }); - await page.goto("/settings"); + await page.goto("/workers"); await expect(page.getByLabel("worker 1 engine")).toHaveValue("chough"); }); diff --git a/editor/e2e/workers.spec.ts b/editor/e2e/workers.spec.ts @@ -1,6 +1,7 @@ // The Workers page: live per-worker status plus the runtime enable/disable/drain // controls and the temporary pause-all/resume-all. These are transient operator -// overrides on the global worker pool — they don't touch settings.json. +// overrides on the global worker pool — they don't touch settings.json — plus +// the persisted worker list, which is configured here now. import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; @@ -263,3 +264,81 @@ test("a worker not in the saved default starts disabled on the next launch", asy await expect(gpu).toContainText(/idle|busy/); await expect(extra).toContainText("disabled"); }); + +// --- The persisted worker list, configured here ------------------------------ +// +// Moved from settings.spec.ts: the list was a fieldset in the one big settings +// form and is now its own form on this page, beside the live workers it +// describes. Every worker aria-label is byte-identical; the page and the save +// button are what changed. The file-level beforeEach already does the +// resetData("empty") these tests used to bring with them. + +test.describe("worker configuration", () => { + test("shows the migrated default workers and persists an added worker", async ({ + page, + }) => { + await page.goto("/workers"); + const form = page.locator('form[data-settings-block="workers"]'); + // An empty settings.json migrates to one whisper-cpp worker PER slot — the old + // default parallelTranscriptions of 2 becomes two enabled whisper workers. + await expect(form.getByLabel("worker 1 name")).toHaveValue(/whisper/i); + await expect(form.getByLabel("worker 2 name")).toHaveValue(/whisper/i); + + // Add a third worker on a different engine. + await form.getByRole("button", { name: "+ Local worker" }).click(); + await form.getByLabel("worker 3 name").fill("CPU chough"); + await form.getByLabel("worker 3 engine").selectOption("chough"); + + await form.getByRole("button", { name: "Save workers" }).click(); + await expect(form.getByRole("status")).toHaveText("Saved."); + + const saved = await readJson<{ + workers?: { name: string; appId?: string; priority: number }[]; + }>("test-settings.json"); + expect(saved.workers).toHaveLength(3); + expect(saved.workers?.[2].name).toBe("CPU chough"); + expect(saved.workers?.[2].appId).toBe("chough"); + // List order is priority. + expect(saved.workers?.[2].priority).toBe(2); + + await page.reload(); + await expect(form.getByLabel("worker 3 name")).toHaveValue("CPU chough"); + }); + + test("copy duplicates a worker into a new slot", async ({ page }) => { + await page.goto("/workers"); + const form = page.locator('form[data-settings-block="workers"]'); + // Configure worker 1 as a chough-server worker, then copy it — the pattern for + // running two togglable slots against one chough --server. + await form.getByLabel("worker 1 engine").selectOption("chough"); + await form.getByLabel("worker 1 name").fill("Chough server"); + await form.getByLabel(/chough server URL/i).fill("http://localhost:8165"); + await form.getByRole("button", { name: "copy worker 1" }).click(); + + await expect(form.getByLabel("worker 2 name")).toHaveValue("Chough server (copy)"); + + await form.getByRole("button", { name: "Save workers" }).click(); + await expect(form.getByRole("status")).toHaveText("Saved."); + + const saved = await readJson<{ + workers?: { appId?: string; config?: { remoteUrl?: string } }[]; + }>("test-settings.json"); + const choughs = (saved.workers ?? []).filter((w) => w.appId === "chough"); + expect(choughs.length).toBe(2); + expect(choughs.every((w) => w.config?.remoteUrl === "http://localhost:8165")).toBe( + true, + ); + }); + + test("rejects saving with no enabled workers", async ({ page }) => { + await page.goto("/workers"); + const form = page.locator('form[data-settings-block="workers"]'); + // Migration produced two enabled workers; disable both. + await form.getByLabel("worker 1 enabled").uncheck(); + await form.getByLabel("worker 2 enabled").uncheck(); + await form.getByRole("button", { name: "Save workers" }).click(); + await expect(form.getByRole("alert")).toContainText( + /at least one worker must be enabled/i, + ); + }); +});