Files
Lyra/docs/superpowers/plans/2026-07-15-press-controls.md
Jonathan 878eac066e docs: implementation plan for Press controls + inter-job delay
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:50:54 +02:00

1190 lines
42 KiB
Markdown

# 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) <noreply@anthropic.com>`
---
### 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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<boolean> {
const row = await prisma.config.findUnique({ where: { key } });
return row?.value === "true";
}
async function setFlag(key: string, value: boolean): Promise<void> {
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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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<string, string> = {
"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) <noreply@anthropic.com>"
```
---
### 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 `<SectionHeader title="On the Press" ... />` 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 `<SectionHeader ... />`):
```tsx
<div className="press-controls">
{paused ? (
<button className="btn sm accent" onClick={() => floorAction("resume", "Press resumed")}>
Resume the press
</button>
) : (
<button className="btn sm ghost" onClick={() => floorAction("pause", "Press paused")}>
Pause the press
</button>
)}
<button className="btn sm ghost" onClick={clearPress}>
Clear queue
</button>
{downloading ? (
<button className="btn sm" onClick={stopPress}>
Stop now
</button>
) : null}
{paused ? <span className="chip">Press paused</span> : null}
</div>
```
- [ ] **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."}{" "}
<button className="btn sm" onClick={() => retry(r.id)}>
Retry
</button>
</>
) : j.state === "requested" ? (
<span className="item-controls">
{j.paused ? (
<>
<span className="chip">Paused</span>{" "}
<button className="btn sm ghost" onClick={() => pauseItem(r.id, false)}>
Resume
</button>
</>
) : (
<button className="btn sm ghost" onClick={() => pauseItem(r.id, true)}>
Pause
</button>
)}{" "}
<button className="btn sm ghost" onClick={() => removeItem(r.id)}>
Remove
</button>
</span>
) : 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) <noreply@anthropic.com>"
```
---
### 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.