# Press controls + inter-job delay — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add runtime control over the Floor queue ("the Press") — a configurable delay between downloads, per-item pause/remove, and whole-press pause/resume/clear/force-stop. **Architecture:** The worker is a single serial loop that reads Config fresh every iteration. Three new Config keys (`floor.paused`, `floor.jobDelaySeconds`, `floor.killRequested`) and one new `Job.paused` column drive behavior; pure helper predicates in a new `floor.py` gate the loop. Web adds a small `/api/floor` control endpoint plus per-item pause/remove routes, and the Floor UI renders the controls. Force-stop aborts the in-flight download by exiting the process; Docker's restart policy + the existing crash-reclaim path recover it. **Tech Stack:** Python 3.12 worker (psycopg, pytest), Next.js 15 web (Prisma, vitest route tests), Postgres. ## Global Constraints - Config is read fresh each worker loop iteration (`worker/lyra_worker/main.py:262`) — no restart needed for `floor.*` changes. - The worker is strictly serial; an in-flight download **cannot** be interrupted except by process death, after which `reclaim_stuck_jobs` requeues it and `clear_staging_root` wipes the partial download. - Remove/Clear are **cancel-for-now**: manual requests are deleted; monitored/wanted releases stay wanted and reappear on the monitor cadence. Permanent skip stays the per-release Ignore. - Pause is **downloads-only**: monitor/scan/discovery keep running while the press is paused. - Boolean Config values are the strings `"true"`/`"false"`; the worker's truthy set is `_TRUE = {"1","true","yes","on"}`. - Worker tests run against `lyra_test` only (`worker/tests/conftest.py`); web tests run with `DATABASE_URL=…/lyra_test` (`web/package.json` `test` script). Never point them at `lyra`. - Worker pytest is invoked as `.venv/bin/pytest` from `worker/`. Web tests: `npm test` from `web/`. - Commit messages end with: `Co-Authored-By: Claude Opus 4.8 (1M context) ` --- ### Task 1: Add `Job.paused` column (migration) **Files:** - Modify: `web/prisma/schema.prisma` (the `Job` model, around line 49-62) - Create: `web/prisma/migrations/20260715000000_add_job_paused/migration.sql` **Interfaces:** - Produces: a `Job.paused BOOLEAN NOT NULL DEFAULT false` column, and a regenerated Prisma client exposing `paused` on the Job model and as a relation filter. - [ ] **Step 1: Add the column to the Prisma schema** In `web/prisma/schema.prisma`, add a `paused` field to the `Job` model (place it after `downloadProgress`): ```prisma model Job { id String @id @default(cuid()) request Request @relation(fields: [requestId], references: [id], onDelete: Cascade) requestId String @unique state JobState @default(requested) currentStage String @default("intake") attempts Int @default(0) error String? claimedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt downloadProgress Float @default(0) paused Boolean @default(false) candidates Candidate[] } ``` - [ ] **Step 2: Hand-author the migration SQL** Create `web/prisma/migrations/20260715000000_add_job_paused/migration.sql`: ```sql -- AlterTable ALTER TABLE "Job" ADD COLUMN "paused" BOOLEAN NOT NULL DEFAULT false; ``` - [ ] **Step 3: Apply the migration to the test DB and regenerate the client** Run from `web/`: ```bash DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra_test npx prisma migrate deploy npx prisma generate ``` Expected: `migrate deploy` reports `1 migration ... applied`; `generate` reports the client was generated. - [ ] **Step 4: Verify the column exists** Run: ```bash DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra_test npx prisma db execute --stdin <<'SQL' SELECT column_name, data_type, column_default FROM information_schema.columns WHERE table_name='Job' AND column_name='paused'; SQL ``` Expected: one row: `paused | boolean | false`. - [ ] **Step 5: Commit** ```bash git add web/prisma/schema.prisma web/prisma/migrations/20260715000000_add_job_paused git commit -m "feat(db): add Job.paused column for per-item press pause Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 2: Worker `floor.py` — pause / delay / kill-switch helpers **Files:** - Create: `worker/lyra_worker/floor.py` - Create: `worker/tests/test_floor.py` **Interfaces:** - Produces: - `press_paused(config: dict[str, str]) -> bool` - `job_delay_seconds(config: dict[str, str]) -> float` (>= 0, invalid/absent → 0.0) - `kill_requested(config: dict[str, str]) -> bool` - `consume_kill_switch(conn, exit_fn=os._exit) -> bool` — if `floor.killRequested` is set, clears it and calls `exit_fn(0)`; returns whether it fired. - [ ] **Step 1: Write the failing tests** Create `worker/tests/test_floor.py`: ```python from lyra_worker.floor import ( press_paused, job_delay_seconds, kill_requested, consume_kill_switch, ) def test_press_paused_reads_truthy_values(): assert press_paused({"floor.paused": "true"}) is True assert press_paused({"floor.paused": "ON"}) is True assert press_paused({"floor.paused": "false"}) is False assert press_paused({}) is False def test_job_delay_seconds_parses_and_defaults(): assert job_delay_seconds({"floor.jobDelaySeconds": "30"}) == 30.0 assert job_delay_seconds({}) == 0.0 assert job_delay_seconds({"floor.jobDelaySeconds": "-5"}) == 0.0 assert job_delay_seconds({"floor.jobDelaySeconds": "junk"}) == 0.0 def test_kill_requested_reads_flag(): assert kill_requested({"floor.killRequested": "true"}) is True assert kill_requested({}) is False def test_consume_kill_switch_clears_flag_and_exits(conn): with conn.cursor() as cur: cur.execute( 'INSERT INTO "Config"(key, value, secret, "updatedAt") ' "VALUES ('floor.killRequested', 'true', false, now())" ) conn.commit() calls = [] fired = consume_kill_switch(conn, exit_fn=lambda code: calls.append(code)) assert fired is True assert calls == [0] with conn.cursor() as cur: cur.execute("SELECT value FROM \"Config\" WHERE key = 'floor.killRequested'") assert cur.fetchone()[0] == "false" def test_consume_kill_switch_noop_when_unset(conn): calls = [] fired = consume_kill_switch(conn, exit_fn=lambda code: calls.append(code)) assert fired is False assert calls == [] ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `.venv/bin/pytest tests/test_floor.py -q` Expected: FAIL — `ModuleNotFoundError: No module named 'lyra_worker.floor'`. - [ ] **Step 3: Implement `floor.py`** Create `worker/lyra_worker/floor.py`: ```python import os from lyra_worker.config import get_config _TRUE = {"1", "true", "yes", "on"} def press_paused(config: dict[str, str]) -> bool: """True when the whole press is paused — the worker stops claiming new jobs.""" return str(config.get("floor.paused", "")).strip().lower() in _TRUE def job_delay_seconds(config: dict[str, str]) -> float: """Seconds to wait between finishing one download and claiming the next. Invalid or absent -> 0.0 (back-to-back). Negatives are clamped to 0.""" try: return max(0.0, float(int(config.get("floor.jobDelaySeconds", "") or 0))) except (TypeError, ValueError): return 0.0 def kill_requested(config: dict[str, str]) -> bool: """True when a force-stop of the in-flight download has been requested.""" return str(config.get("floor.killRequested", "")).strip().lower() in _TRUE def consume_kill_switch(conn, exit_fn=os._exit) -> bool: """If a force-stop is pending, clear the flag and terminate the process (default os._exit) so the in-flight download is aborted. The container restart policy brings the worker back; startup clears staging and requeues the aborted job. Returns whether it fired. exit_fn is injectable for tests.""" config = get_config(conn) if not kill_requested(config): return False with conn.cursor() as cur: cur.execute( 'UPDATE "Config" SET value = \'false\', "updatedAt" = now() WHERE key = %s', ("floor.killRequested",), ) conn.commit() exit_fn(0) return True ``` - [ ] **Step 4: Run the tests to verify they pass** Run: `.venv/bin/pytest tests/test_floor.py -q` Expected: PASS (5 passed). - [ ] **Step 5: Commit** ```bash git add worker/lyra_worker/floor.py worker/tests/test_floor.py git commit -m "feat(worker): floor pause/delay/kill-switch helpers Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 3: `claim_next` skips paused jobs **Files:** - Modify: `worker/lyra_worker/claim.py:27-57` - Modify: `worker/tests/test_claim.py` **Interfaces:** - Consumes: `Job.paused` (Task 1). - Produces: `claim_next` never claims a job with `paused = true`. - [ ] **Step 1: Write the failing test** Append to `worker/tests/test_claim.py`: ```python def test_claim_skips_paused_jobs(conn): paused_job = insert_request(conn, album="Paused Album") with conn.cursor() as cur: cur.execute('UPDATE "Job" SET paused = true WHERE id = %s', (paused_job,)) conn.commit() unpaused_job = insert_request(conn, album="Live Album") claimed = claim_next(conn) assert claimed == unpaused_job # the paused one is skipped # With only the paused job left, nothing is claimable. assert claim_next(conn) is None ``` - [ ] **Step 2: Run the test to verify it fails** Run: `.venv/bin/pytest tests/test_claim.py::test_claim_skips_paused_jobs -q` Expected: FAIL — `claim_next` returns the paused job (assert mismatch) or claims it as the second call. - [ ] **Step 3: Add the paused guard to `claim_next`** In `worker/lyra_worker/claim.py`, change the SELECT in `claim_next` from: ```python SELECT id FROM "Job" WHERE state = 'requested' ORDER BY "createdAt" ASC FOR UPDATE SKIP LOCKED LIMIT 1 ``` to: ```python SELECT id FROM "Job" WHERE state = 'requested' AND NOT paused ORDER BY "createdAt" ASC FOR UPDATE SKIP LOCKED LIMIT 1 ``` - [ ] **Step 4: Run the claim tests to verify they pass** Run: `.venv/bin/pytest tests/test_claim.py -q` Expected: PASS (4 passed). - [ ] **Step 5: Commit** ```bash git add worker/lyra_worker/claim.py worker/tests/test_claim.py git commit -m "feat(worker): claim_next skips per-item paused jobs Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 4: Wire the main loop — pause gate, inter-job delay, kill watcher **Files:** - Modify: `worker/lyra_worker/main.py` (imports; `run_forever` around lines 229-298) **Interfaces:** - Consumes: `press_paused`, `job_delay_seconds`, `consume_kill_switch` (Task 2); `Job.paused` claim guard (Task 3). - Produces: the running worker honors `floor.paused` (skip claiming), `floor.jobDelaySeconds` (wait between jobs), and `floor.killRequested` (abort in-flight download via process exit). This task is loop wiring; it is verified by the Task 2/3 unit tests plus a manual smoke check, not a new unit test. - [ ] **Step 1: Import the helpers and `wait_for_db`** In `worker/lyra_worker/main.py`, add to the imports near the top (after the existing `from lyra_worker.db import wait_for_db` line — it is already imported): ```python from lyra_worker.floor import press_paused, job_delay_seconds, consume_kill_switch ``` - [ ] **Step 2: Add the kill-switch watcher daemon** In `main.py`, add this function next to `_liveness_heartbeat` (after it, ~line 227): ```python KILL_POLL_SECONDS = 1.5 def _kill_switch_watch() -> None: """Poll floor.killRequested on a background daemon thread (its own DB connection) so a force-stop aborts the process even while the main loop is blocked in a download. On any DB hiccup, reconnect and keep watching.""" conn = wait_for_db() while True: try: consume_kill_switch(conn) except Exception: try: conn.close() except Exception: pass conn = wait_for_db() time.sleep(KILL_POLL_SECONDS) ``` - [ ] **Step 3: Start the watcher thread in `run_forever`** In `run_forever`, next to the existing heartbeat thread start (`main.py:231`), add a second daemon thread: ```python threading.Thread(target=_liveness_heartbeat, daemon=True).start() threading.Thread(target=_kill_switch_watch, daemon=True).start() ``` - [ ] **Step 4: Add a delay tracker before the loop** In `run_forever`, next to the other `last_*` trackers (`main.py:251-254`), add: ```python next_job_allowed = 0.0 # monotonic time before which no new job is claimed (inter-job delay) ``` - [ ] **Step 5: Gate the claim on pause + delay, and set the delay after finishing** In the loop body, replace the claim/run/finish block (currently `main.py:286-298`): ```python job_id = claim_next(conn) if job_id is None: time.sleep(IDLE_SLEEP) continue print(f"worker: claimed job {job_id}", flush=True) try: run_pipeline(conn, job_id, adapters, resolver=resolver, tagger=tagger, staging_root=STAGING_ROOT, upgrade_cutoff=mcfg.quality_cutoff) except Exception as e: # one bad job must not take down the worker loop print(f"worker: pipeline failed for job {job_id}: {e}", flush=True) conn.rollback() finish_job(conn, job_id, mcfg) print(f"worker: finished job {job_id}", flush=True) ``` with: ```python # Press paused (downloads only) or the inter-job delay hasn't elapsed: keep the # loop alive (monitor/scan/discovery above still run) but claim nothing. if press_paused(config) or now < next_job_allowed: time.sleep(IDLE_SLEEP) continue job_id = claim_next(conn) if job_id is None: time.sleep(IDLE_SLEEP) continue print(f"worker: claimed job {job_id}", flush=True) try: run_pipeline(conn, job_id, adapters, resolver=resolver, tagger=tagger, staging_root=STAGING_ROOT, upgrade_cutoff=mcfg.quality_cutoff) except Exception as e: # one bad job must not take down the worker loop print(f"worker: pipeline failed for job {job_id}: {e}", flush=True) conn.rollback() finish_job(conn, job_id, mcfg) print(f"worker: finished job {job_id}", flush=True) next_job_allowed = time.monotonic() + job_delay_seconds(config) ``` - [ ] **Step 6: Verify the whole worker suite still passes** Run from `worker/`: `.venv/bin/pytest -q -k "not live"` Expected: PASS (all offline tests, including `test_floor.py` and `test_claim.py`). - [ ] **Step 7: Smoke-check the loop wiring imports cleanly** Run from `worker/`: `.venv/bin/python -c "import lyra_worker.main"` Expected: no output, exit 0 (module imports without error). - [ ] **Step 8: Commit** ```bash git add worker/lyra_worker/main.py git commit -m "feat(worker): pause gate, inter-job delay, and force-stop watcher in main loop Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 5: Web `GET/POST /api/floor` **Files:** - Create: `web/src/app/api/floor/route.ts` - Create: `web/src/app/api/floor/route.test.ts` **Interfaces:** - Produces: - `GET /api/floor` → `{ paused: boolean }` - `POST /api/floor` body `{ action: "pause"|"resume"|"clear"|"stop" }` → `pause`/`resume` set `floor.paused`; `clear` deletes all Requests whose Job.state is `requested` (returns `{ ok, cleared }`); `stop` sets `floor.paused` and `floor.killRequested` true. - [ ] **Step 1: Write the failing tests** Create `web/src/app/api/floor/route.test.ts`: ```typescript import { describe, it, expect } from "vitest"; import { GET, POST } from "./route"; import { prisma } from "@/lib/db"; function post(action: string) { return new Request("http://localhost/api/floor", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action }), }); } async function flag(key: string) { return (await prisma.config.findUnique({ where: { key } }))?.value ?? null; } async function queuedRequest(album: string, state = "requested") { return prisma.request.create({ data: { artist: "A", album, status: "pending", job: { create: { state: state as never } } }, include: { job: true }, }); } describe("floor API", () => { it("GET reports paused=false by default", async () => { const res = await GET(); expect(await res.json()).toEqual({ paused: false }); }); it("pause and resume toggle floor.paused", async () => { await POST(post("pause")); expect(await flag("floor.paused")).toBe("true"); const paused = await (await GET()).json(); expect(paused).toEqual({ paused: true }); await POST(post("resume")); expect(await flag("floor.paused")).toBe("false"); }); it("stop sets both paused and killRequested", async () => { const res = await POST(post("stop")); expect(res.status).toBe(200); expect(await flag("floor.paused")).toBe("true"); expect(await flag("floor.killRequested")).toBe("true"); }); it("clear deletes only queued (requested) requests", async () => { const queued = await queuedRequest("Queued"); const active = await queuedRequest("Downloading", "downloading"); const res = await POST(post("clear")); const body = await res.json(); expect(body.cleared).toBe(1); expect(await prisma.request.findUnique({ where: { id: queued.id } })).toBeNull(); expect(await prisma.request.findUnique({ where: { id: active.id } })).not.toBeNull(); }); it("rejects an unknown action", async () => { const res = await POST(post("nope")); expect(res.status).toBe(400); }); }); ``` - [ ] **Step 2: Run the tests to verify they fail** Run from `web/`: `npm test -- src/app/api/floor/route.test.ts` Expected: FAIL — cannot import `./route` (module missing). - [ ] **Step 3: Implement the route** Create `web/src/app/api/floor/route.ts`: ```typescript import { prisma } from "@/lib/db"; async function getFlag(key: string): Promise { const row = await prisma.config.findUnique({ where: { key } }); return row?.value === "true"; } async function setFlag(key: string, value: boolean): Promise { const v = value ? "true" : "false"; await prisma.config.upsert({ where: { key }, create: { key, value: v, secret: false }, update: { value: v }, }); } export async function GET() { return Response.json({ paused: await getFlag("floor.paused") }); } export async function POST(request: Request) { let body: { action?: string }; try { body = (await request.json()) as { action?: string }; } catch { return Response.json({ error: "invalid JSON" }, { status: 400 }); } switch (body.action) { case "pause": await setFlag("floor.paused", true); return Response.json({ ok: true, paused: true }); case "resume": await setFlag("floor.paused", false); return Response.json({ ok: true, paused: false }); case "stop": await setFlag("floor.paused", true); await setFlag("floor.killRequested", true); return Response.json({ ok: true, paused: true, stopped: true }); case "clear": { const { count } = await prisma.request.deleteMany({ where: { job: { is: { state: "requested" } } }, }); return Response.json({ ok: true, cleared: count }); } default: return Response.json({ error: "unknown action" }, { status: 400 }); } } ``` - [ ] **Step 4: Run the tests to verify they pass** Run from `web/`: `npm test -- src/app/api/floor/route.test.ts` Expected: PASS (5 passed). - [ ] **Step 5: Commit** ```bash git add web/src/app/api/floor/route.ts web/src/app/api/floor/route.test.ts git commit -m "feat(web): /api/floor pause/resume/clear/stop controls Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 6: Web `POST /api/requests/[id]/pause` **Files:** - Create: `web/src/app/api/requests/[id]/pause/route.ts` - Create: `web/src/app/api/requests/[id]/pause/route.test.ts` **Interfaces:** - Produces: `POST /api/requests/[id]/pause` body `{ paused: boolean }` → sets `Job.paused`; 400 unless `Job.state === "requested"`; 404 if the request/job is missing. - [ ] **Step 1: Write the failing tests** Create `web/src/app/api/requests/[id]/pause/route.test.ts`: ```typescript import { describe, it, expect } from "vitest"; import { POST } from "./route"; import { prisma } from "@/lib/db"; import type { Prisma } from "@prisma/client"; function req(id: string, paused: boolean) { return new Request(`http://localhost/api/requests/${id}/pause`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ paused }), }); } async function seed(jobData: Prisma.JobCreateWithoutRequestInput) { return prisma.request.create({ data: { artist: "A", album: "B", status: "pending", job: { create: jobData } }, include: { job: true }, }); } describe("per-item pause API", () => { it("pauses a queued job", async () => { const created = await seed({ state: "requested" }); const res = await POST(req(created.id, true), { params: Promise.resolve({ id: created.id }) }); expect(res.status).toBe(200); const job = await prisma.job.findUnique({ where: { id: created.job!.id } }); expect(job?.paused).toBe(true); }); it("resumes a paused job", async () => { const created = await seed({ state: "requested", paused: true }); await POST(req(created.id, false), { params: Promise.resolve({ id: created.id }) }); const job = await prisma.job.findUnique({ where: { id: created.job!.id } }); expect(job?.paused).toBe(false); }); it("rejects pausing an in-flight job", async () => { const created = await seed({ state: "downloading" }); const res = await POST(req(created.id, true), { params: Promise.resolve({ id: created.id }) }); expect(res.status).toBe(400); }); it("404s for a missing request", async () => { const res = await POST(req("nope", true), { params: Promise.resolve({ id: "nope" }) }); expect(res.status).toBe(404); }); }); ``` - [ ] **Step 2: Run the tests to verify they fail** Run from `web/`: `npm test -- src/app/api/requests/\\[id\\]/pause/route.test.ts` Expected: FAIL — module missing. - [ ] **Step 3: Implement the route** Create `web/src/app/api/requests/[id]/pause/route.ts`: ```typescript import { prisma } from "@/lib/db"; export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; let body: { paused?: unknown }; try { body = (await request.json()) as { paused?: unknown }; } catch { return Response.json({ error: "invalid JSON" }, { status: 400 }); } if (typeof body.paused !== "boolean") { return Response.json({ error: "paused must be a boolean" }, { status: 400 }); } const req = await prisma.request.findUnique({ where: { id }, include: { job: true } }); if (!req || !req.job) { return Response.json({ error: "not found" }, { status: 404 }); } if (req.job.state !== "requested") { return Response.json({ error: "only queued jobs can be paused" }, { status: 400 }); } await prisma.job.update({ where: { id: req.job.id }, data: { paused: body.paused } }); return Response.json({ ok: true, paused: body.paused }); } ``` - [ ] **Step 4: Run the tests to verify they pass** Run from `web/`: `npm test -- src/app/api/requests/\\[id\\]/pause/route.test.ts` Expected: PASS (4 passed). - [ ] **Step 5: Commit** ```bash git add "web/src/app/api/requests/[id]/pause" git commit -m "feat(web): per-item pause route for queued jobs Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 7: Web `DELETE /api/requests/[id]` (remove) **Files:** - Create: `web/src/app/api/requests/[id]/route.ts` - Create: `web/src/app/api/requests/[id]/route.test.ts` **Interfaces:** - Produces: `DELETE /api/requests/[id]` → deletes the Request (cascades Job + Candidates); 400 if the job is not `requested`; 404 if missing. - [ ] **Step 1: Write the failing tests** Create `web/src/app/api/requests/[id]/route.test.ts`: ```typescript import { describe, it, expect } from "vitest"; import { DELETE } from "./route"; import { prisma } from "@/lib/db"; import type { Prisma } from "@prisma/client"; function del(id: string) { return new Request(`http://localhost/api/requests/${id}`, { method: "DELETE" }); } async function seed(jobData: Prisma.JobCreateWithoutRequestInput) { return prisma.request.create({ data: { artist: "A", album: "B", status: "pending", job: { create: jobData } }, include: { job: true }, }); } describe("remove request API", () => { it("deletes a queued request and cascades its job", async () => { const created = await seed({ state: "requested" }); const res = await DELETE(del(created.id), { params: Promise.resolve({ id: created.id }) }); expect(res.status).toBe(200); expect(await prisma.request.findUnique({ where: { id: created.id } })).toBeNull(); expect(await prisma.job.findUnique({ where: { id: created.job!.id } })).toBeNull(); }); it("refuses to remove an in-flight job", async () => { const created = await seed({ state: "downloading" }); const res = await DELETE(del(created.id), { params: Promise.resolve({ id: created.id }) }); expect(res.status).toBe(400); expect(await prisma.request.findUnique({ where: { id: created.id } })).not.toBeNull(); }); it("404s for a missing request", async () => { const res = await DELETE(del("nope"), { params: Promise.resolve({ id: "nope" }) }); expect(res.status).toBe(404); }); }); ``` - [ ] **Step 2: Run the tests to verify they fail** Run from `web/`: `npm test -- src/app/api/requests/\\[id\\]/route.test.ts` Expected: FAIL — module missing. - [ ] **Step 3: Implement the route** Create `web/src/app/api/requests/[id]/route.ts`: ```typescript import { prisma } from "@/lib/db"; export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const req = await prisma.request.findUnique({ where: { id }, include: { job: true } }); if (!req) { return Response.json({ error: "not found" }, { status: 404 }); } if (req.job && req.job.state !== "requested") { return Response.json({ error: "only queued items can be removed" }, { status: 400 }); } await prisma.request.delete({ where: { id } }); // cascades Job + Candidates return Response.json({ ok: true }); } ``` - [ ] **Step 4: Run the tests to verify they pass** Run from `web/`: `npm test -- src/app/api/requests/\\[id\\]/route.test.ts` Expected: PASS (3 passed). - [ ] **Step 5: Commit** ```bash git add "web/src/app/api/requests/[id]/route.ts" "web/src/app/api/requests/[id]/route.test.ts" git commit -m "feat(web): DELETE /api/requests/[id] to remove a queued item Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 8: Inter-job delay setting (config route + Settings form) **Files:** - Modify: `web/src/app/api/monitor/config/route.ts` (the `DEFAULTS` map) - Modify: `web/src/app/api/monitor/config/route.test.ts` (add a case) — create if absent - Modify: `web/src/app/settings/settings-form.tsx` (the `MONITOR_FIELDS` list, ~line 30-33) **Interfaces:** - Consumes: the existing monitor-config GET/PATCH (generic Config allowlist upsert). - Produces: `floor.jobDelaySeconds` is readable/writable via `/api/monitor/config` and editable on the Settings → Monitor tab. The worker reads `floor.jobDelaySeconds` (Task 2/4). - [ ] **Step 1: Write/extend the failing test** Add to `web/src/app/api/monitor/config/route.test.ts` (create the file with this content if it does not exist): ```typescript import { describe, it, expect } from "vitest"; import { GET, PATCH } from "./route"; import { prisma } from "@/lib/db"; describe("monitor config — floor.jobDelaySeconds", () => { it("defaults jobDelaySeconds to 0 and round-trips a new value", async () => { const before = await (await GET()).json(); expect(before["floor.jobDelaySeconds"]).toBe("0"); await PATCH( new Request("http://localhost/api/monitor/config", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ "floor.jobDelaySeconds": "45" }), }), ); const stored = await prisma.config.findUnique({ where: { key: "floor.jobDelaySeconds" } }); expect(stored?.value).toBe("45"); const after = await (await GET()).json(); expect(after["floor.jobDelaySeconds"]).toBe("45"); }); }); ``` - [ ] **Step 2: Run to verify it fails** Run from `web/`: `npm test -- src/app/api/monitor/config/route.test.ts` Expected: FAIL — `before["floor.jobDelaySeconds"]` is `undefined`, not `"0"`. - [ ] **Step 3: Add the key to the config route DEFAULTS** In `web/src/app/api/monitor/config/route.ts`, add the key to `DEFAULTS`: ```typescript const DEFAULTS: Record = { "monitor.enabled": "false", "monitor.pollIntervalHours": "24", "monitor.retryIntervalHours": "6", "monitor.qualityCutoff": "2", "monitor.upgradeWindowDays": "14", "floor.jobDelaySeconds": "0", }; ``` - [ ] **Step 4: Run to verify the test passes** Run from `web/`: `npm test -- src/app/api/monitor/config/route.test.ts` Expected: PASS. - [ ] **Step 5: Add the field to the Settings Monitor tab** In `web/src/app/settings/settings-form.tsx`, extend the `MONITOR_FIELDS` list (currently around line 30-33) to include the delay field: ```typescript const MONITOR_FIELDS: [string, string][] = [ ["monitor.pollIntervalHours", "Poll interval (hours)"], ["monitor.retryIntervalHours", "Retry interval (hours)"], ["monitor.qualityCutoff", "Quality cutoff"], ["monitor.upgradeWindowDays", "Upgrade window (days)"], ["floor.jobDelaySeconds", "Delay between downloads (seconds)"], ]; ``` (No other change needed — the Monitor tab already maps `MONITOR_FIELDS` (declared as `const MONITOR_FIELDS: [string, string][]` at ~line 28) to number inputs bound to `monitorCfg` and saves via `PATCH /api/monitor/config`.) - [ ] **Step 6: Verify the settings form compiles** Run from `web/`: `npm run build 2>&1 | tail -5` (or `npx tsc --noEmit` if faster). Expected: no type errors. - [ ] **Step 7: Commit** ```bash git add web/src/app/api/monitor/config/route.ts web/src/app/api/monitor/config/route.test.ts web/src/app/settings/settings-form.tsx git commit -m "feat(web): inter-job download delay setting (Settings → Monitor) Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 9: Floor UI — press controls, per-item controls, paused surfacing **Files:** - Modify: `web/src/app/api/requests/route.ts` (add `paused` to the job projection, ~line 60-70) - Modify: `web/src/app/queue.tsx` **Interfaces:** - Consumes: `GET /api/floor`, `POST /api/floor`, `POST /api/requests/[id]/pause`, `DELETE /api/requests/[id]` (Tasks 5-7); `Job.paused` (Task 1). - Produces: Floor header controls (Pause/Resume, Clear queue, Stop now) + a paused banner; per-item Pause/Resume/Remove on queued rows. UI is verified by driving the running app (Task 10 / the `verify` skill), not a unit test — this repo has no React component test harness. - [ ] **Step 1: Surface `Job.paused` in the requests API** In `web/src/app/api/requests/route.ts`, add `paused` to the returned job object (inside the `job: r.job ? (() => { ... return { ... }; })()` block): ```typescript return { state: r.job.state, currentStage: r.job.currentStage, attempts: r.job.attempts, error: r.job.error, claimedAt: r.job.claimedAt, updatedAt: r.job.updatedAt, downloadProgress: r.job.downloadProgress, paused: r.job.paused, candidateCount: cands.length, chosen: chosen ? { source: chosen.source, format: chosen.format, trackCount: chosen.trackCount } : null, }; ``` - [ ] **Step 2: Extend the `Row` type and `jobOf` default in `queue.tsx`** In `web/src/app/queue.tsx`, add `paused: boolean;` to the `Row.job` type (after `downloadProgress: number;`) and to the `jobOf` fallback object (add `paused: false,`). - [ ] **Step 3: Add floor state + control handlers** In the `Queue` component, add a `paused` state and fetch it in `refresh`, plus handlers. After `const [album, setAlbum] = useState("");`: ```typescript const [paused, setPaused] = useState(false); ``` Extend `refresh()` to fetch floor state alongside the others: ```typescript async function refresh() { const [reqRes, wantRes, artRes, floorRes] = await Promise.all([ fetch("/api/requests"), fetch("/api/wanted"), fetch("/api/artists"), fetch("/api/floor"), ]); setRows((await reqRes.json()).requests ?? []); setWanted(((await wantRes.json()).wanted ?? []).length); setWatching(((await artRes.json()).artists ?? []).length); setPaused((await floorRes.json()).paused ?? false); } ``` Add these handlers next to `retry`: ```typescript async function floorAction(action: string, note: string) { const res = await fetch("/api/floor", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action }), }).catch(() => null); if (!res?.ok) { toast("Couldn't update the press"); return; } toast(note); refresh(); } async function clearPress() { const queued = rows.filter((r) => jobOf(r).state === "requested").length; if (queued === 0) return; if (!confirm(`Clear ${queued} queued item${queued === 1 ? "" : "s"} from the press?`)) return; await floorAction("clear", "Cleared the queue"); } async function stopPress() { if (!confirm("Stop the current download and pause the press? The worker restarts and the aborted album re-downloads when you resume.")) return; await floorAction("stop", "Stopping the press…"); } async function pauseItem(id: string, next: boolean) { const res = await fetch(`/api/requests/${id}/pause`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ paused: next }), }).catch(() => null); if (!res?.ok) { toast("Couldn't update the item"); return; } refresh(); } async function removeItem(id: string) { const res = await fetch(`/api/requests/${id}`, { method: "DELETE" }).catch(() => null); if (!res?.ok) { toast("Couldn't remove the item"); return; } toast("Removed from the press"); refresh(); } ``` - [ ] **Step 4: Render the press header controls** Just below the `` line, add a controls bar. Compute whether a download is active first (place near the `active`/`pressed` computations): ```typescript const downloading = rows.some((r) => { const s = jobOf(r).state; return s === "matching" || s === "matched" || s === "downloading" || s === "tagging"; }); ``` Then the controls JSX (after the ``): ```tsx
{paused ? ( ) : ( )} {downloading ? ( ) : null} {paused ? Press paused : null}
``` - [ ] **Step 5: Render per-item controls on queued rows** In the `active.map((r) => { ... })` body, the `note` is currently only set for `attention`. Extend it so queued rows get pause/remove buttons. Replace the `const note = attention ? (...) : undefined;` block with: ```tsx const note = attention ? ( <> {j.error?.trim() || "No source met the quality cutoff, or the pipeline stalled."}{" "} ) : j.state === "requested" ? ( {j.paused ? ( <> Paused{" "} ) : ( )}{" "} ) : undefined; ``` (Because `JobRow` renders `note` when present and otherwise the `bar`, and queued rows have no `bar`, this shows the controls on queued rows without disturbing downloading/attention rows.) - [ ] **Step 6: Add minimal styling** In `web/src/app/design.css`, add (near the other `.floor`/`.btn` rules): ```css .press-controls { display: flex; gap: 0.5rem; align-items: center; margin: 0 0 1rem; flex-wrap: wrap; } .item-controls { display: inline-flex; gap: 0.5rem; align-items: center; } ``` - [ ] **Step 7: Build to verify no type/compile errors** Run from `web/`: `npm run build 2>&1 | tail -8` Expected: build succeeds (no TypeScript errors). - [ ] **Step 8: Commit** ```bash git add web/src/app/queue.tsx web/src/app/api/requests/route.ts web/src/app/design.css git commit -m "feat(web): Floor press + per-item controls (pause/resume/clear/stop/remove) Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 10: Ops restart policy, deploy, and live verification **Files:** - Modify (ops repo, on the server): `/opt/git/vm-download/docker/lyra/compose.yaml` — add `restart: unless-stopped` to the `worker` service. **Interfaces:** - Consumes: everything above. - Produces: the feature deployed to prod; force-stop recovers the worker via the restart policy. - [ ] **Step 1: Run the full worker + web test suites** From `worker/`: `.venv/bin/pytest -q -k "not live"` → all pass. From `web/`: `npm test` → all pass. - [ ] **Step 2: Add the worker restart policy in the ops repo** On the server (`ssh download`), edit `/opt/git/vm-download/docker/lyra/compose.yaml`, adding to the `worker:` service: ```yaml restart: unless-stopped ``` Commit it in that repo (its own conventions). Verify the local `docker-compose.yml` in this repo already has `restart: unless-stopped` on `worker` (it does — no change needed here). - [ ] **Step 3: Build and push the images** From this repo root: ```bash sha=$(git rev-parse --short HEAD) docker build -q -t git.jger.nl/jonathan/lyra-worker:latest -t git.jger.nl/jonathan/lyra-worker:$sha worker docker build -q -t git.jger.nl/jonathan/lyra-web:latest -t git.jger.nl/jonathan/lyra-web:$sha web docker push git.jger.nl/jonathan/lyra-worker:latest && docker push git.jger.nl/jonathan/lyra-worker:$sha docker push git.jger.nl/jonathan/lyra-web:latest && docker push git.jger.nl/jonathan/lyra-web:$sha ``` (Web changed this time, so both images ship. The web container runs `prisma migrate deploy` on startup, applying the `Job.paused` migration.) - [ ] **Step 4: Deploy on the server** ```bash ssh download "cd /opt/git/vm-download/docker/lyra && docker compose pull web worker && docker compose up -d web worker" ``` - [ ] **Step 5: Verify the migration and restart policy landed** ```bash ssh download "docker inspect lyra-worker-1 --format 'restart={{.HostConfig.RestartPolicy.Name}}'" ssh download "docker exec lyra-db-1 psql -U lyra -d lyra -tAc \"SELECT column_name FROM information_schema.columns WHERE table_name='Job' AND column_name='paused'\"" ``` Expected: `restart=unless-stopped`; and `paused` printed (column exists in prod). - [ ] **Step 6: Drive the feature end-to-end (verify skill)** Use the `verify` / `run` skill against the live UI (or curl): - `GET /api/floor` → `{paused:false}`. - Pause the press, confirm the worker stops claiming (a queued item stays `requested`); resume, confirm it drains. - Pause a single queued item; confirm it is skipped while an unpaused later item is claimed. - Remove a queued item; confirm it disappears. - Set a delay (Settings → Monitor → "Delay between downloads"), confirm jobs space out. - With a download active, click Stop now; confirm the worker restarts (new `StartedAt`), staging is cleared, and the press comes back paused. - [ ] **Step 7: Update the roadmap memory** Append to the project-state memory that press controls + inter-job delay shipped, noting the ops restart-policy change and that force-stop relies on it. --- ## Self-review notes - **Spec coverage:** delay (Tasks 2/4/8), per-item pause (Tasks 1/3/6/9), per-item remove (Tasks 7/9), press pause/resume (Tasks 4/5/9), clear (Tasks 5/9), force-stop (Tasks 2/4/5/9) + restart policy (Task 10). All spec sections mapped. - **Cancel-for-now / pause-downloads-only / active-item-force-kill** decisions honored in Tasks 4, 5, 7. - **Migration auto-applies** on deploy via the web entrypoint; Task 10 verifies it in prod.