Archilyzer · Source

archilyzer

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

commit 21fc5d95ce0753073c9ade1c26824e21e2e0a787
parent 09a6a08f9e6115ed45acd845d126896236dd3e94
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 18 Jun 2026 18:37:26 -0400

Persist a default worker arrangement and disable workers on Stop

Add a Workers-page "Set as default" button that snapshots the currently
enabled workers to transcripts/.workers/defaults.json; the worker pool
re-applies it on the next launch, starting exactly those workers enabled
and every other worker (including ones added later) disabled. The default
governs the launch-time seed only — a Settings save still applies each
worker's enabled flag as before.

Also make "Stop & keep progress" drain the worker (it ends disabled)
instead of leaving it enabled to grab the next video, matching Drain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/jobs/workerDefaults.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/jobs/workerPool.ts | 43++++++++++++++++++++++++++++++++++++++++++-
Mcommon/lib/paths.ts | 6++++++
Meditor/CHANGELOG.md | 3++-
Meditor/app/workers/actions.ts | 29+++++++++++++++++++++++++----
Meditor/app/workers/buildWorkers.ts | 10+++++++++-
Meditor/app/workers/components/WorkersView.tsx | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Meditor/e2e/parakeet-partial.spec.ts | 9++++++++-
Meditor/e2e/workers.spec.ts | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
9 files changed, 298 insertions(+), 12 deletions(-)

