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:
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'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 & 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");
+});