commit ed66c5404e188347aea39d174ccce7c988092491
parent de6327699740739953508658754a6f91392c5794
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 24 Aug 2026 09:48:58 -0400
workers: a remote is N slots — expanded at reconfigure, probed from its health
RemoteWorkerConfig gains `slots`; the pool expands one remote config into that
many independently-schedulable entries at reconfigure time (slot 1 keeps the
base id, extras are #2..#N), which finally makes the workers.ts header's
"priority and a slot count" promise true without turning PoolEntry.busy into a
counter. Blank slots are filled from the remote's own /api/worker/health —
the enabled-worker count that payload has always carried and every caller
discarded — through a TTL cache (controller/remoteCapacity.ts, the
sweepRecency pattern): reconfigure stays synchronous, a stale cache fires one
fire-and-forget probe, and a changed answer re-reconfigures with runtime state
preserved. Shrinking retires surplus slots via the removed-worker drain path;
an expansion slot inherits the base slot's runtime state so a landed probe
can't re-enable a remote the operator just disabled.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
7 files changed, 417 insertions(+), 27 deletions(-)
diff --git a/common/controller/remoteCapacity.test.ts b/common/controller/remoteCapacity.test.ts
@@ -0,0 +1,129 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import http from "node:http";
+import type { AddressInfo } from "node:net";
+import type { Worker } from "../lib/workers";
+import {
+ cachedRemoteSlots,
+ clearRemoteCapacityCache,
+ probeRemoteCapacity,
+ remoteCapacityStale,
+} from "./remoteCapacity";
+
+// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test controller/remoteCapacity.test.ts
+//
+// Probes run against a real node:http stub on a loopback port — no network, no
+// mocking of fetch. What is pinned: the count is enabled AND non-degraded
+// workers, a dead remote caches null (so it is not re-probed every
+// reconfigure), and a remote with nothing enabled still counts as one slot
+// rather than vanishing from the pool.
+
+function remoteWorker(id: string, baseUrl: string, token?: string): Worker {
+ return {
+ id,
+ name: id,
+ kind: "remote",
+ enabled: true,
+ priority: 0,
+ remote: { baseUrl, ...(token ? { token } : {}) },
+ };
+}
+
+async function withHealthServer(
+ handler: http.RequestListener,
+ fn: (baseUrl: string) => Promise<void>,
+): Promise<void> {
+ const server = http.createServer(handler);
+ await new Promise<void>((resolve) =>
+ server.listen(0, "127.0.0.1", resolve),
+ );
+ const { port } = server.address() as AddressInfo;
+ try {
+ await fn(`http://127.0.0.1:${port}`);
+ } finally {
+ await new Promise<void>((resolve) => server.close(() => resolve()));
+ }
+}
+
+function healthBody(workers: Array<{ state: string; degraded: boolean }>) {
+ return JSON.stringify({ ok: true, paused: false, workers });
+}
+
+test("probeRemoteCapacity counts enabled, non-degraded workers", async () => {
+ clearRemoteCapacityCache();
+ let auth: string | undefined;
+ await withHealthServer(
+ (req, res) => {
+ auth = req.headers.authorization;
+ res.setHeader("content-type", "application/json");
+ res.end(
+ healthBody([
+ { state: "enabled", degraded: false },
+ { state: "enabled", degraded: true },
+ { state: "disabled", degraded: false },
+ { state: "enabled", degraded: false },
+ ]),
+ );
+ },
+ async (baseUrl) => {
+ const worker = remoteWorker("box", baseUrl, "sekrit");
+ assert.equal(remoteCapacityStale("box"), true);
+ const slots = await probeRemoteCapacity(worker);
+ assert.equal(slots, 2);
+ assert.equal(auth, "Bearer sekrit");
+ assert.equal(cachedRemoteSlots("box"), 2);
+ assert.equal(remoteCapacityStale("box"), false);
+ },
+ );
+});
+
+test("an unreachable remote caches the failure instead of re-probing", async () => {
+ clearRemoteCapacityCache();
+ // Nothing listens here; the connection is refused immediately.
+ const worker = remoteWorker("dead", "http://127.0.0.1:59599");
+ assert.equal(await probeRemoteCapacity(worker), null);
+ assert.equal(cachedRemoteSlots("dead"), null);
+ // The failure IS cached: not stale, so reconfigure won't fire another probe
+ // inside the TTL window.
+ assert.equal(remoteCapacityStale("dead"), false);
+});
+
+test("a remote with zero enabled workers still counts as one slot", async () => {
+ clearRemoteCapacityCache();
+ await withHealthServer(
+ (_req, res) => {
+ res.setHeader("content-type", "application/json");
+ res.end(healthBody([{ state: "disabled", degraded: false }]));
+ },
+ async (baseUrl) => {
+ // A configured remote must never silently vanish from the pool; the
+ // health ping in transcribeOne is what handles a genuinely-down remote.
+ assert.equal(
+ await probeRemoteCapacity(remoteWorker("idlebox", baseUrl)),
+ 1,
+ );
+ },
+ );
+});
+
+test("concurrent probes for one worker share a single request", async () => {
+ clearRemoteCapacityCache();
+ let hits = 0;
+ await withHealthServer(
+ (_req, res) => {
+ hits++;
+ res.setHeader("content-type", "application/json");
+ res.end(healthBody([{ state: "enabled", degraded: false }]));
+ },
+ async (baseUrl) => {
+ const worker = remoteWorker("shared", baseUrl);
+ const [a, b] = await Promise.all([
+ probeRemoteCapacity(worker),
+ probeRemoteCapacity(worker),
+ ]);
+ assert.equal(a, 1);
+ assert.equal(b, 1);
+ assert.equal(hits, 1);
+ },
+ );
+});
diff --git a/common/controller/remoteCapacity.ts b/common/controller/remoteCapacity.ts
@@ -0,0 +1,102 @@
+// How many slots a REMOTE worker is worth, probed from the remote itself.
+//
+// A remote runs its own worker pool, and its /api/worker/health has always
+// returned that pool's per-worker summary — which every caller promptly threw
+// away, reading only `ok`. So the number of transcriptions a remote can
+// actually take in parallel was sitting on the wire while the primary treated
+// every remote as one slot. This module keeps that answer: the count of the
+// remote's enabled, non-degraded workers.
+//
+// TTL-CACHED, THE sweepRecency WAY. reconfigure() is synchronous and runs on
+// every batch start and settings save; a network probe cannot live inside it.
+// So the cache is read synchronously (cachedRemoteSlots), staleness is asked
+// synchronously (remoteCapacityStale), and the probe itself is fired
+// fire-and-forget by the pool when the answer is stale — no timer, no
+// background loop. The staleness cost is bounded and cheap: a remote that
+// gained a worker in the last minute runs one slot short until the next probe.
+//
+// An explicit `remote.slots` in the worker config bypasses all of this — the
+// operator's number is not second-guessed by a probe.
+
+import type { Worker } from "../lib/workers";
+
+const REMOTE_CAPACITY_TTL_MS = 60_000;
+
+type Cached = {
+ at: number;
+ // null = the probe failed (unreachable, bad body). Cached too, so a dead
+ // remote is not re-probed on every reconfigure inside one TTL window.
+ slots: number | null;
+};
+
+const cache = new Map<string, Cached>();
+const inFlight = new Map<string, Promise<number | null>>();
+
+// Exported for tests, which need a clean slate between fixtures.
+export function clearRemoteCapacityCache(): void {
+ cache.clear();
+ inFlight.clear();
+}
+
+// The cached slot count, or null when unknown (never probed, or the last probe
+// failed). Deliberately returns a STALE value rather than null — bounded
+// staleness beats a pool that collapses to one slot for the duration of a
+// probe.
+export function cachedRemoteSlots(workerId: string): number | null {
+ return cache.get(workerId)?.slots ?? null;
+}
+
+export function remoteCapacityStale(workerId: string): boolean {
+ const hit = cache.get(workerId);
+ return !hit || Date.now() - hit.at >= REMOTE_CAPACITY_TTL_MS;
+}
+
+type HealthBody = {
+ ok?: boolean;
+ workers?: Array<{ state?: string; degraded?: boolean }>;
+};
+
+// Probe the remote's /api/worker/health and cache how many transcriptions it
+// can take at once: its enabled, non-degraded workers. In-flight probes are
+// shared, failures are cached (as null) for the TTL, and the count is floored
+// at 1 — a configured remote must never silently vanish from the pool because
+// its operator momentarily disabled everything; the health ping in
+// transcribeOne is what handles a remote that is genuinely down.
+export function probeRemoteCapacity(
+ worker: Worker,
+ timeoutMs = 3000,
+): Promise<number | null> {
+ const running = inFlight.get(worker.id);
+ if (running) return running;
+ const probe = (async (): Promise<number | null> => {
+ const baseUrl = (worker.remote?.baseUrl ?? "").replace(/\/+$/, "");
+ let slots: number | null = null;
+ if (baseUrl) {
+ try {
+ const token = worker.remote?.token?.trim();
+ const res = await fetch(`${baseUrl}/api/worker/health`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ if (res.ok) {
+ const body = (await res.json()) as HealthBody;
+ if (body.ok === true && Array.isArray(body.workers)) {
+ slots = Math.max(
+ 1,
+ body.workers.filter(
+ (w) => w.state === "enabled" && w.degraded !== true,
+ ).length,
+ );
+ }
+ }
+ } catch {
+ // Unreachable/timeout — cached as unknown below.
+ }
+ }
+ cache.set(worker.id, { at: Date.now(), slots });
+ return slots;
+ })();
+ inFlight.set(worker.id, probe);
+ void probe.finally(() => inFlight.delete(worker.id));
+ return probe;
+}
diff --git a/common/jobs/workerPool.test.ts b/common/jobs/workerPool.test.ts
@@ -290,6 +290,79 @@ test("summary() carries tags and keeps its shape for untagged workers", () => {
assert.deepEqual(summary.find((w) => w.id === "tagged")?.tags, ["cpu"]);
});
+// --- Remote slot expansion (RemoteWorkerConfig.slots) ---
+
+function remoteWorker(id: string, slots?: number, priority = 0): Worker {
+ return {
+ id,
+ name: id,
+ kind: "remote",
+ enabled: true,
+ priority,
+ // An explicit slot count means the capacity probe never fires, keeping
+ // these tests fully offline.
+ remote: { baseUrl: "http://127.0.0.1:9", ...(slots ? { slots } : {}) },
+ };
+}
+
+test("a remote with slots: N expands to N independently-leasable entries", async () => {
+ const pool = new WorkerPool();
+ pool.reconfigure([remoteWorker("box", 3)], { applyEnabled: true });
+ const ids = pool.summary().map((w) => w.id);
+ // Slot 1 keeps the BASE id so saved defaults and degrade marks keep meaning
+ // what they meant; extras are #2..#N.
+ assert.deepEqual(ids.sort(), ["box", "box#2", "box#3"]);
+ const l1 = await pool.acquire();
+ const l2 = await pool.acquire();
+ const l3 = await pool.acquire();
+ assert.equal(new Set([l1, l2, l3].map((l) => l.worker.id)).size, 3);
+ // All three busy — a fourth claim yields nothing.
+ assert.equal(pool.tryAcquire([]), null);
+ for (const l of [l1, l2, l3]) l.release();
+});
+
+test("shrinking a remote's slots retires the surplus (drain if busy)", async () => {
+ const pool = new WorkerPool();
+ pool.reconfigure([remoteWorker("box", 3)], { applyEnabled: true });
+ const l1 = await pool.acquire(); // box
+ const l2 = await pool.acquire(); // box#2
+ pool.reconfigure([remoteWorker("box", 1)], { applyEnabled: true });
+ // The idle surplus slot is gone at once; the busy one drains.
+ const byId = new Map(pool.summary().map((w) => [w.id, w]));
+ assert.equal(byId.has("box#3"), false);
+ assert.equal(byId.get("box#2")?.state, "draining");
+ l2.release();
+ assert.equal(
+ pool.summary().some((w) => w.id === "box#2"),
+ false,
+ "the drained surplus slot is dropped when its lease releases",
+ );
+ l1.release();
+});
+
+test("degrading one remote slot leaves its siblings eligible", async () => {
+ const pool = new WorkerPool();
+ pool.reconfigure([remoteWorker("box", 2)], { applyEnabled: true });
+ pool.markDegraded("box");
+ const lease = await pool.acquire();
+ assert.equal(lease.worker.id, "box#2");
+ lease.release();
+});
+
+test("a slot added by expansion inherits the base slot's runtime state", async () => {
+ const pool = new WorkerPool();
+ pool.reconfigure([remoteWorker("box", 1)], { applyEnabled: true });
+ // Operator disables the remote from the Workers page (runtime, not settings)…
+ pool.disableWorker("box");
+ // …then a reconfigure grows the slot count (as a landed probe would).
+ pool.reconfigure([remoteWorker("box", 2)]);
+ assert.equal(
+ pool.summary().find((w) => w.id === "box#2")?.state,
+ "disabled",
+ "expansion must not re-enable a remote the operator just disabled",
+ );
+});
+
test("legacy { background: true } maps to the background tier", async () => {
const { pool, lease } = await busyPool();
const order: string[] = [];
diff --git a/common/jobs/workerPool.ts b/common/jobs/workerPool.ts
@@ -25,6 +25,11 @@
// drains, and the freed slot goes to the manual waiter.
import { workerMatches, type Worker, type WorkerKind } from "../lib/workers";
+import {
+ cachedRemoteSlots,
+ probeRemoteCapacity,
+ remoteCapacityStale,
+} from "../controller/remoteCapacity";
import { getSettings } from "../lib/settings";
import { getPaths } from "../lib/paths";
import { readWorkerDefaults } from "./workerDefaults";
@@ -160,34 +165,59 @@ export class WorkerPool {
const next = workers ?? getSettings().workers;
const seen = new Set<string>();
for (const w of next) {
- seen.add(w.id);
- const existing = this.entries.get(w.id);
- if (existing) {
- existing.config = w;
- existing.retireWhenIdle = false;
- if (applyEnabled) {
- // Reconcile runtime state with the (just-saved) persisted intent.
- // Disabling a busy worker drains it; (re-)enabling clears degraded.
- if (w.enabled) {
- existing.state = "enabled";
- existing.degraded = false;
- existing.consecutiveFailures = 0;
- } else if (existing.busy) {
- existing.state = "draining";
- } else {
- existing.state = "disabled";
+ // A REMOTE worker is N slots, not one — expanded HERE rather than by
+ // turning `busy` into a counter, which would touch every grant/drain/
+ // summary path for one worker kind. Slot 1 keeps the BASE id (so saved
+ // defaults, task labels and degrade marks against the configured id keep
+ // meaning what they meant); extra slots are `${id}#2..#N`, each an
+ // independently enable/drain/degrade-able entry sharing one remote
+ // config. Shrinking the count retires the surplus through the exact
+ // removed-worker path below (drain if busy, drop when idle).
+ const slotCount = w.kind === "remote" ? this.remoteSlotCount(w) : 1;
+ for (let slot = 1; slot <= slotCount; slot++) {
+ const cfg: Worker =
+ slot === 1
+ ? w
+ : { ...w, id: `${w.id}#${slot}`, name: `${w.name} #${slot}` };
+ seen.add(cfg.id);
+ const existing = this.entries.get(cfg.id);
+ if (existing) {
+ existing.config = cfg;
+ existing.retireWhenIdle = false;
+ if (applyEnabled) {
+ // Reconcile runtime state with the (just-saved) persisted intent.
+ // Disabling a busy worker drains it; (re-)enabling clears degraded.
+ if (w.enabled) {
+ existing.state = "enabled";
+ existing.degraded = false;
+ existing.consecutiveFailures = 0;
+ } else if (existing.busy) {
+ existing.state = "draining";
+ } else {
+ existing.state = "disabled";
+ }
}
+ // else: leave runtime state untouched (preserve operator overrides).
+ } else {
+ // A slot added by expansion inherits the base slot's RUNTIME state:
+ // a probe answering mid-run must not re-enable a remote the operator
+ // just disabled from the Workers page.
+ const base = slot > 1 ? this.entries.get(w.id) : undefined;
+ this.entries.set(cfg.id, {
+ config: cfg,
+ busy: false,
+ state: base
+ ? base.state === "draining"
+ ? "enabled"
+ : base.state
+ : w.enabled
+ ? "enabled"
+ : "disabled",
+ degraded: base?.degraded ?? false,
+ consecutiveFailures: 0,
+ retireWhenIdle: false,
+ });
}
- // else: leave runtime state untouched (preserve operator overrides).
- } else {
- this.entries.set(w.id, {
- config: w,
- busy: false,
- state: w.enabled ? "enabled" : "disabled",
- degraded: false,
- consecutiveFailures: 0,
- retireWhenIdle: false,
- });
}
}
// Workers no longer in settings: drop if idle, else retire when idle.
@@ -214,6 +244,30 @@ export class WorkerPool {
this.pump();
}
+ // How many slot entries one remote config expands to. An explicit
+ // remote.slots is the operator's number and is never second-guessed; absent,
+ // the TTL-cached probe of the remote's /api/worker/health answers (its
+ // enabled worker count — a payload that was already sent and discarded), and
+ // 1 stands in until the first probe lands. The probe is fired here,
+ // fire-and-forget, ONLY when the cache is stale — reconfigure() is
+ // synchronous and hot (every batch start), so it never waits on the network;
+ // a changed answer simply re-runs reconfigure with runtime state preserved.
+ private remoteSlotCount(w: Worker): number {
+ const explicit = w.remote?.slots;
+ if (typeof explicit === "number" && explicit >= 1) {
+ return Math.floor(explicit);
+ }
+ const cached = cachedRemoteSlots(w.id);
+ if (remoteCapacityStale(w.id)) {
+ void probeRemoteCapacity(w)
+ .then((slots) => {
+ if (slots !== null && slots !== cached) this.reconfigure();
+ })
+ .catch(() => {});
+ }
+ return cached ?? 1;
+ }
+
// Temporary pause: disable every worker (stop granting new leases; in-flight
// leases finish on their own), recording each worker's pre-pause state so
// resumeAll restores it exactly. Idempotent — a second call is a no-op.
diff --git a/common/lib/workers.ts b/common/lib/workers.ts
@@ -33,12 +33,23 @@ export type RemoteWorkerConfig = {
// When true the remote shares the transcripts mount, so we send
// {channelSlug, videoId} instead of uploading the audio bytes.
sharedFs?: boolean;
+ // How many units this remote takes in parallel. The pool expands one remote
+ // config into this many independently-schedulable slot entries at
+ // reconfigure time (the defaultWorkersFromApps trick, applied live). Absent =
+ // probed from the remote's /api/worker/health (its enabled worker count) —
+ // see controller/remoteCapacity.ts; 1 until the probe answers.
+ slots?: number;
};
// A worker is ONE processing slot — one transcription at a time. To run N in
// parallel on the same engine, define N workers (the settings editor's "Copy"
// button duplicates one). This makes each slot independently togglable on the
// Workers page (free up one core/GPU while the rest keep going).
+//
+// The one exception is a REMOTE worker with a slot count (remote.slots, or the
+// probed capacity): the pool expands it into N slot entries itself, because
+// asking the operator to hand-copy a remote once per slot of a machine whose
+// slot count the machine already reports would be busywork.
export type Worker = {
// Stable slug; used in settings, task ids, and logs.
id: string;
@@ -135,6 +146,10 @@ function sanitizeRemoteConfig(value: unknown): RemoteWorkerConfig {
if (typeof r.baseUrl === "string") cfg.baseUrl = r.baseUrl.trim();
if (typeof r.token === "string" && r.token.trim()) cfg.token = r.token.trim();
if (r.sharedFs === true) cfg.sharedFs = true;
+ // Clamped to a sane range; absent (the default) means "probe the remote".
+ if (typeof r.slots === "number" && Number.isFinite(r.slots) && r.slots >= 1) {
+ cfg.slots = Math.min(64, Math.floor(r.slots));
+ }
return cfg;
}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **A remote worker is now worth what the remote can actually do, not one slot.** The worker model's own header has promised since it shipped that workers "carry a priority and a slot count" — no such field existed, so a remote box running four workers of its own took one transcription at a time from here unless you hand-copied its card four times. A remote card now has **Slots**: set a number, or leave it blank and the remote is asked directly — its health endpoint has always returned its per-worker summary, and every caller read one boolean off it and threw the rest away. The pool expands one remote into that many independently-schedulable slots (each can be enabled, drained or degraded on its own on the Workers page), re-checks a blank-slots remote about once a minute with no timer and no waiting — a stale answer costs at most one slot for one minute — and shrinking the count retires the surplus through the same drain-then-drop path a removed worker takes.
- **A worker's tags now route work instead of being stored and ignored.** The worker model has carried a `tags` field since it shipped, documented as "stored but NOT consulted by the scheduler" — so a second machine could be *described* as CPU-only or diarization-only and the scheduler would hand it anything anyway. Tags are consulted now, under one written-down rule: a tag names an **operation** (`diarization`, `digest`, …) or a **resource** (`gpu`, `cpu`, `network`); work asks for what it needs; an untagged worker still takes anything, so an install that never touched tags routes exactly as it always has. The grant is matched rather than blind — a freed CPU slot now goes to the first waiter that can actually use it instead of sitting behind a GPU waiter at the head of the queue, which was a deadlock, not a slowdown. The settings form gained a Tags field per worker that warns (and only warns) about a tag the scheduler will never match, and the Workers page shows each worker's tags beside its state. Arming the speaker-work sweep was one unlabelled button meaning *every enabled operation, every channel* — and on this archive that includes speaker-names-from-the-transcript at **~1 model call per transcript chunk**, on the order of 194,000 calls. The one setting that would have bounded it, `sweepKinds`, has been honoured by the run since the sweep was written and settable by **nothing but a hand-edit of `settings.json`**; "diarization and names-from-the-audio only" had no expression anywhere in the product. The lane now opens on a console: tick which **operations** the run covers, each stating its own backlog *and what one unit of it costs*, and read **THE PLAN** underneath — the ordered channel itinerary the sweep will actually walk, built by the same counting the run does, with the position marker on the channel it is working now. Untick the expensive lane and the total falls, the rows re-sort, and the button re-labels itself. *Newest first, across all channels* stops being a phrase in a dropdown and becomes a consequence you can see.
- **The channel rows in the plan are the channel scope.** Rather than a second picker, **Choose channels** turns the itinerary into checkboxes — off by default, so the resting state is a clean list — and the button then says *Sweep 3 channels* instead of *Sweep every channel*. Scope, plan and progress are deliberately **one view rather than three**, because the alternative has a bug in it: the scope has to be written at the instant the sweep is armed. The boot hook re-launches a sweep from settings alone, so a scope saved separately (or saved and then not armed) comes back after a restart as the corpus-wide run it was meant to replace. The control that sets the scope is therefore the control that arms.
- **The digest sweep got the same console, and a scope it could not previously be given at all.** Its channel scope was reachable in the controller and unreachable from the application — the action that armed it took no arguments. It takes one now. It offers no operation list, because it *is* one operation and a permanently-ticked checkbox would imply a choice that does not exist.
diff --git a/editor/app/settings/components/WorkersField.tsx b/editor/app/settings/components/WorkersField.tsx
@@ -277,7 +277,11 @@ function WorkerCard({
</label>
)}
<span className="text-xs text-muted-foreground">
- {row.kind === "local" ? "Local · one slot" : "Remote · one slot"}
+ {row.kind === "local"
+ ? "Local · one slot"
+ : remote.slots !== undefined
+ ? `Remote · ${remote.slots} slot${remote.slots === 1 ? "" : "s"}`
+ : "Remote · slots probed from the remote"}
</span>
<TagsField
tags={row.tags ?? []}
@@ -379,6 +383,18 @@ function WorkerCard({
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"