Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
42 KiB
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 forfloor.*changes. - The worker is strictly serial; an in-flight download cannot be interrupted except by process death, after which
reclaim_stuck_jobsrequeues it andclear_staging_rootwipes 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_testonly (worker/tests/conftest.py); web tests run withDATABASE_URL=…/lyra_test(web/package.jsontestscript). Never point them atlyra. - Worker pytest is invoked as
.venv/bin/pytestfromworker/. Web tests:npm testfromweb/. - 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(theJobmodel, around line 49-62) - Create:
web/prisma/migrations/20260715000000_add_job_paused/migration.sql
Interfaces:
-
Produces: a
Job.paused BOOLEAN NOT NULL DEFAULT falsecolumn, and a regenerated Prisma client exposingpausedon 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):
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:
-- 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/:
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:
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
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]) -> booljob_delay_seconds(config: dict[str, str]) -> float(>= 0, invalid/absent → 0.0)kill_requested(config: dict[str, str]) -> boolconsume_kill_switch(conn, exit_fn=os._exit) -> bool— iffloor.killRequestedis set, clears it and callsexit_fn(0); returns whether it fired.
-
Step 1: Write the failing tests
Create worker/tests/test_floor.py:
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:
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
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_nextnever claims a job withpaused = true. -
Step 1: Write the failing test
Append to worker/tests/test_claim.py:
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:
SELECT id FROM "Job"
WHERE state = 'requested'
ORDER BY "createdAt" ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
to:
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
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_foreveraround lines 229-298)
Interfaces:
- Consumes:
press_paused,job_delay_seconds,consume_kill_switch(Task 2);Job.pausedclaim guard (Task 3). - Produces: the running worker honors
floor.paused(skip claiming),floor.jobDelaySeconds(wait between jobs), andfloor.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):
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):
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:
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:
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):
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:
# 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
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/floorbody{ action: "pause"|"resume"|"clear"|"stop" }→pause/resumesetfloor.paused;cleardeletes all Requests whose Job.state isrequested(returns{ ok, cleared });stopsetsfloor.pausedandfloor.killRequestedtrue.
-
Step 1: Write the failing tests
Create web/src/app/api/floor/route.test.ts:
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:
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
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]/pausebody{ paused: boolean }→ setsJob.paused; 400 unlessJob.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:
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:
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
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 notrequested; 404 if missing. -
Step 1: Write the failing tests
Create web/src/app/api/requests/[id]/route.test.ts:
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:
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
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(theDEFAULTSmap) - Modify:
web/src/app/api/monitor/config/route.test.ts(add a case) — create if absent - Modify:
web/src/app/settings/settings-form.tsx(theMONITOR_FIELDSlist, ~line 30-33)
Interfaces:
-
Consumes: the existing monitor-config GET/PATCH (generic Config allowlist upsert).
-
Produces:
floor.jobDelaySecondsis readable/writable via/api/monitor/configand editable on the Settings → Monitor tab. The worker readsfloor.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):
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:
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:
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
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(addpausedto 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.pausedin 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):
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
Rowtype andjobOfdefault inqueue.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("");:
const [paused, setPaused] = useState(false);
Extend refresh() to fetch floor state alongside the others:
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:
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):
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 ... />):
<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:
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):
.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
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— addrestart: unless-stoppedto theworkerservice.
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:
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:
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
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
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.