Compare commits

..
Author SHA1 Message Date
dev_agent 592691c470 Task ef3f4877: Admin snapshot route for the Today view (#190) 2026-09-23 19:56:33 -04:00
orchestrator-agent 22f1a6861d Merge pull request 'Reconcile dev from main (release 0.6.0 commits)' (#186) from main into dev 2026-09-23 16:16:23 -04:00
overseer 939556a38a Merge pull request 'Release: Re-imagined architecture - Features 15, 16, 18, 20 (2026-09-23)' (#185) from release/2026-09-23-reimagining into main
Reviewed-on: #185
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-23 14:55:38 -04:00
overseer 8676bdc6ad release: correct the 0.6.0 notes - no containerization artifacts ship in this release 2026-09-23 14:35:01 -04:00
overseer 0ed35ab85a release: advance version to 0.6.0 and add the re-imagined architecture release notes 2026-09-23 14:29:12 -04:00
overseer c57d950101 merge: origin/main into 0.6.0 release (bring the architecture doc; keep dev versions and notes) 2026-09-23 14:27:56 -04:00
dev_agent 70ee4ec111 Make the missed-sweep watermark claim safe across processes (#184)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 13:06:40 -04:00
dev_agent 415ababc8e Automatically prepare releases for completed features (#182)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 13:02:44 -04:00
overseer 0972761994 Merge pull request 'Add the re-imagined architecture document' (#140) from docs/architecture-reimagining into main
Reviewed-on: #140
2026-09-21 16:09:26 -04:00
overseer 10f365ef9f Add the re-imagined architecture document 2026-09-21 13:10:57 -04:00
14 changed files with 1183 additions and 41 deletions
+129 -21
View File
@@ -1,27 +1,135 @@
# NestQuest Release Notes
## Unreleased
## Version 0.6.0 — 2026-09-23
- **Storage layer extraction (task d0b691d8):** the SQLite storage layer
(schema, migrations, db, store, const, recurrence, DAOs) is bundled
under `custom_components/nestquest/core/`, with the integration's
original module names preserved as re-export shims.
- **Logger namespace change (breaking for logging configuration):** the
migrations runner now logs under
`custom_components.nestquest.core.migrations` instead of
`custom_components.nestquest.migrations`. Users who tuned the old
logger name (e.g. in `logger.yaml` or `configuration.yaml`) must
update the entry to keep that level, as the old name no longer
receives migration log output.
- **Additional logger namespace moves:** the children registry and the
admin allowlist business layers now log under
`custom_components.nestquest.core.children` and
`custom_components.nestquest.core.admin_allowlist` respectively
(moved with the engines/business-layer extraction, task 510c1f78).
The parent `custom_components.nestquest` logger still catches both,
so users who tune the parent rather than the leaf names are
unaffected; only users who pinned a leaf logger name by its old
spelling need to update.
### Scope
Sixth release of NestQuest, promoting the re-imagined architecture
(Features 15, 16, 18 and 20) and the foreground development-orchestration
tooling from `dev` onto 0.5.0. This is a major architectural change: the
Home Assistant integration becomes a thin client of a standalone NestQuest
API service that owns the database.
- **Feature 15 — Domain Core Extraction:** all domain code (schema,
migrations, database access, the DAOs, the pure recurrence/presence
engines, materialization, completion, the SSE-free event payload
builders, the panel snapshot builder, the children/definitions/allowlist
business layers, and an explicit settings object) now lives in a
Home-Assistant-free package bundled inside the integration at
`custom_components/nestquest/core/`. The SQLite schema and migration
versions are unchanged, so an existing `nestquest.db` opens without
migration. Covered by a no-Home-Assistant end-to-end smoke test.
(Tasks d0b691d8, 510c1f78, 4dd8f870, 628473ad.)
- **Feature 16 — NestQuest API Service:** a standalone FastAPI service
(`api/`) that imports `core/` and is the only writer of the database.
Panel plane (`/api/v1/panel/*`) is authenticated by a static service
token: household snapshot and an idempotent complete route. Admin plane
(`/api/v1/admin/*`): children, quest definitions, presence
schedule/override, uncomplete, regenerate, history query + CSV export,
and settings — authenticated by an Authentik OIDC JWT (issuer, audience,
JWKS signature and expiry) plus membership in `nestquest-admins`, with
the panel service token rejected outright. A server-sent-events stream
publishes the quest-completed, quest-uncompleted, quest-missed and
child-day-complete transitions; the missed sweep runs in the API with a
cross-process-safe watermark. `/health` and the OpenAPI document are
served. (Tasks 042dfc5c, ae7e90ae, 1860ef76, 4d7bae00, 44c93cfd,
e70e6bfb, 0f2afb29, e4845baa, b586a2f1, e4a9d31b, da0226b3, 2d2dda53,
2b3de7e5, 9047a97b, 9a118da9, 058c7b69, a232c4f5.)
- **Feature 18 — HA Integration as API Client:** the integration no longer
owns a database. It polls `GET /api/v1/panel/snapshot` and builds the
existing sensors (entity ids and per-child instance payload shape
unchanged), subscribes to the SSE stream and re-fires the four HA bus
events, proxies `nestquest.complete_quest` to the panel complete route,
and caches the last-good snapshot so a brief API outage does not blank
the panel. The HA admin services and the service permission gate are
removed; only the panel completion path remains. (Tasks 37d5e806,
6e70d166, b1ed9c45, 47c7c90e, d25b875f, d04f5bf3, ad67fc44.)
- **Feature 20 — Zero-Config Panel Dashboard:** the panel installs with no
hand-written card YAML and no manual dashboard creation. The cards
auto-discover children from a household entity attribute (ordered by the
API's `sort_order`), a Lovelace dashboard strategy generates the
party-board view and one quest-log view per child at render time with
computed navigation paths, and the integration registers the dashboard
on first setup. Navigation and the panel transitions match the design
mockups. (Tasks 1b6d91d2, c859af23, c2d4dd8e, 107b8fbe, 50067c1f,
fc69c940, 67775be9.)
- **Development orchestration tooling:** durable OpenCode controller and
role launchers, the foreground terminal orchestrator, the independent
review/approval gates, and the automatic release-preparation policy.
### Requirements
- Minimum Home Assistant version: **2024.6.0**.
- A running NestQuest API service, reachable at the base URL and panel
service token configured in the integration options. The API service
source ships in `api/`; there is no container image, compose file or
deployment runbook in this release, and Feature 17 — the deployment
(TLS reverse proxy, Authentik, network separation, backups) — is **not**
part of this release and must be completed before the integration is
functional. See `docs/ARCHITECTURE.md` for the target design.
### Breaking changes
- **The HA integration no longer owns a database.** It reads exclusively
from the NestQuest API. The local `nestquest.db` is no longer opened,
migrated or written by the integration (the file is left untouched on
disk). Without a reachable API the entities become unavailable after the
configured staleness threshold; the panel serves the last-good snapshot
in the meantime.
- **HA admin services are removed** (`uncomplete`, quest-definition
create/update/set-active, presence pattern/override, CSV export,
`manage_child`, `regenerate`) and the HA-side permission gate is gone.
Admin operations move to the API, and — once Feature 19 lands — the
admin PWA. Any automation that called an admin service must be
repointed at the API.
- **Logger namespaces moved** under `custom_components.nestquest.core.*`:
the migrations runner now logs under
`custom_components.nestquest.core.migrations` (previously
`custom_components.nestquest.migrations`), and the children registry and
admin allowlist log under `custom_components.nestquest.core.children` and
`custom_components.nestquest.core.admin_allowlist`. The parent
`custom_components.nestquest` logger still catches all three; only users
who pinned a leaf logger name by its old spelling must update.
- **The panel installs with no configuration.** `child_order`,
`quest_log_path` and `board_path` are no longer required (an explicit
value still wins). The integration registers a `custom:nestquest-party`
dashboard on setup; point TouchHub at it.
- **Schema is unchanged at version 7** — no migration runs on upgrade.
### Known Limitations
- **Feature 17 (API deployment and identity) is not in this release.** The
API service source ships in `api/`, but no container image, compose
file, TLS reverse proxy, Authentik application/provider/`nestquest-admins`
group, proven network separation of the panel routes, or backup/restore
is included. Feature 17's containerization groundwork was completed on
its own feature branch, which is **not** merged into `dev` and therefore
is not part of this release. Until that deployment is completed the
integration has nothing to talk to.
- **Feature 19 (admin PWA) is not in this release.** There is no admin UI
in 0.6.0; admin operations are reachable only through the API directly.
- Moving the household database from Home Assistant to the API box is a
manual file copy (the schema is unchanged).
- Single household; single API service.
### Rollback
Schema version is unchanged (7), so no migration is involved in either
direction. Nevertheless **back up `nestquest.db` (plus `-wal`/`-shm`)
before upgrading**: 0.6.0 never touches it, and a downgrade to 0.5.0
resumes using exactly that file.
**HACS installs:**
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.5.0).
2. Restart Home Assistant.
**Manual installs:**
1. Replace `custom_components/nestquest/` with the 0.5.0 tree.
2. Restart Home Assistant.
**Repository operators:** git-revert the 0.6.0 release merge on `main`.
## Version 0.5.0 — 2026-09-18
+117
View File
@@ -116,6 +116,22 @@ owns every rule so the two routes cannot drift:
a ``Content-Disposition`` attachment filename and the documented,
stable header row (Admin spec §5 event-row fields).
The snapshot route (task ef3f4877) is the admin-plane twin of the
panel's ``GET /api/v1/panel/snapshot`` for the admin PWA's Today tab —
the PWA is internet-facing and never holds the panel service token
(D-012), so it reads the same snapshot here:
- ``GET /snapshot`` threads ONE clock read (:func:`_local_now`) into
:func:`nestquest_core.snapshot.build_snapshot` and shapes each
child's instances through
:func:`nestquest_core.snapshot.instance_payload` with
``include_missed=True`` — admin payloads KEEP missed instances with
their ``missed`` state (D-009), where the panel omits them. The
body is the panel's documented shape (``today_iso``, ``cycle_day``,
``children`` with the per-child presence, rollup counts and
``instances``); the builder, the state derivation and the shaping
all live in the core.
The settings routes (task 2b3de7e5) follow the same pattern over
:mod:`nestquest_core.settings_store` — the API's OWN durable settings
store. The API now OWNS settings in the database (a validated JSON
@@ -254,6 +270,7 @@ from api.nestquest_core import (
core_recurrence,
core_settings,
core_settings_store,
core_snapshot,
core_sweep,
)
@@ -683,6 +700,59 @@ class AdminMissedSweepResponse(BaseModel):
fired: int
class AdminInstancePayload(BaseModel):
"""One instance in the admin snapshot payload.
Mirrors :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=True``: ``state`` is ``open`` | ``completed`` |
``missed`` (admin payloads keep missed instances, D-009),
``on_time`` passes the completion event's flag through verbatim
(``None`` unless completed), and ``completed_at`` is the event's
UTC timestamp when completed.
"""
id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_time: str | None
state: str
overdue: bool
completed_at: str | None
on_time: bool | None
class AdminChildSnapshotPayload(BaseModel):
"""One child's day: presence, rollup counts, and today's instances."""
child_id: int
child_name: str
present: bool
#: ISO date of the child's next present day while they are away;
#: ``None`` when the child is present today.
next_present: str | None
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
instances: list[AdminInstancePayload]
class AdminSnapshotResponse(BaseModel):
"""Today's whole household snapshot for the admin Today tab.
The panel snapshot's shape (``today_iso``, ``cycle_day``,
``children`` in the household's sort order), with missed instances
kept in each child's ``instances``.
"""
today_iso: str
cycle_day: int
children: list[AdminChildSnapshotPayload]
#: 404 detail for a child id the core layer reports as non-existent.
_CHILD_NOT_FOUND_DETAIL = "Child not found"
@@ -1729,4 +1799,51 @@ async def admin_run_missed_sweep(
return AdminMissedSweepResponse(fired=len(events))
@router.get(
"/snapshot",
summary="Today's household snapshot",
response_model=AdminSnapshotResponse,
)
async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
"""Return today's full household snapshot for the admin Today tab.
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: ONE clock read
(:func:`_local_now`) goes into
:func:`nestquest_core.snapshot.build_snapshot` against the live
database, and each child's instances are shaped with
``instance_payload(include_missed=True)`` — missed instances are
KEPT in the admin payload (D-009) — into
:class:`AdminSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
)
return AdminSnapshotResponse(
today_iso=snapshot.today_iso,
cycle_day=snapshot.cycle_day,
children=[
AdminChildSnapshotPayload(
child_id=child.child_id,
child_name=child.child_name,
present=child.present,
next_present=child.next_present,
due_today=child.due_today,
completed_today=child.completed_today,
remaining_today=child.remaining_today,
completion_pct=child.completion_pct,
instances=[
AdminInstancePayload.model_validate(instance)
for instance in core_snapshot.instance_payload(
child.instances, include_missed=True
)
],
)
for child in snapshot.children
],
)
__all__ = ["router"]
+35 -1
View File
@@ -41,4 +41,38 @@ class MetaStateDao:
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
(key, value),
)
)
async def claim(self, key: str, value: str) -> bool:
"""Atomically set ``key`` to ``value`` unless it already is.
The single upsert statement carries a ``WHERE`` guard on the
conflict path (``t.value IS NOT excluded.value``), so the
read-compare-write is ONE SQLite statement — atomic at the
DATABASE level, not just within one process: two connections
(two processes sharing one SQLite file) cannot both observe
"not yet set" and both write, because the second writer's
statement re-evaluates the guard against the first writer's
committed value and matches zero rows.
Returns True only when THIS call changed the row: the first
claimant wins and every later claimant of the same value
returns False. A DIFFERENT value always wins (an advance, e.g.
the sweep watermark moving to a new date) and re-arms the
same-value loser check for the new value.
Runs under the shared connection lock and inside one
transaction, like :meth:`set`; ``cursor.rowcount`` (1 for a
matched write, 0 for a guarded no-op) is captured by the
wrapper on the executor thread, so no sqlite3 object crosses
back to the event loop.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
result = await self._database.execute(
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value "
f"WHERE {_TABLE}.value IS NOT excluded.value",
(key, value),
)
return result.rowcount == 1
+42 -15
View File
@@ -26,12 +26,17 @@ later completed simply gains its completion event; history keeps both
rows (append-only), and the sweep's later runs skip it via the
no-completion-event rule.
The whole watermark read → query → build → write span runs inside the
The whole watermark read → query → build span runs inside the
per-database sweep lock (one lock per connection wrapper and running
loop, the settings store's pattern): an overlapping startup sweep and
a rollover sweep queue instead of interleaving, and the loser re-reads
the watermark INSIDE the lock — sees the winner's date — and announces
nothing twice.
a rollover sweep of the SAME process queue instead of interleaving.
The announce decision itself is the DATABASE-level claim
(:meth:`MetaStateDao.claim`), so two PROCESSES sharing one SQLite file
(the integration's rollover sweep and the API service's scheduler)
cannot both announce the same missed batch either: both may read the
old watermark and build the events, but the single conditional upsert
lets exactly one of them win the claim — the loser returns [] and
announces nothing.
The caller supplies ``today`` as a plain :class:`datetime.date`: the
household-local calendar date, read from ``hass.config.time_zone`` by
@@ -60,11 +65,11 @@ LOGGER = logging.getLogger(__name__)
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
#: the settings store's pattern: the whole watermark read → query →
#: build → write span is one critical section, so an overlapping
#: startup sweep and rollover sweep queue instead of double-announcing.
#: The loser re-reads the watermark INSIDE the lock, sees the winner's
#: date, and fires nothing.
#: the settings store's pattern: the watermark read → query → build
#: span is one critical section, so an overlapping startup sweep and
#: rollover sweep of the SAME process queue instead of interleaving.
#: Same-process callers are fully serialised by it; the cross-process
#: case is closed by the database-level claim, not by this lock.
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
@@ -99,15 +104,24 @@ async def run_missed_sweep(
the caller decides how to publish them and announces nothing on
its own.
The whole watermark read → query → build → write span runs inside
the per-database sweep lock: an overlapping startup sweep and a
rollover sweep queue instead of interleaving, and the loser
re-reads the watermark inside the lock — sees the winner's date —
and announces nothing twice. Every event in one run shares one
The watermark read → query → build span runs inside the
per-database sweep lock, so overlapping callers of ONE process
queue instead of interleaving. The announce decision is the
DATABASE-level claim (:meth:`MetaStateDao.claim` — a single
conditional upsert on the watermark, atomic across processes):
every caller builds the events optimistically, then only the
caller whose claim actually advanced the watermark returns them;
a loser (another process already announced for ``today``) returns
[] and announces nothing twice. Every event in one run shares one
``occurred_at`` stamp (one run, one announcement moment), and the
payload comes from the shared
:func:`~.events._instance_payload` builder so the shape can never
drift from the documented §3 contract.
Failure ordering: the watermark is advanced ONLY by the claim,
AFTER the events are fully built — a failure before the claim
leaves the watermark unadvanced, so a later run re-sweeps the same
window and nothing is lost.
"""
today_iso = today.isoformat()
dao = MetaStateDao(database)
@@ -134,10 +148,23 @@ async def run_missed_sweep(
),
)
)
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
# The database-level claim gates the announcement: only the
# caller that actually advanced the watermark may publish the
# built events, so two processes racing one SQLite file cannot
# both announce the same missed batch. A same-value loser's
# claim matches zero rows and this run announces nothing.
claimed = await dao.claim(SWEEP_WATERMARK_KEY, today_iso)
if not claimed:
LOGGER.info(
"NestQuest missed sweep for %s lost the watermark claim; "
"announcing nothing",
today_iso,
)
return []
LOGGER.info(
"NestQuest missed sweep for %s built %d event(s)",
today_iso,
len(events),
)
return events
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.5.0"
"version": "0.6.0"
}
+203
View File
@@ -0,0 +1,203 @@
# NestQuest Architecture
Status: approved direction (2026-09-21). This document supersedes the
single-process design implied by Features 01–14, in which the Home Assistant
integration owned the database and the parent admin surface was planned as a
Lovelace card inside Home Assistant.
## 1. Why this changed
The original design put everything inside the Home Assistant integration: the
SQLite database, the business logic, the kid panel cards, and (planned) the
parent admin card. Three problems surfaced:
1. **The panel needed hand configuration.** Standing up the kid panel required
hand-writing card YAML (`child_order`, `quest_log_path`, `board_path`) and
hand-creating a Lovelace dashboard. A fresh install rendered nothing.
2. **The admin surface fought Home Assistant.** The admin design
(`design/ADMIN-SPEC.md`) is drawn at 390×844 — it is a phone app. Trapping it
inside a Lovelace card meant fighting HA's UI constraints for a mobile-first
experience.
3. **Data was locked to one process.** A standalone admin web app cannot reach a
SQLite file inside Home Assistant's config directory.
The re-imagined design keeps the panel in Home Assistant (TouchHub loads an HA
dashboard), moves data ownership to a standalone API service, and promotes the
admin surface to a standalone mobile-first PWA.
## 2. Target architecture
```
DAKboard TouchHub
│ (loads an HA dashboard URL)
▼
HA box API box
┌───────────────────────────────┐ ┌───────────────────────────────────┐
│ Lovelace panel dashboard │ │ reverse proxy (TLS) │
│ ├ nestquest-party-board-card │ service │ ├ /api/v1/panel/* LAN-only │
│ └ nestquest-quest-log-card │ token │ └ /api/v1/admin/* public │
│ │ ─────────▶ │ FastAPI (imports core/) │
│ NestQuest integration │ │ └ SQLite (owned here) │
│ (thin client, no database) │ ◀───────── │ Authentik (OIDC) │
│ sensors + HA bus events │ SSE └───────────────────────────────────┘
└───────────────────────────────┘ ▲
PWA ───────────┘
https://nestquest.cubecraftlabs.com
```
One owner of the data (the API). Two clients (the HA integration, the PWA).
## 3. Data ownership
**The API service is the only writer.** The HA integration never opens the
database; it reads a snapshot and posts completions. This removes the
two-writer problem entirely.
- **Storage:** SQLite on the API box. Single household, trivial concurrency,
and the existing schema, migrations, and DAO tests are already SQLite. Postgres
buys nothing at this scale.
- **Schema:** unchanged. The cutover from the current HA-hosted database is a
file copy — same schema, same migration version.
- **Backups:** continuous replication (e.g. Litestream) plus a proven restore.
A configured-but-untested backup is not a backup.
## 4. Authentication: two planes
The kid panel and the admin PWA have opposite trust levels, so they use
separate credentials and separate network exposure.
### Panel plane (machine)
- **Credential:** a static service token.
- **Exposure:** `/api/v1/panel/*` is reachable **only from the LAN**.
- **Grants:** read the snapshot, and the single open write — complete a quest as
`actor: panel` with the tapped `actor_child_id`.
- **Refused:** admin routes reject the service token outright, not merely as a
non-admin. The internet cannot reach these routes at all.
This preserves the original fail-closed boundary: the kiosk can complete a
quest and nothing else.
### Admin plane (human)
- **Credential:** an Authentik OIDC JWT (authorization code + PKCE).
- **Exposure:** `/api/v1/admin/*` is internet-facing behind Authentik.
- **Grants:** everything a parent can do — definitions, assignment, presence,
children, un-complete, history, export, settings, regenerate.
- **Gate:** the API validates the JWT (issuer, audience, JWKS signature, expiry)
and requires membership in the `nestquest-admins` group. The Authentik group
is the single source of truth for "who is a parent", replacing the
integration-side admin allowlist.
## 5. Components
### `core/` — domain library
The extracted domain layer: schema, migrations, database access, DAOs, the pure
recurrence and presence engines, materialization, completion, event payload
construction, and the children/definitions/allowlist business layers. **No
Home Assistant imports.** Consumed by both the API and (during transition) the
integration. This is where the ~1500 existing tests live after extraction.
### `api/` — NestQuest API
A FastAPI service importing `core/`. Owns the database. Serves the panel and
admin planes, validates Authentik tokens, and publishes a transition event
stream (SSE) that the integration consumes. No business logic in route handlers.
### `custom_components/nestquest/` — HA integration (client)
A thin client. Polls the snapshot and builds the existing sensors; subscribes to
the SSE stream and re-fires the four HA bus events; proxies the panel completion.
Holds a last-good snapshot cache so a brief API outage does not blank the panel.
Owns no database and no business logic.
### `panel/` — Lovelace cards and dashboard strategy
The two kid-facing Lit cards plus a dashboard strategy that generates the panel
views at render time. Cards auto-discover children, so no `child_order` is
required; the strategy computes navigation paths, so `quest_log_path` and
`board_path` are not required.
### `admin/` — Admin PWA
A React progressive web app implementing `design/ADMIN-SPEC.md`, authenticated
via Authentik OIDC, installable on a phone. The only admin surface.
## 6. Zero-configuration panel
A fresh install should show the panel with no YAML:
1. **Auto-discovery.** The party-board card reads an ordered child list from a
household entity attribute instead of a configured `child_order`.
2. **Dashboard strategy.** A Lovelace strategy generates a party-board view and
one quest-log view per child, wiring navigation between them, so paths are
computed rather than configured. Explicit configuration still wins if a user
supplies it.
3. **Auto-registration.** The integration registers the dashboard on first
setup, so there is no manual dashboard step.
## 7. Home Assistant surface preserved
The re-imagining must not break what already works:
- **Sensors:** the per-child and household sensor entity_ids and the per-child
instance attribute payload shape are unchanged. The panel cards and the four
blueprints depend on them.
- **Events:** the four HA bus events (`nestquest_quest_completed`,
`nestquest_quest_uncompleted`, `nestquest_quest_missed`,
`nestquest_child_day_complete`) keep firing with their documented payloads.
They are now driven by the API's transition stream rather than by local
service handlers.
- **Blueprints:** the morning summary, afternoon reminder, end-of-day report, and
day-complete celebration continue to work with no change.
## 8. Events without an HA token on the API box
The API publishes transitions on an SSE stream; the integration subscribes and
re-fires the HA bus events locally. This deliberately avoids storing a Home
Assistant long-lived token on the API box, which would grant full HA access.
## 9. Migration phases
Mapped to the Maestro features:
| Phase | Feature | Outcome |
|---|---|---|
| 1 | 15 — Domain Core Extraction | `core/` runs with no HA imports; one implementation of the rules |
| 2 | 16 — NestQuest API Service | Panel and admin planes served with two-plane auth |
| 3 | 17 — API Deployment and Identity | TLS, Authentik, backups, network separation |
| 4 | 18 — HA Integration as API Client | Integration becomes a client; entity/event surface preserved |
| 5 | 19 — Admin PWA | Mobile-first React PWA replaces the admin card |
| 6 | 20 — Zero-Config Panel Dashboard | Panel installs itself; navigation and animations match the mockups |
Cutover: copy the existing SQLite file to the API box (same schema).
## 10. Locked decisions
- **API on its own box** — separate from Home Assistant.
- **Authentik** (`https://auth.cubecraftlabs.com`) for admin identity; a
`nestquest-admins` group is the admin source of truth.
- **Single household** — no tenant column, no multi-household auth.
- **React** for the admin PWA.
- **Drop the HA admin services** — the PWA is the only admin surface. The panel
completion path stays.
- **SQLite on the API box** initially; Postgres only if multi-household ever
becomes real.
## 11. Preserved vs. changed
**Preserved:** the tested Python domain layer, the panel card designs and
tokens, the sensor and event contract, the completion model, the append-only
event log, the panel's fail-closed "complete only" boundary.
**Changed:** data ownership moves to the API; the integration becomes a client;
the admin card becomes a PWA; panel cards become config-free with a
strategy-generated dashboard.
**New:** the API service, Authentik-gated admin auth, the PWA, and a
last-good-snapshot cache in the integration.
**Cost:** two new deployables (the API and the PWA) and one new box. Accepted
knowingly in exchange for a self-installing panel, an admin surface that is not
fighting HA's UI, and a data layer with real auth in front of it.
+6
View File
@@ -57,6 +57,12 @@ class CyclePromptTests(unittest.TestCase):
"feature becomes task-complete and its reviewed merge into `dev` is verified",
output,
)
self.assertIn("Every required feature task is `done`", output)
self.assertIn("No task review, remediation, approval, or merge remains pending", output)
self.assertIn("feature acceptance criteria and integrated checks pass", output)
self.assertIn("Independent approval applies to the exact feature head", output)
self.assertIn("approver's merge of that head into `dev` is verified", output)
self.assertIn("reflects the completed feature and exact repository references", output)
self.assertIn("Task completion inside an unmerged feature branch does not trigger", output)
self.assertIn("Do not create a duplicate promotion", output)
self.assertIn("Allow only one active promotion pull request to `main`", output)
@@ -1,4 +1,5 @@
from pathlib import Path
import fcntl
import os
import subprocess
import tempfile
@@ -47,6 +48,7 @@ class ForegroundLauncherTests(unittest.TestCase):
"NQ_PROJECT_ROOT": str(project),
"OPENCODE_BIN": str(opencode),
"SYSTEMCTL_BIN": str(systemctl),
"NQ_STATE_DIR": str(root / "state"),
})
printed = subprocess.run([str(SCRIPT), "--print-prompt"], env=env,
text=True, capture_output=True)
@@ -67,6 +69,16 @@ class ForegroundLauncherTests(unittest.TestCase):
["--check-interactive", "--project", str(project)])
output.unlink()
lock_path = root / "state/controller.lock"
with lock_path.open("w") as held_lock:
fcntl.flock(held_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
locked = subprocess.run([str(SCRIPT)], env=env, text=True,
capture_output=True)
self.assertEqual(locked.returncode, 75)
self.assertIn("Another NestQuest orchestrator or controller",
locked.stderr)
self.assertFalse(output.exists())
open_marker.touch()
refused = subprocess.run([str(SCRIPT)], env=env, text=True,
capture_output=True)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.5.0"
version = "0.6.0"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
+12
View File
@@ -11,6 +11,7 @@ control_home=${NQ_CONTROL_HOME:-/home/overseer/.local/share/nestquest-controller
project=${NQ_PROJECT_ROOT:-/mnt/ai-storage/projects/NestQuest}
opencode_bin=${OPENCODE_BIN:-/home/overseer/.opencode/bin/opencode}
systemctl_bin=${SYSTEMCTL_BIN:-systemctl}
state_dir=${NQ_STATE_DIR:-/home/overseer/.local/state/nestquest-controller}
renderer="$control_home/scripts/render-cycle-prompt"
profile="$control_home/templates/nestquest.profile.json"
@@ -41,6 +42,17 @@ fi
# Foreground mode owns orchestration. A dormant service must never race it.
"$systemctl_bin" --user stop nestquest-controller.service
# Share the controller's lock and hold it across the final exec. This closes
# the launch-time race between two foreground sessions as well as the race with
# the background controller.
mkdir -p "$state_dir"
chmod 700 "$state_dir"
exec 9>"$state_dir/controller.lock"
if ! flock -n 9; then
echo "Another NestQuest orchestrator or controller is running." >&2
exit 75
fi
session_status=$("$control_home/scripts/nq-controller" --check-interactive --project "$project")
if [[ "$session_status" == "interactive NestQuest session open" ]]; then
echo "Another interactive NestQuest OpenCode session is open." >&2
+1 -1
View File
@@ -703,7 +703,7 @@ The verified feature-to-`{{INTEGRATION_BRANCH}}` merge is the triggering transit
Before creating a release branch or pull request:
1. Verify this session is the sole active orchestrator for the project. `{{TERMINAL_LAUNCHER}}` must enforce that exclusivity. If sole ownership cannot be verified, do not create a release branch or pull request; escalate instead.
1. Verify this session is the sole active orchestrator for the project. `{{TERMINAL_LAUNCHER}}` enforces that exclusivity by holding the shared controller lock for the full foreground session. If that lock is not held by this session, do not create a release branch or pull request; escalate instead.
2. Reconcile open release records, release branches, and promotion pull requests.
3. Do not create a duplicate promotion for an already-covered `{{INTEGRATION_BRANCH}}` commit.
4. Allow only one active promotion pull request to `{{RELEASE_BRANCH}}`. If an earlier promotion is still open and does not contain the newly completed feature, record the feature as pending for the next automatic promotion. Resume it after the active promotion is merged or closed; if it was closed without merge, recalculate the next promotion from the current validated `{{INTEGRATION_BRANCH}}` head.
+328
View File
@@ -0,0 +1,328 @@
"""Admin-plane snapshot route tests (task ef3f4877).
These tests exercise ``GET /api/v1/admin/snapshot`` against the ASGI
app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely
authenticates with NO network access. The household is seeded with
:func:`tests.test_api_panel._seed_household` through the SAME core
copy the app uses (``nestquest_core.*``), and both routes' single
clock read (``_local_now``) is pinned to noon so nothing straddles
midnight.
Proven per the done-condition:
- An admin JWT returns 200 with the documented shape: ``today_iso``,
``cycle_day`` and, per child, the presence fields, the rollup counts
and ``instances`` in the core ``instance_payload`` shape.
- A MISSED instance is KEPT in the admin payload (D-009) and still
OMITTED from the panel payload. The core builder lists only
instances due on ``now.date()``, and ``derive_state`` calls an
instance missed only when its due date is BEFORE that day — so a
real snapshot never carries a missed view. The test therefore
wraps the real builder to add the seeded past-due instance as a
view whose state the core's own ``derive_state`` computes
(``missed``), and reads it through both routes.
- Auth runs through the router's ONE ``require_admin`` dependency: no
credential is 401, the panel service token is refused outright
(403, the credential-type detail), and a non-admin JWT is 403.
- The handler performs exactly ONE clock read per request.
"""
from __future__ import annotations
import dataclasses
import datetime
import importlib
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api.nestquest_core import core_snapshot
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
from tests.test_api_panel import _seed_household
# The completion layer through the SAME core copy the app uses — the
# missed test derives its view's state with the core's own rule.
_completion = importlib.import_module("nestquest_core.completion")
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _panel_headers() -> dict[str, str]:
"""The Authorization header carrying the harness panel token."""
return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
@pytest.fixture
async def snapshot_household(
temp_db_path: str, monkeypatch
) -> SimpleNamespace:
"""A seeded household served by the admin-plane app, clock pinned.
``today`` comes from one host clock read; the pinned instant is
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). Both
the admin and the panel route's ``_local_now`` return it, and the
admin one counts its calls.
"""
today = datetime.datetime.now().astimezone().date()
pinned_now = datetime.datetime(
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
)
seed = await _seed_household(temp_db_path, today, pinned_now)
clock_reads: list[datetime.datetime] = []
def _admin_now() -> datetime.datetime:
clock_reads.append(pinned_now)
return pinned_now
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
client=client,
seed=seed,
today=today,
pinned_now=pinned_now,
pinned_utc_iso=pinned_now.astimezone(
datetime.timezone.utc
).isoformat(),
clock_reads=clock_reads,
)
# --- authentication: the ONE require_admin dependency ----------------------
async def test_snapshot_without_credential_is_401(
snapshot_household: SimpleNamespace,
) -> None:
"""No Authorization header is the uniform 401."""
response = await snapshot_household.client.get("/api/v1/admin/snapshot")
assert response.status_code == 401
async def test_snapshot_refuses_panel_service_token_outright(
snapshot_household: SimpleNamespace,
) -> None:
"""The panel service token is refused as a credential type (403).
403 with the credential-type detail — not the uniform 401 and not
the non-admin group detail — proves the token was refused outright,
never tried as a JWT.
"""
response = await snapshot_household.client.get(
"/api/v1/admin/snapshot", headers=_panel_headers()
)
assert response.status_code == 403
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
async def test_snapshot_refuses_non_admin_jwt(
snapshot_household: SimpleNamespace,
) -> None:
"""A valid JWT without the nestquest-admins group is 403."""
non_admin = make_token(groups=("some-other-group",))
response = await snapshot_household.client.get(
"/api/v1/admin/snapshot",
headers={"Authorization": f"Bearer {non_admin}"},
)
assert response.status_code == 403
assert response.json()["detail"] == FORBIDDEN_DETAIL
# --- the payload -----------------------------------------------------------
async def test_admin_jwt_returns_documented_snapshot(
snapshot_household: SimpleNamespace,
) -> None:
"""An admin JWT returns 200 with the documented snapshot shape.
Every field is asserted as an explicit literal, with ONE clock
read for the request. With no missed view in the real snapshot,
the admin body equals the panel body for the same household.
"""
client = snapshot_household.client
seed = snapshot_household.seed
response = await client.get(
"/api/v1/admin/snapshot", headers=_admin_headers()
)
assert response.status_code == 200
assert len(snapshot_household.clock_reads) == 1
body = response.json()
assert body == {
"today_iso": snapshot_household.today.isoformat(),
# Ada's 2-week cycle anchored yesterday => today is cycle day 2.
"cycle_day": 2,
"children": [
{
"child_id": seed.ada.id,
"child_name": "Ada",
"present": False,
"next_present": seed.returns_date.isoformat(),
"due_today": 1,
"completed_today": 0,
"remaining_today": 1,
"completion_pct": 0,
"instances": [
{
"id": seed.pack_bag_instance.id,
"definition_id": seed.pack_bag_instance.definition_id,
"child_id": seed.ada.id,
"title": "Pack bag",
"icon": None,
"window": "morning",
"due_time": "10:00",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
}
],
},
{
"child_id": seed.bo.id,
"child_name": "Bo",
"present": True,
"next_present": None,
"due_today": 1,
"completed_today": 1,
"remaining_today": 0,
"completion_pct": 100,
"instances": [
{
"id": seed.brush_teeth_instance.id,
"definition_id": (
seed.brush_teeth_instance.definition_id
),
"child_id": seed.bo.id,
"title": "Brush teeth",
"icon": None,
"window": "morning",
"due_time": "17:00",
"state": "completed",
"overdue": False,
"completed_at": snapshot_household.pinned_utc_iso,
"on_time": True,
}
],
},
],
}
panel = await client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
assert panel.json() == body
async def test_admin_keeps_missed_instance_panel_omits_it(
snapshot_household: SimpleNamespace, monkeypatch
) -> None:
"""A missed view is KEPT by the admin route, OMITTED by the panel's.
The real builder is wrapped to add the seeded past-due Stale chore
instance to Ada's views, its state derived by the core's own
``derive_state`` (no events, due yesterday => ``missed``). Both
routes read the SAME wrapped snapshot, so the only difference is
each route's ``include_missed`` flag.
"""
client = snapshot_household.client
seed = snapshot_household.seed
stale = seed.stale_instance
missed_state = _completion.derive_state(
stale, None, snapshot_household.today
)
assert missed_state == "missed"
missed_view = core_snapshot.QuestInstanceView(
instance_id=stale.id,
definition_id=stale.definition_id,
child_id=stale.child_id,
title="Stale chore",
icon=None,
window=stale.window,
due_date=stale.due_date,
due_time=stale.due_time,
state=missed_state,
overdue=False,
completed_at=None,
was_on_time=None,
)
real_build_snapshot = core_snapshot.build_snapshot
async def _with_missed_view(database, settings, now):
snapshot = await real_build_snapshot(database, settings, now)
return dataclasses.replace(
snapshot,
children=tuple(
dataclasses.replace(
child, instances=(*child.instances, missed_view)
)
if child.child_id == seed.ada.id
else child
for child in snapshot.children
),
)
monkeypatch.setattr(core_snapshot, "build_snapshot", _with_missed_view)
admin = await client.get(
"/api/v1/admin/snapshot", headers=_admin_headers()
)
assert admin.status_code == 200
admin_ada = admin.json()["children"][0]
assert admin_ada["child_id"] == seed.ada.id
assert [instance["id"] for instance in admin_ada["instances"]] == [
seed.pack_bag_instance.id,
stale.id,
]
assert admin_ada["instances"][1] == {
"id": stale.id,
"definition_id": stale.definition_id,
"child_id": seed.ada.id,
"title": "Stale chore",
"icon": None,
"window": "morning",
"due_time": "09:00",
"state": "missed",
"overdue": False,
"completed_at": None,
"on_time": None,
}
panel = await client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
panel_ada = panel.json()["children"][0]
assert [instance["id"] for instance in panel_ada["instances"]] == [
seed.pack_bag_instance.id
], "the panel payload must still omit missed instances"
+295
View File
@@ -0,0 +1,295 @@
"""Cross-process safety of the missed-sweep watermark claim.
Feature 11's sweep guards its announcement with the watermark; the
original guard was a read-then-set inside a per-PROCESS asyncio lock,
so two PROCESSES sharing one SQLite file (the HA integration's
rollover sweep and the API service's scheduler) could both read the
old watermark and both announce the same missed batch. These tests
pin the fix:
- :meth:`MetaStateDao.claim` is the atomic, database-level primitive:
one conditional upsert whose ``rowcount`` says whether THIS caller
changed the row. The first claimant of a value wins; a second
claimant of the same value — even on an INDEPENDENT connection to
the same file, the shape two processes produce — loses.
- :func:`run_missed_sweep` gates the announcement on that claim: two
independent ``NestQuestDatabase`` wrappers (two processes' shape)
racing one seeded database yields exactly one announcer.
- The documented sweep semantics are unchanged: a same-night rerun
announces nothing, and a run after days of downtime announces the
accumulated ``[watermark, today)`` window exactly once.
Everything here runs against the bundled HA-free core copy
(``custom_components.nestquest.core``) with a plain
``asyncio.to_thread`` executor — no Home Assistant, no API server.
"""
from __future__ import annotations
import asyncio
import datetime
from types import SimpleNamespace
import pytest
from custom_components.nestquest.core.dao_instances import (
QuestInstancesDao,
)
from custom_components.nestquest.core.dao_meta import MetaStateDao
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.core.children import create_child
from custom_components.nestquest.core.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.core.materialize import materialize
from custom_components.nestquest.core.recurrence import ScheduleRule
from custom_components.nestquest.core.sweep import (
SWEEP_WATERMARK_KEY,
run_missed_sweep,
)
#: One missed event per unswept past-due instance — the sweep's only
#: event type.
EVENT_QUEST_MISSED = "nestquest_quest_missed"
async def _executor(fn, *args, **kwargs):
"""Run a synchronous callable on a worker thread and await it.
The :class:`NestQuestDatabase` executor contract (the shape of
``hass.async_add_executor_job``), so sqlite3 calls never block the
event loop.
"""
return await asyncio.to_thread(fn, *args, **kwargs)
async def _open_database(db_path: str) -> NestQuestDatabase:
"""Open (without migrating) one independent connection wrapper."""
database = NestQuestDatabase(_executor)
await database.open(db_path)
return database
async def _seed_missed_household(
db_path: str, today: datetime.date
) -> SimpleNamespace:
"""Seed a household with two past-due open instances on ``db_path``.
One child, two quests whose ONLY instances are due YESTERDAY
(materialized with ``today`` pinned to yesterday, ``end_date``
closing the rules so the later today-walk cannot duplicate them).
Seeding uses its OWN connection, opened and closed here, so the
connections the tests race with start fresh.
"""
database = await _open_database(db_path)
try:
await apply_migrations(database)
today_iso = today.isoformat()
yesterday = today - datetime.timedelta(days=1)
yesterday_iso = yesterday.isoformat()
rule = ScheduleRule.from_dict(
{
"rule_type": "daily",
"start_date": yesterday_iso,
"end_date": yesterday_iso,
}
)
ada = await create_child(database, "Ada", sort_order=0)
await create_quest_definition(
database, "Stale chore", rule, [ada.id], [("morning", "09:00")]
)
await create_quest_definition(
database, "Old chore", rule, [ada.id], [("evening", "18:30")]
)
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
# A quest due TODAY (created after the yesterday walk so it has
# no past-due instance): the post-downtime catch-up run's
# target — inside [watermark=today, later_today) but not
# inside the day-one sweep's window.
await create_quest_definition(
database,
"Recoverable chore",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso}
),
[ada.id],
[("afternoon", "17:00")],
)
await materialize(database, today_iso, today_iso, today=today)
instances = QuestInstancesDao(database)
rows = await instances.list_by_child_and_date(ada.id, yesterday_iso)
assert len(rows) == 2
today_rows = await instances.list_by_child_and_date(
ada.id, today_iso
)
assert len(today_rows) == 1
return SimpleNamespace(
child=ada,
instances=rows,
recoverable_instance=today_rows[0],
yesterday_iso=yesterday_iso,
today_iso=today_iso,
)
finally:
await database.close()
# --- the claim primitive: atomic at the database level ------------------
async def test_claim_first_caller_wins_and_second_same_value_loses(
temp_db_path: str,
) -> None:
"""claim() is a conditional upsert: first True, same-value second False.
A different value always wins (an advance re-arms the check), and
the stored value follows the winner.
"""
database = await _open_database(temp_db_path)
try:
await apply_migrations(database)
dao = MetaStateDao(database)
assert await dao.get(SWEEP_WATERMARK_KEY) is None
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is True
# Same value again: the row is already there — nothing changed.
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is False
assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-01"
# A DIFFERENT value is an advance, not a re-claim: it wins.
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is True
assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-02"
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is False
finally:
await database.close()
async def test_claim_is_atomic_across_independent_connections(
temp_db_path: str,
) -> None:
"""Two connections to one file cannot both claim the same value.
This is the two-process shape: independent sqlite3 connections (one
per ``NestQuestDatabase`` wrapper), racing the SAME key on the SAME
event loop with no shared lock. The single conditional upsert is
serialized by SQLite itself, so exactly one caller's rowcount is 1.
"""
first = await _open_database(temp_db_path)
second = await _open_database(temp_db_path)
try:
await apply_migrations(first)
won = await asyncio.gather(
MetaStateDao(first).claim(SWEEP_WATERMARK_KEY, "2026-09-01"),
MetaStateDao(second).claim(SWEEP_WATERMARK_KEY, "2026-09-01"),
)
assert sorted(won) == [False, True]
# One stored row, holding the winner's value.
assert await MetaStateDao(first).get(SWEEP_WATERMARK_KEY) == (
"2026-09-01"
)
assert await MetaStateDao(second).get(SWEEP_WATERMARK_KEY) == (
"2026-09-01"
)
finally:
await first.close()
await second.close()
# --- the sweep: the announcement is gated by the atomic claim -----------
async def test_two_process_shape_race_exactly_one_announces(
temp_db_path: str,
) -> None:
"""Two independent connections racing the sweep: ONE announcer.
Each wrapper carries its own connection AND its own per-process
sweep lock, so nothing in-process serialises the two runs — the
only guard left is the database-level claim. Both read the unset
watermark and both build the events, but exactly one claim wins:
one run returns the missed events, the loser returns [].
"""
today = datetime.datetime.now().astimezone().date()
seed = await _seed_missed_household(temp_db_path, today)
first = await _open_database(temp_db_path)
second = await _open_database(temp_db_path)
try:
results = await asyncio.gather(
run_missed_sweep(first, today=today),
run_missed_sweep(second, today=today),
)
announcers = [r for r in results if r]
assert len(announcers) == 1
event_type, payload = announcers[0][0]
assert event_type == EVENT_QUEST_MISSED
assert payload["due_date"] == seed.yesterday_iso
# Both instances of the seeded window, one event each.
assert len(announcers[0]) == 2
losers = [r for r in results if not r]
assert losers == [[]]
# The watermark holds the run's date for whoever looks next.
assert (
await MetaStateDao(first).get(SWEEP_WATERMARK_KEY)
== seed.today_iso
)
finally:
await first.close()
await second.close()
async def test_same_night_rerun_announces_nothing(temp_db_path: str) -> None:
"""A second sweep for the same date is an empty no-op (the watermark)."""
today = datetime.datetime.now().astimezone().date()
await _seed_missed_household(temp_db_path, today)
database = await _open_database(temp_db_path)
try:
first = await run_missed_sweep(database, today=today)
assert len(first) == 2
second = await run_missed_sweep(database, today=today)
assert second == []
finally:
await database.close()
async def test_post_downtime_run_announces_accumulated_window_once(
temp_db_path: str,
) -> None:
"""After days of downtime the catch-up run sweeps the window once.
The first run sweeps yesterday's instances (watermark := today);
``today`` then jumps two days and the catch-up run — on a
DIFFERENT connection, the two-process shape — announces the
accumulated ``[watermark, later_today)`` window exactly once, and
a rerun on the original connection is silent again.
"""
today = datetime.datetime.now().astimezone().date()
later_today = today + datetime.timedelta(days=2)
seed = await _seed_missed_household(temp_db_path, today)
first = await _open_database(temp_db_path)
second = await _open_database(temp_db_path)
try:
# Day one: yesterday's two instances fire once.
day_one = await run_missed_sweep(first, today=today)
assert len(day_one) == 2
assert all(
payload["due_date"] == seed.yesterday_iso
for _, payload in day_one
)
# The downtime catch-up: the window is [watermark, later_today).
catchup = await run_missed_sweep(second, today=later_today)
assert len(catchup) == 1
event_type, payload = catchup[0]
assert event_type == EVENT_QUEST_MISSED
assert payload["due_date"] == seed.today_iso
assert payload["instance_id"] == seed.recoverable_instance.id
assert payload["child_id"] == seed.child.id
# A further rerun announces nothing.
assert await run_missed_sweep(first, today=later_today) == []
assert (
await MetaStateDao(first).get(SWEEP_WATERMARK_KEY)
== later_today.isoformat()
)
finally:
await first.close()
await second.close()
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.5.0"
version = "0.6.0"
source = { virtual = "." }
[package.dev-dependencies]