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:
2026-09-23 04:07:49 -04:00
parent fa4415c0d5
commit dfe8587c7a
7 changed files with 1041 additions and 97 deletions
+86
View File
@@ -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"]