SSE endpoint publishing quest-completed transitions (task 4d7bae00)

The panel needs live transitions, so add ONE in-process publisher
(api/events.py, installed on app.state.publisher in the lifespan) and
a GET /api/v1/panel/events SSE route on the panel router — it inherits
the single require_panel_token check. The complete route now builds
the event through the core builder and publishes it only when the
core's .appended flag says an actual transition happened; a no-op
re-complete publishes nothing. Tests stream from a real uvicorn
instance on an ephemeral port because httpx's ASGITransport buffers
endless SSE bodies.
This commit is contained in:
2026-09-22 23:00:42 -04:00
parent 550a8b3e1e
commit 4e79aebc22
5 changed files with 438 additions and 0 deletions
+55
View File
@@ -53,14 +53,19 @@ from __future__ import annotations
import datetime
import importlib
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, field_validator
from api.database import DatabaseState
from api.dependencies import require_panel_token
from api.nestquest_core import (
core_completion,
core_events,
core_settings,
core_snapshot,
)
@@ -299,7 +304,57 @@ async def panel_complete_instance(
# The derived state is "done" in both the appended and no-op cases;
# ``result.appended`` distinguishes them for callers that care —
# the panel route returns the same success either way.
if result.appended:
# An ACTUAL transition: publish the SSE transition event. The
# payload is built by the core builder (never hand-built here)
# and fan-out is fire-and-forget (see api/events.py).
event_type, payload = await core_events.build_quest_completed_event(
database, instance_id, was_on_time=result.was_on_time
)
request.app.state.publisher.publish(event_type, payload)
return {"status": str(result)}
async def _sse_stream(request: Request) -> AsyncIterator[str]:
"""Yield the SSE frames for every published transition event.
One subscriber of the app's single publisher (resolved off
``app.state.publisher``); each event becomes one
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
publisher's subscription (on client disconnect) tears the
subscription down cleanly — no queue is leaked.
"""
publisher = request.app.state.publisher
async for event_type, payload in publisher.subscribe():
yield (
f"event: {event_type}\n"
f"data: {json.dumps(payload)}\n\n"
)
@router.get(
"/events",
summary="Stream panel transition events (SSE)",
)
async def panel_events(request: Request) -> StreamingResponse:
"""Stream the app's transition events to the panel (Server-Sent
Events).
Requires the panel service token (checked by the router's shared
:func:`~api.dependencies.require_panel_token` dependency — the ONE
token check; this handler performs NO auth of its own).
Returns a ``text/event-stream`` response whose body is produced by
:func:`_sse_stream` off the app's single in-process publisher
(``app.state.publisher``, installed in the lifespan). The stream
ends when the client disconnects, at which point the subscription
is torn down (its queue removed) with nothing leaked.
"""
return StreamingResponse(
_sse_stream(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache"},
)
__all__ = ["router"]