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>
120 lines
4.3 KiB
Python
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")
|