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

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 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):

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]) -> 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:

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_next never claims a job with paused = 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_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):

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/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:

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]/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:

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 not requested; 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 (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):

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 (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):

            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("");:

  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 — 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:

    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.