878eac066e
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1190 lines
42 KiB
Markdown
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.
|