Files
photoanalyzer/photo_pipeline/api/routes/jobs.py
domverse ce5c8db5bc US02-05: Build the Static Application Shell
Add the reusable browser shell primitives Phase B views build on:

- store.js: observable store (get/set/subscribe)
- events.js: job activity adapter — SSE preferred, polling fallback,
  sharing the event `seq` as cursor so a transport switch drops nothing
- api.js: cancellable() (AbortController) + job endpoints; aborted
  requests reject with code "cancelled"

Make the SSE stream generically consumable: emit default `message`
events with the type in the JSON payload instead of `event: <type>`, so
a browser EventSource receives the open-ended type set (state:*,
claimed, …) via onmessage without enumerating it. The `event: done`
sentinel and resumable `id:` cursors are unchanged.

Tests: frontend/js/tests/ in-browser unit suite (api errors +
cancellation, store transitions, routing, SSE→polling fallback) served
over the app's static mount and driven by tests/e2e/test_frontend_shell.py,
which also asserts asset loading, deep-link + reload restore, JSON-only
/api/v1, and a clean console/network. Reuses the installed playwright —
no JS toolchain added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 15:47:52 +02:00

120 lines
4.3 KiB
Python

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