From 878eac066e0866c761f3f1e5f643b86d5c4d13e0 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 15 Jul 2026 11:50:54 +0200 Subject: [PATCH] docs: implementation plan for Press controls + inter-job delay Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-15-press-controls.md | 1189 +++++++++++++++++ 1 file changed, 1189 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-press-controls.md diff --git a/docs/superpowers/plans/2026-07-15-press-controls.md b/docs/superpowers/plans/2026-07-15-press-controls.md new file mode 100644 index 0000000..e9607d6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-press-controls.md @@ -0,0 +1,1189 @@ +# 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.