"""Job command, status, blocker, and activity-event API. Operational endpoints return JSON with the shared error envelope; the event stream returns SSE with resumable ``id:`` cursors (SQLite rowid). A client that drops the stream reconnects with ``Last-Event-ID`` and receives exactly the durable events it missed — no duplicates, none lost. The same events are available as plain JSON via ``?after=`` for a polling fallback. """ from __future__ import annotations import json import anyio from fastapi import APIRouter, Query, Request from fastapi.responses import JSONResponse, StreamingResponse from photo_pipeline.schemas import JobStartRequest from photo_pipeline.services.jobs import ( ACTIVE_STATES, InvalidTransition, JobBlocked, JobError, JobService, ) router = APIRouter(tags=["jobs"]) def _service(request: Request) -> JobService: return JobService(request.app.state.session_factory) def _error(status: int, code: str, message: str) -> JSONResponse: return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}}) @router.post("/jobs") def start_job(body: JobStartRequest, request: Request): service = _service(request) try: return service.enqueue( body.job_type, lock=body.lock, idempotency_key=body.idempotency_key, items=body.items, ) except JobBlocked as error: return _error(409, error.code, str(error)) @router.get("/jobs/{job_id}") def get_job(job_id: str, request: Request): service = _service(request) snapshot = service.get(job_id) if snapshot is None: return _error(404, "not_found", f"unknown job {job_id}") return {**snapshot, "progress": service.progress(job_id)} @router.post("/jobs/{job_id}/cancel") def cancel_job(job_id: str, request: Request): service = _service(request) if service.get(job_id) is None: return _error(404, "not_found", f"unknown job {job_id}") try: return service.cancel(job_id) except InvalidTransition as error: return _error(409, "invalid_transition", str(error)) except JobError as error: return _error(409, "job_error", str(error)) @router.get("/jobs/{job_id}/blockers") def job_blockers(job_id: str, request: Request, lock: str = Query(...)): return {"lock": lock, "blockers": _service(request).blockers(lock)} @router.get("/jobs/{job_id}/events") def list_events(job_id: str, request: Request, after: int = 0): service = _service(request) snapshot = service.get(job_id) if snapshot is None: return _error(404, "not_found", f"unknown job {job_id}") return {"state": snapshot["state"], "events": service.events_after(job_id, after)} @router.get("/jobs/{job_id}/events/stream") async def stream_events(job_id: str, request: Request, after: int = 0): service = _service(request) if service.get(job_id) is None: return _error(404, "not_found", f"unknown job {job_id}") header = request.headers.get("last-event-id") cursor = int(header) if header and header.isdigit() else after async def generate(): nonlocal cursor # Bounded so a stream can't run forever; a terminal job closes immediately. # ponytail: sync DB reads inside the async loop are fine for a local # single-user app; move to a threadpool/pubsub if it ever multiplexes. for _ in range(600): for event in service.events_after(job_id, cursor): cursor = event["seq"] # Default (unnamed) event so a browser EventSource receives every # event via onmessage without enumerating the open-ended type set # (state:*, claimed, …); the type travels in the JSON payload, # matching the polling fallback's {seq, type, message} shape. yield ( f"id: {event['seq']}\n" f"data: {json.dumps({'type': event['type'], 'message': event['message']})}\n\n" ) snapshot = service.get(job_id) if snapshot is None or snapshot["state"] not in ACTIVE_STATES: yield "event: done\ndata: {}\n\n" return await anyio.sleep(0.1) return StreamingResponse(generate(), media_type="text/event-stream")