Run the missed sweep in the API and publish quest-missed on SSE
WHY: The nightly missed-quest sweep only existed on the Home Assistant plane (the rollover listener fired hass.bus), so the API service - now the writer of record with its own settings store and SSE stream - had no way to announce missed quests, and an admin could not trigger a catch-up run after downtime. - Extract the sweep policy (watermark read, list_missed_unswept query, documented payload build, watermark write, per-database lock) into the HA-free core (core/sweep.py), building each event through the shared events payload builder so the shape cannot drift; the integration's sweep.py becomes a thin shim that fires the returned events on hass.bus, keeping its signature. - Admin trigger POST /api/v1/admin/missed-sweep: ONE clock read, ONE core call, each returned event published on the SSE stream through the existing publish_quest_missed helper; returns the fired count. Watermark makes a same-day rerun silent and a post-downtime run sweep the accumulated window once. - Daily scheduler (api/scheduler.py) wired in the lifespan: reads day_rollover_time fresh each cycle, runs the sweep at the rollover, publishes on the app publisher, backs off and logs on failure, cancels cleanly on shutdown; injectable clock/sleep for tests. - Tests: route trigger over a live uvicorn server with a live SSE stream (one transition per instance with documented fields, same-day rerun silent, post-downtime catch-up once, non-admin 403 / absent 401) plus the scheduler rollover/cancellation test.
This commit is contained in:
@@ -139,6 +139,20 @@ are deliberately not unified here:
|
||||
module's uniform None-as-absent rule). The response is the updated
|
||||
effective settings, the same shape ``GET`` returns.
|
||||
|
||||
The missed-sweep trigger (task 058c7b69) follows the same pattern over
|
||||
:mod:`nestquest_core.sweep` — the HA-free sweep policy the
|
||||
integration's rollover listener shares:
|
||||
|
||||
- ``POST /missed-sweep`` runs the sweep for the API host's local
|
||||
``today`` (ONE :func:`_local_now` clock read threaded into the core
|
||||
call) and publishes each returned ``nestquest_quest_missed`` event
|
||||
on the SSE stream through
|
||||
:func:`api.transitions.publish_quest_missed`. The watermark rule
|
||||
(a same-day rerun announces nothing, a post-downtime run sweeps the
|
||||
accumulated window once), the no-completion-event rule and the
|
||||
read-only-over-the-domain-tables guarantee all live in the core; the
|
||||
response reports how many transitions were published.
|
||||
|
||||
Error mapping (every children, quest-definition and presence route,
|
||||
deliberately narrow):
|
||||
|
||||
@@ -237,6 +251,7 @@ from api.nestquest_core import (
|
||||
core_recurrence,
|
||||
core_settings,
|
||||
core_settings_store,
|
||||
core_sweep,
|
||||
)
|
||||
|
||||
#: All admin-plane routes share this router; the ONE admin dependency
|
||||
@@ -654,6 +669,17 @@ class AdminSettingsResponse(BaseModel):
|
||||
celebration_enabled: bool
|
||||
|
||||
|
||||
class AdminMissedSweepResponse(BaseModel):
|
||||
"""The result of ``POST /api/v1/admin/missed-sweep``.
|
||||
|
||||
``fired`` is the number of ``nestquest_quest_missed`` transitions
|
||||
the run built AND this route published on the SSE stream — zero
|
||||
when the sweep's watermark already covers today (a same-day rerun).
|
||||
"""
|
||||
|
||||
fired: int
|
||||
|
||||
|
||||
#: 404 detail for a child id the core layer reports as non-existent.
|
||||
_CHILD_NOT_FOUND_DETAIL = "Child not found"
|
||||
|
||||
@@ -803,6 +829,19 @@ def _raise_settings_error(error: ValueError) -> NoReturn:
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
|
||||
|
||||
def _raise_sweep_error(error: ValueError) -> NoReturn:
|
||||
"""Map a core missed-sweep ``ValueError`` onto 422 and raise it.
|
||||
|
||||
The sweep route has no path ids, so it has no 404 case: a core
|
||||
``ValueError`` — a rejected date the sweep's query refuses before
|
||||
any read, unreachable from this handler because ``today`` comes
|
||||
from the route's own clock read — is 422 with the core's message
|
||||
as the detail. Only ``ValueError`` is caught, so an unexpected
|
||||
failure re-raises rather than becoming a 4xx.
|
||||
"""
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
|
||||
|
||||
def _local_now() -> datetime.datetime:
|
||||
"""Return the API host's local time as ONE timezone-aware clock read.
|
||||
|
||||
@@ -1635,4 +1674,51 @@ async def admin_update_settings(
|
||||
return _settings_response(settings)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/missed-sweep",
|
||||
summary="Run the nightly missed-quest sweep now",
|
||||
response_model=AdminMissedSweepResponse,
|
||||
)
|
||||
async def admin_run_missed_sweep(
|
||||
request: Request,
|
||||
) -> AdminMissedSweepResponse:
|
||||
"""Run the missed-quest sweep and publish what it built.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter over the SAME
|
||||
HA-free sweep policy the integration's rollover listener runs
|
||||
(:mod:`nestquest_core.sweep`): ONE timezone-aware clock read
|
||||
(:func:`_local_now`) pins the API host's local ``today``, and ONE
|
||||
:func:`nestquest_core.sweep.run_missed_sweep` call does everything
|
||||
else — the past-due open-instance query, the documented payload
|
||||
build and the watermark update (a same-day rerun is an empty
|
||||
no-op, and a run after downtime sweeps the accumulated window
|
||||
once). The route is read-only over the domain tables: it never
|
||||
mutates an instance and never writes a completion event.
|
||||
|
||||
Each returned event is published on the SSE stream through
|
||||
:func:`api.transitions.publish_quest_missed` (the payload built by
|
||||
the core builder, never hand-built here; fan-out is
|
||||
fire-and-forget, see api/events.py). The response reports
|
||||
``fired`` — the number of transitions published. Error mapping
|
||||
(:func:`_raise_sweep_error`): every core ``ValueError`` is 422;
|
||||
anything else re-raises.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
database = state.database
|
||||
today = _local_now().date()
|
||||
try:
|
||||
events = await core_sweep.run_missed_sweep(database, today=today)
|
||||
except ValueError as error:
|
||||
_raise_sweep_error(error)
|
||||
for _event_type, payload in events:
|
||||
await api_transitions.publish_quest_missed(
|
||||
database,
|
||||
int(payload["instance_id"]),
|
||||
request.app.state.publisher,
|
||||
)
|
||||
return AdminMissedSweepResponse(fired=len(events))
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
Reference in New Issue
Block a user