diff --git a/common/jobs/workerDefaults.ts b/common/jobs/workerDefaults.ts @@ -0,0 +1,64 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Paths } from "../lib/paths"; + +// Persisted "default" worker arrangement. Unlike the in-memory worker pool +// (whose runtime enable/disable/drain state is wiped on every server restart), +// this small file survives restarts: it records the set of worker ids that +// should start ENABLED on the next launch. Any configured worker NOT listed +// here starts disabled. It is written only by the explicit Workers-page "Set as +// default" button (not on every runtime toggle) and read once by the worker +// pool on first use. +// +// File absent = no default has been set; the pool keeps its current behavior of +// seeding runtime state from each worker's persisted `enabled` flag. File +// present = the pool overlays this set over that seed at launch. +// +// This is the worker analogue of common/jobs/syncSchedulerState.ts. The read is +// synchronous (like getSettings) so the synchronous WorkerPool can call it +// during lazy init; the write is async (atomic tmp + rename) from the server +// action. + +export type WorkerDefaults = { + // Worker ids that should start enabled at launch. Order is not significant. + enabledWorkerIds: string[]; +}; + +// Read the default arrangement, tolerating a missing/corrupt file by returning +// null (meaning "no default set — fall back to settings.enabled"). The id list +// is coerced to a deduped string[] so a hand-edited file can't crash init. +export function readWorkerDefaults(paths: Paths): WorkerDefaults | null { + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(paths.workerDefaultsFile, "utf8")); + } catch { + return null; + } + if (!raw || typeof raw !== "object") return null; + const r = raw as Record<string, unknown>; + if (!Array.isArray(r.enabledWorkerIds)) return null; + const seen = new Set<string>(); + for (const id of r.enabledWorkerIds) { + if (typeof id === "string" && id) seen.add(id); + } + return { enabledWorkerIds: Array.from(seen) }; +} + +// Write the default arrangement atomically (tmp file + rename), creating the +// .workers dir on first use. Ids are sanitized to a deduped string[]. +export async function writeWorkerDefaults( + paths: Paths, + enabledWorkerIds: string[], +): Promise<void> { + const seen = new Set<string>(); + for (const id of enabledWorkerIds) { + if (typeof id === "string" && id) seen.add(id); + } + const out: WorkerDefaults = { enabledWorkerIds: Array.from(seen) }; + await fs.promises.mkdir(path.dirname(paths.workerDefaultsFile), { + recursive: true, + }); + const tmp = `${paths.workerDefaultsFile}.tmp-${process.pid}`; + await fs.promises.writeFile(tmp, JSON.stringify(out, null, 2) + "\n"); + await fs.promises.rename(tmp, paths.workerDefaultsFile); +} diff --git a/common/jobs/workerPool.ts b/common/jobs/workerPool.ts @@ -17,6 +17,8 @@ import type { Worker } from "../lib/workers"; import { getSettings } from "../lib/settings"; +import { getPaths } from "../lib/paths"; +import { readWorkerDefaults } from "./workerDefaults"; // Consecutive transport/exec failures before a worker is auto-marked degraded // (skipped by the scheduler until re-enabled). See markFailure / Phase 5. @@ -81,6 +83,31 @@ class WorkerPool { this.initialized = true; // Initial seed: apply the persisted enabled flags as the starting state. this.reconfigure(getSettings().workers, { applyEnabled: true }); + // Then overlay a saved "default" arrangement, if one exists, so the operator + // gets their preferred set of enabled workers back at launch. + this.applyDefaults(); + } + + // Apply the persisted "default" worker arrangement (Workers-page "Set as + // default") over the settings-seeded state: each listed worker starts enabled, + // every other worker starts disabled. A no-op when no default has been saved + // (readWorkerDefaults returns null) — the settings.enabled seed stands. This + // governs the launch-time seed only; a later settings save still re-applies + // settings.enabled via reconfigure(applyEnabled: true). + private applyDefaults(): void { + const defaults = readWorkerDefaults(getPaths()); + if (!defaults) return; + const enabled = new Set(defaults.enabledWorkerIds); + for (const entry of this.entries.values()) { + if (enabled.has(entry.config.id)) { + entry.state = "enabled"; + entry.degraded = false; + entry.consecutiveFailures = 0; + } else { + entry.state = "disabled"; + } + } + this.pump(); } // Re-sync the pool with a worker list (defaults to current settings). Updates @@ -317,10 +344,15 @@ class WorkerPool { } // True if the worker had an active transcription that supports partial-stop and - // was asked to stop. + // was asked to stop. Stopping also takes the worker OUT of rotation: it's busy + // finishing the current window, so drain it (→ disabled once the lease + // releases, via grant().release) rather than leaving it enabled to immediately + // grab the next video — "Stop & keep progress" means stop, not stop-then-go. partialStopWorker(id: string): boolean { const entry = this.entries.get(id); if (!entry?.activeStop) return false; + entry.state = entry.busy ? "draining" : "disabled"; + this.syncSnapshot(id, "disabled"); entry.activeStop(); return true; } @@ -375,6 +407,15 @@ class WorkerPool { return false; } + // Ids of every currently-enabled worker — the snapshot the Workers page "Set + // as default" button persists as the launch default (see workerDefaults.ts). + enabledIds(): string[] { + this.ensureInit(); + return Array.from(this.entries.values()) + .filter((e) => e.state === "enabled") + .map((e) => e.config.id); + } + summary(): WorkerSummary[] { this.ensureInit(); return Array.from(this.entries.values()) diff --git a/common/lib/paths.ts b/common/lib/paths.ts @@ -19,6 +19,11 @@ export type Paths = { // a rolling run log). Survives restarts, unlike the in-memory job registry. // See common/jobs/syncSchedulerState.ts. schedulerStateFile: string; + // Persisted "default" worker arrangement: the set of worker ids that should + // start enabled on the next server launch (workers not listed start + // disabled). Written by the Workers page "Set as default" button; applied by + // the worker pool on first use. See common/jobs/workerDefaults.ts. + workerDefaultsFile: string; lmdbPath: string; exportDir: string; // The dir Next.js serves at "/" (also holds checked-in static assets). For @@ -78,6 +83,7 @@ export function getPaths(): Paths { jobsDir: path.join(transcriptsDir, ".jobs"), workerScratchDir: path.join(transcriptsDir, ".worker-scratch"), schedulerStateFile: path.join(transcriptsDir, ".scheduler", "state.json"), + workerDefaultsFile: path.join(transcriptsDir, ".workers", "defaults.json"), lmdbPath: path.join(transcriptsDir, "index.mdb"), exportDir, exportPublicDir, diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **The Workers page can save the current arrangement as a launch default, and "Stop & keep progress" now takes the worker out of rotation.** A **Set as default** button (top of `/workers`) snapshots which workers are enabled right now; on the next server launch the pool starts exactly those workers enabled and **every other worker disabled** — including workers added later that aren't in the saved set. This persists the otherwise-transient runtime on/off state across restarts without touching `settings.json` (it's a small `transcripts/.workers/defaults.json` the pool reads on first use). Once a default is saved the button reads **Update default** and each included worker shows a small **default** badge; the default governs the launch-time seed only, so editing a worker's Enabled flag in Settings still takes effect as before. Separately, **Stop & keep progress** now drains the worker after finishing the current window (it ends *disabled*) instead of leaving it enabled to immediately grab the next video — matching **Drain**, which already ended disabled. See `common/jobs/workerDefaults.ts`. - **Downloads stop and stay blocked when free disk space runs low.** A new **Settings → Minimum free disk space (GB)** floor (default **5 GB**; set **0** to disable) guards every download against filling the disk. When free space on the transcripts data directory is at or below the floor, a download job is **prevented from starting** — the channel/import action returns a clear "Low disk space: X free, Y required" error instead of queuing — and a **running batch stops between videos**: the in-flight download finishes, no new ones start, and the batch ends cleanly (status *done*, partial progress preserved) rather than crashing into an out-of-space error mid-file. Free space is measured natively (`statfs`, no new dependency) and the check **fails open** — if it can't read the filesystem, downloads proceed rather than being wrongly blocked. The monitor widget (and `/api/jobs/active`) gained a compact disk indicator showing free space, which turns red and reads "downloads paused" when below the floor (and stays visible even when idle, so it explains why nothing is downloading). `store-playlist` (which writes no media) is not gated. See `common/lib/diskSpace.ts`. - **New read-only monitor widget (`/widget`) plus a builder to compose and embed it.** A compact, chrome-less page shows worker status (a colored idle/busy/draining/disabled/degraded dot per worker) and active-job progress bars at a glance — no sidebar, no command palette, and no action controls — so it fits in a small pinned window or an `<iframe>` for at-a-glance monitoring. It reuses the existing `/api/jobs/active` and `/api/workers` endpoints (polled live), and its initial paint is server-rendered for no flicker. What it shows is driven entirely by GET params: `jobs`/`workers` (toggle each section), `channel` (filter active jobs to one slug), `poll` (refresh seconds), `compact` (drop per-task detail), `titles` (section headers), and `idle=hide` (collapse to a tiny "Idle" line when nothing is active). A new **Monitor** page under the sidebar's **Pool** group (`/widget/builder`) exposes all of those as form controls, builds the shareable link with a **Copy** button, an **Open popup** button that launches the widget in a chrome-less `window.open` popup (a tab-less window) at the selected preview size, and live-previews the real widget in a sized iframe. To strip the app shell on exactly the widget route, the root layout now renders its sidebar/command-palette/auto-refresh through a small `AppFrame` client wrapper that hides them when the path is `/widget` (the builder keeps the normal shell). The per-worker payload builder shared by the Workers page and `/api/workers` was extracted to `buildWorkersPayload()` so the widget reuses it too. - **The channel video selector can bulk-remove audio files and wrong-format audio, and a new channel-wide sweep clears wrong-format audio in one click.** The selector pane's bulk action picker (channel page → video list) gained two operations, both pure filesystem ops that queue no job (so they never trigger a transcode). **Remove audio files** deletes each checked video's finalized `audio.<ext>` files while keeping transcripts, metadata, and any in-progress `.part` download (which can still resume). **Remove wrong-format audio** deletes only audio files that aren't the channel's target format — e.g. the `audio.m4a` / `audio.mp4` leftovers from downloads that failed yt-dlp's extract-to-`mp3` step *before* audio-integrity checking existed — even when that's a video's only audio, so it re-downloads cleanly. A matching **Select wrong-format** quick-select (shown when any such videos exist) checks exactly those videos, so the cleanup is one flow: **Select wrong-format** → action **Remove wrong-format audio** → **Apply** (each is confirmed first). For whole-channel cleanup, the channel page's **Cleanup** stage gained a **Remove wrong-format audio** section: a `type "remove" to confirm` sweep that walks every video dir and deletes all non-target audio — including the failed-extract orphans the existing **Clean extra audio formats** deliberately skips (it only de-dupes extras when the target file already exists). The sweep respects per-video **do not clean** markers and shows a reclaim estimate; the bulk action, being an explicit selection, removes regardless of the marker. @@ -11,7 +12,7 @@ - **The editor refreshes itself on a timer so its data stays live without a manual reload.** Every page now passively re-fetches its own server-rendered data on a configurable interval — so the sidebar badges (active/running job counts, changelog dot), channel reports, and any other on-screen figures keep up to date on their own. It uses Next's `router.refresh()` (the same mechanism the jobs list already used) mounted once globally in the root layout, so it covers every page and the shared sidebar with no per-page wiring. To avoid wasting work when you're not looking, it **pauses entirely while the browser tab is hidden** and does **one immediate refresh the moment you return** to the tab (rather than waiting out the interval); it also skips a tick while a previous refresh is still settling, so refreshes can't pile up. The cadence is set in **Settings → Auto-refresh interval (seconds)**: default **5s** (clamped 1–600), or **0 to disable** passive refresh completely. This replaces the jobs page's old bespoke 2.5s auto-refresh (the `/jobs/active` page keeps its faster 1s progress-bar polling, which animates per-task bars without a full re-render). - **Transcription is now driven by configurable workers instead of one global engine.** The old single **App** dropdown in **Settings → Transcription** is replaced by a **Transcription workers** list. Each worker is **one processing slot** — one transcription at a time — with its own engine (whisper.cpp / chough / parakeet) and config, and a priority given by its position in the list (top = preferred). To run several in parallel, add more workers; a **Copy** button duplicates one (e.g. point two copies at the same chough `--server` for two togglable server slots). A batch ("Transcribe missing", bucket, bulk, single-video) hands each video — per task — to the highest-priority free worker, so a fast GPU worker and a slower CPU worker (e.g. parakeet on the GPU + chough on the CPU) run side by side instead of one engine doing everything. Total parallelism is the number of enabled workers; the old per-run **Concurrency** control and the global **Parallel transcriptions** setting are gone (add/remove workers, or disable/drain one, to change load). A pre-worker `settings.json` migrates automatically to one worker per slot of the previously-selected app (the old parallel-transcriptions count becomes that many enabled copies), plus a disabled worker for any other engine you had configured, so existing installs keep their parallelism. Scheduling is a single process-wide pool, so two batches can't oversubscribe the same GPU. One-slot-per-worker also means you can disable a single slot to free *some* of a CPU/GPU while the rest keep transcribing. - **Remote workers: offload transcription to another instance of this app on your LAN.** Add a **remote** worker in the Settings list with the base URL of another instance (e.g. `http://gpu-box.lan:3001`) and a shared token. When a video is dispatched to it, this instance uploads the audio over HTTP, the remote transcribes it through *its own* worker pool (picking among its local engines), streams progress and log back, and this instance pulls the finished `transcript.json` and normalizes it locally — so the remote needs no knowledge of your channels, just CPU/GPU. The protocol lives under `/api/worker/*` and is **disabled unless `WORKER_TOKEN` is set** in the environment, so an instance is never an open transcription server by accident; every request carries `Authorization: Bearer <token>`, validated with a constant-time compare against the accepting instance's own `WORKER_TOKEN` (never against settings). Uploaded audio and the produced transcript live in a scratch dir that's cleaned up once the result is pulled (or the job is cancelled). If a remote returns a transport error mid-job, the video is automatically retried on another worker; a genuine transcription failure on the remote is not retried. On a transport failure the remote's `GET /api/worker/health` is probed, and a remote confirmed **down** is auto-disabled (shown "degraded" on the Workers page, with **Enable** to retry once it's back) so neither the current video nor later ones keep burning attempts on it — they fail over to a healthy worker. A worker that racks up repeated failures while still reachable is auto-disabled after a few strikes. -- **New Workers page (`/workers`) with live status and runtime controls.** Lists every worker with its state (idle / busy / draining / disabled / degraded) and the video it's currently transcribing with per-task progress. Each worker can be **disabled** (stop taking new work immediately; in-flight transcriptions keep running), **drained** (stop taking new work but let the current video finish — the graceful "free up the GPU when it's done" path), or **enabled** again — without editing settings, so you can hand a CPU/GPU back to other programs and reclaim it later. A **Pause all** button disables every worker at once and remembers each one's state; **Resume all** restores them exactly. These runtime controls are transient (a restart returns workers to their configured enabled state); the Settings list is where the persisted defaults live. +- **New Workers page (`/workers`) with live status and runtime controls.** Lists every worker with its state (idle / busy / draining / disabled / degraded) and the video it's currently transcribing with per-task progress. Each worker can be **disabled** (stop taking new work immediately; in-flight transcriptions keep running), **drained** (stop taking new work but let the current video finish — the graceful "free up the GPU when it's done" path), or **enabled** again — without editing settings, so you can hand a CPU/GPU back to other programs and reclaim it later. A **Pause all** button disables every worker at once and remembers each one's state; **Resume all** restores them exactly. These runtime controls are transient (a restart returns workers to their configured enabled state, unless you capture the current arrangement with **Set as default**); the Settings list is where the persisted per-worker config lives. - **parakeet.cpp transcriptions are resumable, and can be paused mid-run.** The overlapping-segment wrapper now writes each window's raw parakeet-cli JSON to a per-audio work dir (`.<audio>.parakeet/`) as it finishes, and stitches the final transcript only once *all* windows are done (then removes the work dir). Re-running the same transcription picks up the cached windows and only does what's missing — yt-dlp-style resume, so a crash, cancel, or pause never loses completed windows. A busy parakeet worker on the Workers page shows a **Stop & keep progress** button: it finishes the in-flight window, stops and frees the worker (no transcript written yet), and the next "Transcribe missing" resumes from the cached windows and completes. Handy to reclaim a GPU mid-run. (whisper.cpp/chough run as a single pass and don't offer this.) - **Selectable compute device for parakeet.cpp.** A parakeet worker gained a **Device** field that forces the compute device — `cpu` to run on CPU, or a specific GPU like `CUDA0` / `Vulkan1`. parakeet.cpp's `parakeet-cli` has no `--device` flag and otherwise auto-grabs the first GPU the ggml registry reports, so the wrapper now exports the choice as the **`PARAKEET_DEVICE`** environment variable to the CLI (previously it was passed as a non-existent `--device` flag, which the CLI ignored — so a worker set to `cpu` still ran on the GPU). Combined with one-worker-per-slot and Copy, you can pin different parakeet workers to different devices. - **The Workers page and Active Jobs page cross-reference each other.** Each busy worker on `/workers` now shows what it's transcribing right now — the video (linked), the channel it's in, a live elapsed timer, percent, and the engine's progress detail — not just a bare bar. Conversely, every in-flight transcription on `/jobs/active` now says which worker it's running **on** (e.g. "Transcribing <id> on GPU"), so you can see how a batch is spread across your workers at a glance. diff --git a/editor/app/workers/actions.ts b/editor/app/workers/actions.ts @@ -2,11 +2,17 @@ import { revalidatePath } from "next/cache"; import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { writeWorkerDefaults } from "yt-dlp-transcript-common/jobs/workerDefaults"; -// Runtime worker controls for the Workers page. 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. +// 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. +// +// 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. export type WorkerActionResult = { ok: boolean; error?: string }; @@ -60,3 +66,18 @@ export async function stopWorkerPartialAction( ? { ok: true } : { ok: false, error: `Worker "${id}" has nothing to stop` }; } + +// Persist the CURRENT enabled workers as the launch default. On the next server +// start the pool enables exactly these workers and disables every other one +// (including workers added later that aren't in this set). Overwrites any +// previous default. +export async function setDefaultWorkersAction(): Promise<WorkerActionResult> { + const ids = getWorkerPool().enabledIds(); + try { + await writeWorkerDefaults(getPaths(), ids); + } catch (e) { + return { ok: false, error: (e as Error).message }; + } + refresh(); + return { ok: true }; +} diff --git a/editor/app/workers/buildWorkers.ts b/editor/app/workers/buildWorkers.ts @@ -1,5 +1,7 @@ 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 type { WorkersPayload, WorkerTask } from "./components/WorkersView"; // Builds the Workers screen payload: the pool's per-worker slot/state summary @@ -37,5 +39,11 @@ export function buildWorkersPayload(): WorkersPayload { canStopPartial: pool.canStopPartial(w.id), })); - return { paused: pool.isPaused(), workers }; + // 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(), workers, defaultEnabledIds }; } diff --git a/editor/app/workers/components/WorkersView.tsx b/editor/app/workers/components/WorkersView.tsx @@ -10,6 +10,7 @@ import { enableWorkerAction, pauseAllWorkersAction, resumeAllWorkersAction, + setDefaultWorkersAction, stopWorkerPartialAction, } from "../actions"; @@ -52,6 +53,9 @@ export type WorkerView = { export type WorkersPayload = { paused: 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; @@ -59,6 +63,8 @@ const POLL_MS = 1000; export function WorkersView({ initial }: { initial: WorkersPayload }) { const [payload, setPayload] = useState<WorkersPayload>(initial); const [pending, startTransition] = useTransition(); + // Transient "Saved as default" confirmation shown after the button runs. + const [savedDefault, setSavedDefault] = useState(false); const refetch = useCallback(async () => { try { @@ -90,7 +96,23 @@ export function WorkersView({ initial }: { initial: WorkersPayload }) { }); } - const { workers, paused } = payload; + function saveDefault() { + startTransition(async () => { + const res = (await setDefaultWorkersAction()) as { ok: boolean }; + await refetch(); + if (res.ok) setSavedDefault(true); + }); + } + + // Clear the "Saved" confirmation a few seconds after it appears. + useEffect(() => { + if (!savedDefault) return; + const id = setTimeout(() => setSavedDefault(false), 3000); + return () => clearTimeout(id); + }, [savedDefault]); + + const { workers, paused, defaultEnabledIds } = payload; + const defaultSet = new Set(defaultEnabledIds ?? []); return ( <div className="flex flex-col gap-3"> @@ -120,6 +142,25 @@ export function WorkersView({ initial }: { initial: WorkersPayload }) { worker&apos;s previous state. </span> )} + <span className="ml-auto flex items-center gap-2"> + {savedDefault && ( + <span + role="status" + className="text-sm text-green-700 dark:text-green-300" + > + Saved as default + </span> + )} + <button + type="button" + disabled={pending || workers.length === 0} + onClick={saveDefault} + title="Save the currently enabled workers as the launch default; on the next server start only these workers start enabled and all others start disabled" + className="px-3 py-1.5 rounded-md border border-zinc-300 dark:border-zinc-700 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-50" + > + {defaultEnabledIds ? "Update default" : "Set as default"} + </button> + </span> </div> {workers.length === 0 ? ( @@ -133,7 +174,13 @@ export function WorkersView({ initial }: { initial: WorkersPayload }) { ) : ( <ul className="flex flex-col gap-2"> {workers.map((w) => ( - <WorkerCard key={w.id} worker={w} pending={pending} run={run} /> + <WorkerCard + key={w.id} + worker={w} + pending={pending} + run={run} + inDefault={defaultSet.has(w.id)} + /> ))} </ul> )} @@ -175,10 +222,14 @@ function WorkerCard({ worker: w, pending, run, + inDefault, }: { worker: WorkerView; pending: boolean; run: (action: () => Promise<unknown>) => void; + // True when this worker is part of the saved launch default — it will start + // enabled on the next server start. + inDefault: boolean; }) { const badge = stateBadge(w); const engine = w.kind === "remote" ? "remote" : (w.appId ?? "local"); @@ -196,6 +247,14 @@ function WorkerCard({ </span> <span className="text-xs text-zinc-500">{engine}</span> <span className="text-xs text-zinc-400">priority {w.priority}</span> + {inDefault && ( + <span + className="text-[11px] px-1.5 py-0.5 rounded-full border bg-blue-100 dark:bg-blue-950 text-blue-800 dark:text-blue-200 border-blue-300 dark:border-blue-800" + title="Starts enabled on the next server launch (part of the saved default)" + > + default + </span> + )} <span className="ml-auto flex items-center gap-1.5"> {(w.state === "disabled" || w.state === "draining" || w.degraded) && ( <button @@ -214,7 +273,7 @@ function WorkerCard({ disabled={pending} onClick={() => run(() => stopWorkerPartialAction(w.id))} aria-label={`stop ${w.name} keep progress`} - title="Finish the current window, then stop and free the worker; completed windows are cached and resume on the next run" + title="Finish the current window, then stop and take the worker out of rotation (it ends disabled); completed windows are cached and resume on the next run" className="px-2 py-1 rounded border border-amber-300 dark:border-amber-800 text-xs text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-950 disabled:opacity-50" > Stop &amp; keep progress diff --git a/editor/e2e/parakeet-partial.spec.ts b/editor/e2e/parakeet-partial.spec.ts @@ -53,11 +53,18 @@ test("Stop & keep progress pauses a parakeet run, caches the window, and resumes await expect(stop).toBeVisible({ timeout: 15_000 }); await stop.click(); + // Stop also takes the worker out of rotation (it drains → disabled) rather + // than leaving it enabled to grab the next video — an Enable button appears. + const enable = gpu.getByRole("button", { name: /enable GPU parakeet/i }); + await expect(enable).toBeVisible({ timeout: 15_000 }); + // Pause caches the completed window but writes no transcript yet. await expect.poll(() => pathExists(cachedWindow), { timeout: 30_000 }).toBe(true); expect(await pathExists(transcript)).toBe(false); - // Re-running resumes from the cached window and completes the transcript. + // Re-enable the worker (Stop disabled it), then re-running resumes from the + // cached window and completes the transcript. + await gpu.getByRole("button", { name: /enable GPU parakeet/i }).click(); await page.goto("/channels/partial-chan"); await page.getByRole("button", { name: "Transcribe missing" }).click(); await expect.poll(() => pathExists(transcript), { timeout: 30_000 }).toBe(true); diff --git a/editor/e2e/workers.spec.ts b/editor/e2e/workers.spec.ts @@ -4,7 +4,16 @@ import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, resetData, resolvePath, writeSettings } from "./helpers"; +import { pathExists, readJson, resetData, resolvePath, writeSettings } from "./helpers"; +import { baseUrl } from "./baseUrl"; + +// Reset the in-memory worker pool (and job registry) without restarting the +// dev server — the next page load re-seeds the pool from settings + the saved +// default, exactly as a real relaunch would. The editor's test-only endpoint +// nulls the globalThis pool singleton. +async function relaunchPool() { + await fetch(`${baseUrl}/api/test/invalidate-cache`); +} async function makeTranscribeChannel(slug: string, ids: string[]) { const root = resolvePath(`test-transcripts/channels/${slug}`); @@ -174,3 +183,73 @@ test("Workers page and Active jobs cross-reference the running task", async ({ ); await expect(section).toContainText(/on\s+Only/, { timeout: 15_000 }); }); + +test("Set as default persists the enabled workers and re-applies them on relaunch", async ({ + page, +}) => { + await writeSettings(TWO_WORKERS); + await page.goto("/workers"); + const gpu = page.getByRole("listitem").filter({ hasText: "GPU" }); + const cpu = page.getByRole("listitem").filter({ hasText: "CPU" }); + + // Disable CPU, then save the current arrangement (only GPU on) as the default. + await cpu.getByRole("button", { name: "disable CPU" }).click(); + await expect(cpu).toContainText("disabled"); + await page.getByRole("button", { name: /^set as default$/i }).click(); + await expect( + page.getByRole("status").filter({ hasText: /saved as default/i }), + ).toBeVisible(); + + // Persisted to the worker-defaults file as just the GPU id. + const saved = await readJson<{ enabledWorkerIds: string[] }>( + "test-transcripts/.workers/defaults.json", + ); + expect(saved.enabledWorkerIds).toEqual(["gpu"]); + + // The button now reflects an existing default and GPU is badged. + await expect( + page.getByRole("button", { name: /update default/i }), + ).toBeVisible(); + await expect(gpu).toContainText("default"); + + // Re-enable CPU at runtime so the live state differs from the saved default. + await cpu.getByRole("button", { name: "enable CPU" }).click(); + await expect(cpu).toContainText(/idle|busy/); + + // Relaunch: the saved default is applied — GPU enabled, CPU disabled (its + // runtime re-enable is discarded because it's not in the default). + await relaunchPool(); + await page.goto("/workers"); + await expect(gpu).toContainText(/idle|busy/); + await expect(cpu).toContainText("disabled"); +}); + +test("a worker not in the saved default starts disabled on the next launch", async ({ + page, +}) => { + await writeSettings(TWO_WORKERS); + await page.goto("/workers"); + + // Save the default with both workers enabled. + await page.getByRole("button", { name: /^set as default$/i }).click(); + await expect( + page.getByRole("status").filter({ hasText: /saved as default/i }), + ).toBeVisible(); + + // Add a third worker in Settings (enabled there), then relaunch. writeSettings + // resets the pool, so the next load re-seeds from settings + the saved default. + await writeSettings({ + workers: [ + ...TWO_WORKERS.workers, + { id: "extra", name: "Extra", kind: "local", enabled: true, priority: 2, appId: "whisper-cpp", config: {} }, + ], + }); + await page.goto("/workers"); + + const gpu = page.getByRole("listitem").filter({ hasText: "GPU" }); + const extra = page.getByRole("listitem").filter({ hasText: "Extra" }); + // GPU was in the default → enabled; Extra wasn't → disabled despite being + // enabled in settings.json. + await expect(gpu).toContainText(/idle|busy/); + await expect(extra).toContainText("disabled"); +});