Files
Lyra/docs/superpowers/plans/2026-07-10-lyra-walking-skeleton.md
Jonathan d939f83ce3 chore: relax worker dep pins for local Python 3.14
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:28:34 +02:00

1087 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Lyra Walking Skeleton 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:** Stand up the end-to-end Lyra architecture — a Next.js UI/API, a Python worker, and Postgres — where a user submits an album request, the worker claims the resulting job and drives it through the full state machine via a *fake* pipeline, and the UI shows live progress. Zero source integration.
**Architecture:** Three Docker Compose services — `web` (Next.js App Router, owns the Prisma schema), `worker` (Python poll-loop service), and `db` (Postgres). The web app writes `Request` + `Job` rows; the worker claims jobs with `SELECT … FOR UPDATE SKIP LOCKED` and advances each through the state machine. Communication is entirely through Postgres, exactly as slices 24 will use it.
**Tech Stack:** Next.js 15 (App Router, TypeScript, React 19), Prisma 6, PostgreSQL 17, Python 3.12 with psycopg 3. Tests: Vitest 2 (web API), pytest 8 (worker).
## Global Constraints
- Node.js 22 LTS; Next.js App Router (not Pages Router); TypeScript strict mode on.
- Python 3.12 in the worker container (`python:3.12-slim`); local test runs may use a newer 3.x — keep worker deps version-flexible so they install on both. Database access via `psycopg` v3 (not psycopg2); raw SQL only in the worker.
- **Prisma is the single owner of the database schema.** The worker never issues DDL; it reads/writes tables Prisma migrated. Worker tests assume `prisma migrate deploy` has already been run against the target database.
- PostgreSQL 17.
- One `DATABASE_URL` (Postgres connection string) is shared by both services via environment.
- Job state values (exact): `requested`, `matching`, `matched`, `downloading`, `tagging`, `imported`, `needs_attention`.
- Request status values (exact): `pending`, `completed`, `needs_attention`.
- Monorepo layout: `web/` (Next.js), `worker/` (Python), root holds `docker-compose.yml` and `.env.example`.
- Every task ends with a commit.
---
### Task 1: Monorepo scaffold + Docker Compose
**Files:**
- Create: `web/package.json`
- Create: `web/tsconfig.json`
- Create: `web/next.config.ts`
- Create: `web/src/app/layout.tsx`
- Create: `web/src/app/page.tsx`
- Create: `worker/requirements.txt`
- Create: `worker/lyra_worker/__init__.py`
- Create: `worker/tests/__init__.py`
- Create: `worker/pytest.ini`
- Create: `docker-compose.yml`
- Create: `.env.example`
**Interfaces:**
- Consumes: nothing (first task).
- Produces: a runnable Next.js app at `web/`, an importable Python package `lyra_worker`, and a Compose stack with services `web`, `worker`, `db` sharing `DATABASE_URL`.
- [ ] **Step 1: Create the Next.js app manifest and config**
`web/package.json`:
```json
{
"name": "lyra-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "vitest run"
},
"dependencies": {
"next": "15.1.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"@prisma/client": "6.1.0"
},
"devDependencies": {
"typescript": "5.7.2",
"@types/node": "22.10.2",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"prisma": "6.1.0",
"vitest": "2.1.8"
}
}
```
`web/tsconfig.json`:
```json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
```
`web/next.config.ts`:
```ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
```
- [ ] **Step 2: Create a minimal placeholder UI so the app builds**
`web/src/app/layout.tsx`:
```tsx
export const metadata = { title: "Lyra" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
```
`web/src/app/page.tsx`:
```tsx
export default function Home() {
return <main>Lyra</main>;
}
```
- [ ] **Step 3: Create the Python worker package skeleton**
`worker/requirements.txt`:
```
psycopg[binary]>=3.2,<4
pytest>=8.3,<9
```
`worker/lyra_worker/__init__.py`:
```python
"""Lyra acquisition worker."""
__version__ = "0.1.0"
```
`worker/tests/__init__.py`:
```python
```
`worker/pytest.ini`:
```ini
[pytest]
testpaths = tests
```
- [ ] **Step 4: Create the Compose stack and env template**
`.env.example`:
```
POSTGRES_USER=lyra
POSTGRES_PASSWORD=lyra
POSTGRES_DB=lyra
DATABASE_URL=postgresql://lyra:lyra@db:5432/lyra
```
`docker-compose.yml`:
```yaml
services:
db:
image: postgres:17
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
web:
build: ./web
environment:
DATABASE_URL: ${DATABASE_URL}
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
worker:
build: ./worker
environment:
DATABASE_URL: ${DATABASE_URL}
depends_on:
db:
condition: service_healthy
volumes:
postgres-data:
```
- [ ] **Step 5: Verify the scaffold builds**
Run:
```bash
cp .env.example .env
cd web && npm install && npm run build
cd ../worker && python -m pip install -r requirements.txt && python -c "import lyra_worker; print(lyra_worker.__version__)"
```
Expected: `next build` completes without errors; the Python import prints `0.1.0`.
- [ ] **Step 6: Verify Compose config is valid**
Run: `docker compose config -q && echo OK`
Expected: prints `OK` (no schema errors).
- [ ] **Step 7: Commit**
```bash
git add web worker docker-compose.yml .env.example
git commit -m "chore: scaffold web, worker, and compose stack"
```
---
### Task 2: Prisma schema + migration for Request and Job
**Files:**
- Create: `web/prisma/schema.prisma`
- Create: `web/src/lib/db.ts`
- Modify: `web/package.json` (add `postinstall` + `db:migrate` scripts)
**Interfaces:**
- Consumes: `DATABASE_URL` from Task 1.
- Produces:
- Tables `Request(id, artist, album, status, createdAt)` and `Job(id, requestId, state, currentStage, attempts, error, claimedAt, createdAt, updatedAt)`.
- Enums `RequestStatus { pending, completed, needs_attention }` and `JobState { requested, matching, matched, downloading, tagging, imported, needs_attention }`.
- A Prisma client singleton exported as `prisma` from `@/lib/db`.
- [ ] **Step 1: Write the Prisma schema**
`web/prisma/schema.prisma`:
```prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum RequestStatus {
pending
completed
needs_attention
}
enum JobState {
requested
matching
matched
downloading
tagging
imported
needs_attention
}
model Request {
id String @id @default(cuid())
artist String
album String
status RequestStatus @default(pending)
createdAt DateTime @default(now())
job Job?
}
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
}
```
- [ ] **Step 2: Add the Prisma client singleton**
`web/src/lib/db.ts`:
```ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
```
- [ ] **Step 3: Add migration/generate scripts to package.json**
In `web/package.json`, add to `"scripts"`:
```json
"postinstall": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:dev": "prisma migrate dev"
```
- [ ] **Step 4: Create the migration and apply it**
Run (Postgres must be up — `docker compose up -d db`):
```bash
cd web
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
npx prisma migrate dev --name init_request_job
```
Expected: a migration is created under `web/prisma/migrations/` and applied; Prisma prints "Your database is now in sync with your schema."
- [ ] **Step 5: Verify the tables exist**
Run:
```bash
cd web
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
npx prisma db execute --stdin <<'SQL'
SELECT 1 FROM "Request" LIMIT 0;
SELECT 1 FROM "Job" LIMIT 0;
SQL
echo "tables OK"
```
Expected: no error; prints `tables OK`.
- [ ] **Step 6: Commit**
```bash
git add web/prisma web/src/lib/db.ts web/package.json
git commit -m "feat: add Request and Job schema with migration"
```
---
### Task 3: Request API (create + list)
**Files:**
- Create: `web/src/app/api/requests/route.ts`
- Create: `web/vitest.config.ts`
- Create: `web/src/test/setup.ts`
- Test: `web/src/app/api/requests/route.test.ts`
- Modify: `web/package.json` (add `@vitejs/plugin-react` not needed; Vitest config uses node env)
**Interfaces:**
- Consumes: `prisma` from `@/lib/db` (Task 2).
- Produces:
- `POST /api/requests` — body `{ artist: string, album: string }` → creates a `Request` (status `pending`) and its `Job` (state `requested`) in one transaction; responds `201` with `{ id, artist, album, status, job: { id, state, currentStage } }`.
- `GET /api/requests` — responds `200` with `{ requests: Array<{ id, artist, album, status, createdAt, job: { state, currentStage } | null }> }`, newest first.
- [ ] **Step 1: Write the failing test**
`web/vitest.config.ts`:
```ts
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
environment: "node",
setupFiles: ["./src/test/setup.ts"],
fileParallelism: false,
},
});
```
`web/src/test/setup.ts`:
```ts
import { beforeEach } from "vitest";
import { prisma } from "@/lib/db";
beforeEach(async () => {
// Job has a cascade FK to Request; delete Job first.
await prisma.job.deleteMany();
await prisma.request.deleteMany();
});
```
`web/src/app/api/requests/route.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import { POST, GET } from "./route";
function postReq(body: unknown) {
return new Request("http://localhost/api/requests", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("requests API", () => {
it("creates a request with an initial job", async () => {
const res = await POST(postReq({ artist: "Radiohead", album: "In Rainbows" }));
expect(res.status).toBe(201);
const body = await res.json();
expect(body.artist).toBe("Radiohead");
expect(body.status).toBe("pending");
expect(body.job.state).toBe("requested");
expect(body.job.currentStage).toBe("intake");
});
it("rejects a request missing fields", async () => {
const res = await POST(postReq({ artist: "Radiohead" }));
expect(res.status).toBe(400);
});
it("lists requests newest first with job state", async () => {
await POST(postReq({ artist: "A", album: "1" }));
await POST(postReq({ artist: "B", album: "2" }));
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body.requests).toHaveLength(2);
expect(body.requests[0].artist).toBe("B");
expect(body.requests[0].job.state).toBe("requested");
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
cd web
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
npx vitest run src/app/api/requests/route.test.ts
```
Expected: FAIL — cannot import `./route` (module does not exist).
- [ ] **Step 3: Implement the route handlers**
`web/src/app/api/requests/route.ts`:
```ts
import { prisma } from "@/lib/db";
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
const { artist, album } = (body ?? {}) as { artist?: unknown; album?: unknown };
if (typeof artist !== "string" || !artist.trim() || typeof album !== "string" || !album.trim()) {
return Response.json({ error: "artist and album are required" }, { status: 400 });
}
const created = await prisma.request.create({
data: {
artist: artist.trim(),
album: album.trim(),
job: { create: {} },
},
include: { job: true },
});
return Response.json(
{
id: created.id,
artist: created.artist,
album: created.album,
status: created.status,
job: { id: created.job!.id, state: created.job!.state, currentStage: created.job!.currentStage },
},
{ status: 201 },
);
}
export async function GET() {
const requests = await prisma.request.findMany({
orderBy: { createdAt: "desc" },
include: { job: true },
});
return Response.json({
requests: requests.map((r) => ({
id: r.id,
artist: r.artist,
album: r.album,
status: r.status,
createdAt: r.createdAt,
job: r.job ? { state: r.job.state, currentStage: r.job.currentStage } : null,
})),
});
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run:
```bash
cd web
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
npx vitest run src/app/api/requests/route.test.ts
```
Expected: PASS — all three tests green.
- [ ] **Step 5: Commit**
```bash
git add web/src/app/api web/vitest.config.ts web/src/test/setup.ts
git commit -m "feat: add request create/list API"
```
---
### Task 4: Request form + live queue UI
**Files:**
- Modify: `web/src/app/page.tsx`
- Create: `web/src/app/queue.tsx`
**Interfaces:**
- Consumes: `POST /api/requests` and `GET /api/requests` (Task 3).
- Produces: a client page that submits a new request and polls the queue every 2 seconds, rendering each request's artist/album and live job state.
- [ ] **Step 1: Write the client queue component**
`web/src/app/queue.tsx`:
```tsx
"use client";
import { useEffect, useState } from "react";
type Row = {
id: string;
artist: string;
album: string;
status: string;
job: { state: string; currentStage: string } | null;
};
export function Queue() {
const [rows, setRows] = useState<Row[]>([]);
const [artist, setArtist] = useState("");
const [album, setAlbum] = useState("");
async function refresh() {
const res = await fetch("/api/requests");
const body = await res.json();
setRows(body.requests);
}
useEffect(() => {
refresh();
const id = setInterval(refresh, 2000);
return () => clearInterval(id);
}, []);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!artist.trim() || !album.trim()) return;
await fetch("/api/requests", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ artist, album }),
});
setArtist("");
setAlbum("");
refresh();
}
return (
<div>
<form onSubmit={submit}>
<input aria-label="artist" placeholder="Artist" value={artist} onChange={(e) => setArtist(e.target.value)} />
<input aria-label="album" placeholder="Album" value={album} onChange={(e) => setAlbum(e.target.value)} />
<button type="submit">Request</button>
</form>
<ul>
{rows.map((r) => (
<li key={r.id}>
{r.artist} {r.album} · <strong>{r.job?.state ?? r.status}</strong>
{r.job ? ` (${r.job.currentStage})` : ""}
</li>
))}
</ul>
</div>
);
}
```
- [ ] **Step 2: Render the queue on the home page**
`web/src/app/page.tsx`:
```tsx
import { Queue } from "./queue";
export default function Home() {
return (
<main>
<h1>Lyra</h1>
<Queue />
</main>
);
}
```
- [ ] **Step 3: Verify the app builds**
Run: `cd web && npm run build`
Expected: build completes; `/` is compiled as a route with no type errors.
- [ ] **Step 4: Commit**
```bash
git add web/src/app/page.tsx web/src/app/queue.tsx
git commit -m "feat: add request form and live queue UI"
```
---
### Task 5: Worker job-claim logic
**Files:**
- Create: `worker/lyra_worker/db.py`
- Create: `worker/lyra_worker/claim.py`
- Create: `worker/tests/conftest.py`
- Test: `worker/tests/test_claim.py`
**Interfaces:**
- Consumes: `DATABASE_URL`; the `Job`/`Request` tables from Task 2.
- Produces:
- `connect() -> psycopg.Connection` in `db.py`.
- `claim_next(conn) -> str | None` in `claim.py`: atomically selects the oldest `Job` in state `requested` using `FOR UPDATE SKIP LOCKED`, sets its state to `matching`, `claimedAt = now()`, `currentStage = 'match'`, increments `attempts`, commits, and returns the job id (or `None` if none available).
- [ ] **Step 1: Add the DB connection helper**
`worker/lyra_worker/db.py`:
```python
import os
import psycopg
def connect() -> psycopg.Connection:
dsn = os.environ["DATABASE_URL"]
return psycopg.connect(dsn)
```
- [ ] **Step 2: Write the test fixtures**
`worker/tests/conftest.py`:
```python
import os
import psycopg
import pytest
@pytest.fixture()
def conn():
dsn = os.environ["DATABASE_URL"]
connection = psycopg.connect(dsn)
yield connection
# Clean up rows created during the test (Job cascades from Request).
with connection.cursor() as cur:
cur.execute('DELETE FROM "Job"')
cur.execute('DELETE FROM "Request"')
connection.commit()
connection.close()
def insert_request(conn, artist="Artist", album="Album"):
"""Insert a Request + its Job (state 'requested') and return the job id."""
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "Request" (id, artist, album, status, "createdAt") '
"VALUES (gen_random_uuid()::text, %s, %s, 'pending', now()) RETURNING id",
(artist, album),
)
request_id = cur.fetchone()[0]
cur.execute(
'INSERT INTO "Job" (id, "requestId", state, "currentStage", attempts, "createdAt", "updatedAt") '
"VALUES (gen_random_uuid()::text, %s, 'requested', 'intake', 0, now(), now()) RETURNING id",
(request_id,),
)
job_id = cur.fetchone()[0]
conn.commit()
return job_id
```
- [ ] **Step 3: Write the failing test**
`worker/tests/test_claim.py`:
```python
from lyra_worker.claim import claim_next
from tests.conftest import insert_request
def test_claim_returns_none_when_empty(conn):
assert claim_next(conn) is None
def test_claim_advances_job_to_matching(conn):
job_id = insert_request(conn)
claimed = claim_next(conn)
assert claimed == job_id
with conn.cursor() as cur:
cur.execute('SELECT state, "currentStage", attempts, "claimedAt" FROM "Job" WHERE id = %s', (job_id,))
state, stage, attempts, claimed_at = cur.fetchone()
assert state == "matching"
assert stage == "match"
assert attempts == 1
assert claimed_at is not None
def test_claim_does_not_repick_claimed_job(conn):
insert_request(conn)
assert claim_next(conn) is not None
assert claim_next(conn) is None
```
- [ ] **Step 4: Run the test to verify it fails**
Run:
```bash
cd worker
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
python -m pytest tests/test_claim.py -v
```
Expected: FAIL — `ModuleNotFoundError: No module named 'lyra_worker.claim'`.
- [ ] **Step 5: Implement claim_next**
`worker/lyra_worker/claim.py`:
```python
import psycopg
def claim_next(conn: psycopg.Connection) -> str | None:
"""Atomically claim the oldest queued job. Returns its id or None."""
with conn.cursor() as cur:
cur.execute(
"""
SELECT id FROM "Job"
WHERE state = 'requested'
ORDER BY "createdAt" ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
"""
)
row = cur.fetchone()
if row is None:
conn.rollback()
return None
job_id = row[0]
cur.execute(
"""
UPDATE "Job"
SET state = 'matching',
"currentStage" = 'match',
"claimedAt" = now(),
attempts = attempts + 1,
"updatedAt" = now()
WHERE id = %s
""",
(job_id,),
)
conn.commit()
return job_id
```
- [ ] **Step 6: Run the test to verify it passes**
Run:
```bash
cd worker
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
python -m pytest tests/test_claim.py -v
```
Expected: PASS — all three tests green.
- [ ] **Step 7: Commit**
```bash
git add worker/lyra_worker/db.py worker/lyra_worker/claim.py worker/tests/conftest.py worker/tests/test_claim.py
git commit -m "feat: add worker job-claim logic"
```
---
### Task 6: Fake pipeline through the state machine
**Files:**
- Create: `worker/lyra_worker/pipeline.py`
- Test: `worker/tests/test_pipeline.py`
**Interfaces:**
- Consumes: a claimed job id (state `matching`) from Task 5; the DB connection.
- Produces: `run_pipeline(conn, job_id, stage_delay=0.0) -> None`: advances the job through `matched → downloading → tagging → imported`, updating `state` and `currentStage` at each step, then sets the parent `Request.status` to `completed`. `stage_delay` seconds are slept between stages (default `0.0` for tests; the runtime loop passes a small value so progress is visible in the UI).
- [ ] **Step 1: Write the failing test**
`worker/tests/test_pipeline.py`:
```python
from lyra_worker.claim import claim_next
from lyra_worker.pipeline import run_pipeline
from tests.conftest import insert_request
def test_pipeline_drives_job_to_imported(conn):
job_id = insert_request(conn)
claim_next(conn)
run_pipeline(conn, job_id, stage_delay=0.0)
with conn.cursor() as cur:
cur.execute('SELECT state, "currentStage" FROM "Job" WHERE id = %s', (job_id,))
state, stage = cur.fetchone()
cur.execute('SELECT status FROM "Request" WHERE id = (SELECT "requestId" FROM "Job" WHERE id = %s)', (job_id,))
req_status = cur.fetchone()[0]
assert state == "imported"
assert stage == "import"
assert req_status == "completed"
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
cd worker
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
python -m pytest tests/test_pipeline.py -v
```
Expected: FAIL — `ModuleNotFoundError: No module named 'lyra_worker.pipeline'`.
- [ ] **Step 3: Implement the fake pipeline**
`worker/lyra_worker/pipeline.py`:
```python
import time
import psycopg
# (state, currentStage) pairs the job moves through, in order, after claim.
_STAGES = [
("matched", "rank"),
("downloading", "download"),
("tagging", "tag"),
("imported", "import"),
]
def _set_state(conn: psycopg.Connection, job_id: str, state: str, stage: str) -> None:
with conn.cursor() as cur:
cur.execute(
'UPDATE "Job" SET state = %s, "currentStage" = %s, "updatedAt" = now() WHERE id = %s',
(state, stage, job_id),
)
conn.commit()
def run_pipeline(conn: psycopg.Connection, job_id: str, stage_delay: float = 0.0) -> None:
"""Fake acquisition: walk the claimed job through to 'imported'."""
for state, stage in _STAGES:
if stage_delay:
time.sleep(stage_delay)
_set_state(conn, job_id, state, stage)
with conn.cursor() as cur:
cur.execute(
'UPDATE "Request" SET status = \'completed\' '
'WHERE id = (SELECT "requestId" FROM "Job" WHERE id = %s)',
(job_id,),
)
conn.commit()
```
- [ ] **Step 4: Run the test to verify it passes**
Run:
```bash
cd worker
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
python -m pytest tests/test_pipeline.py -v
```
Expected: PASS.
- [ ] **Step 5: Run the whole worker suite**
Run:
```bash
cd worker
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
python -m pytest -v
```
Expected: PASS — claim and pipeline tests all green.
- [ ] **Step 6: Commit**
```bash
git add worker/lyra_worker/pipeline.py worker/tests/test_pipeline.py
git commit -m "feat: add fake acquisition pipeline"
```
---
### Task 7: Worker main loop + Dockerfiles + end-to-end verification
**Files:**
- Create: `worker/lyra_worker/main.py`
- Create: `worker/Dockerfile`
- Create: `web/Dockerfile`
- Create: `web/.dockerignore`
- Create: `worker/.dockerignore`
- Create: `web/docker-entrypoint.sh`
**Interfaces:**
- Consumes: `claim_next` (Task 5), `run_pipeline` (Task 6), `connect` (Task 5).
- Produces: a long-running worker process (`python -m lyra_worker.main`) that repeatedly claims a job and runs the pipeline, sleeping when idle; production Dockerfiles for both services; the web container runs `prisma migrate deploy` before starting.
- [ ] **Step 1: Implement the worker main loop**
`worker/lyra_worker/main.py`:
```python
import time
from lyra_worker.claim import claim_next
from lyra_worker.db import connect
from lyra_worker.pipeline import run_pipeline
IDLE_SLEEP = 2.0
STAGE_DELAY = 1.0 # visible progress in the UI
def run_forever() -> None:
conn = connect()
print("worker: waiting for jobs", flush=True)
try:
while True:
job_id = claim_next(conn)
if job_id is None:
time.sleep(IDLE_SLEEP)
continue
print(f"worker: claimed job {job_id}", flush=True)
run_pipeline(conn, job_id, stage_delay=STAGE_DELAY)
print(f"worker: imported job {job_id}", flush=True)
finally:
conn.close()
if __name__ == "__main__":
run_forever()
```
- [ ] **Step 2: Add the worker Dockerfile and dockerignore**
`worker/Dockerfile`:
```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY lyra_worker ./lyra_worker
CMD ["python", "-m", "lyra_worker.main"]
```
`worker/.dockerignore`:
```
tests
__pycache__
*.pyc
```
- [ ] **Step 3: Create the empty public dir and web dockerignore**
Create `web/public/.gitkeep` (empty file — the standalone image copies `public/`, which must exist):
```
```
`web/.dockerignore`:
```
node_modules
.next
.env
```
- [ ] **Step 4: Add the web Dockerfile and entrypoint**
`web/docker-entrypoint.sh`:
```bash
#!/bin/sh
set -e
npx prisma migrate deploy
exec node server.js
```
`web/Dockerfile`:
```dockerfile
FROM node:22-slim AS build
WORKDIR /app
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npx prisma generate && npm run build
FROM node:22-slim AS run
WORKDIR /app
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
COPY --from=build /app/node_modules/prisma ./node_modules/prisma
COPY --from=build /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=build /app/docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
EXPOSE 3000
CMD ["./docker-entrypoint.sh"]
```
- [ ] **Step 5: Build and start the whole stack**
Run:
```bash
docker compose build
docker compose up -d
sleep 15
docker compose logs worker | tail -n 5
```
Expected: worker logs show `worker: waiting for jobs`; web and db containers are healthy.
- [ ] **Step 6: Verify end-to-end via the API**
Run:
```bash
curl -s -X POST http://localhost:3000/api/requests \
-H "content-type: application/json" \
-d '{"artist":"Radiohead","album":"In Rainbows"}' >/dev/null
# Poll until the request completes (worker takes ~4s with STAGE_DELAY=1s).
for i in $(seq 1 15); do
sleep 2
state=$(curl -s http://localhost:3000/api/requests | python3 -c 'import sys,json;print(json.load(sys.stdin)["requests"][0]["job"]["state"])')
echo "state=$state"
[ "$state" = "imported" ] && break
done
```
Expected: the printed state advances through `matching`/`downloading`/etc. and reaches `imported`.
- [ ] **Step 7: Tear down**
Run: `docker compose down`
Expected: all containers stopped and removed.
- [ ] **Step 8: Commit**
```bash
git add worker/lyra_worker/main.py worker/Dockerfile worker/.dockerignore \
web/Dockerfile web/.dockerignore web/docker-entrypoint.sh web/public/.gitkeep
git commit -m "feat: add worker loop and production Dockerfiles"
```
---
## Notes for the next plan (Pipeline core)
The fake `run_pipeline` in `worker/lyra_worker/pipeline.py` is the seam the next plan replaces. Plan 2 introduces the real six-stage pipeline, the `SourceAdapter` contract, the ranker + confidence scoring, and fake adapters — swapping the fake stage-walk for real stage functions while keeping the claim loop, state machine, and DB schema unchanged. The `candidates`, `releases`, `artists`, `library_items`, and `config` tables from the design spec are added by their respective plans as the stages that need them arrive.