Compare commits

..
Author SHA1 Message Date
overseer cf7e162cbc Add core presence write layer and admin presence routes
The presence business operations (schedule upsert, override
create/delete, each followed by regenerate_for_child) lived only in the
HA-coupled service handlers, so the API admin plane could not reuse
them.  Extract them into core/presence_management.py — model-validated
PresenceSchedule/PresenceOverride construction, the typed DAO calls
and the regeneration side effect in ONE Home-Assistant-free
implementation — and reduce the three HA service handlers to thin
wrappers over it.

Expose the admin plane's presence routes on the existing admin router
(PUT /children/{child_id}/presence-schedule, POST
/presence-overrides, DELETE /presence-overrides/{override_id}): the
body models carry only the shape, validation stays in the presence
model, and the error mapping stays narrow (unknown child/override 404,
rejected schedule/override 422).  Tests drive all three routes as an
admin, prove the schedule upserts in place, an override is created and
then deleted, and the child's resolved presence (PresenceEngine)
changes accordingly.
2026-09-23 02:18:53 -04:00
overseer e85180d492 Admin routes: quest definitions (#159)
Co-authored-by: Joshua <joshua@cnjmail.com>
Co-committed-by: Joshua <joshua@cnjmail.com>
2026-09-23 01:54:41 -04:00
dev_agent 0b7f71831b Admin routes: children (#158) 2026-09-23 01:23:57 -04:00
dev_agent a30b852789 Admin auth: nestquest-admins group gate and service-token rejection (#157) 2026-09-23 00:59:39 -04:00
dev_agent ca1165de3f Admin auth: Authentik JWT validation (#156) 2026-09-23 00:37:30 -04:00
dev_agent d4a55b6b40 SSE: publish uncompleted, child-day-complete and missed (#155) 2026-09-22 23:25:06 -04:00
dev_agent a07ce1b2f2 SSE endpoint and the quest-completed transition (#154) 2026-09-22 23:08:03 -04:00
dev_agent 550a8b3e1e Panel plane: complete route (idempotent) (#153) 2026-09-22 22:38:48 -04:00
dev_agent 4da762173c Panel plane: service-token auth and snapshot route (#152) 2026-09-22 22:23:16 -04:00
dev_agent ca477125d6 Extract the panel snapshot builder into core (task 9047a97b)
Extract the panel snapshot builder into core (task 9047a97b)
2026-09-21 17:43:51 -04:00
dev_agent 7fec3c76ef FastAPI skeleton: health, OpenAPI, core wiring (task 042dfc5c)
FastAPI skeleton: health, OpenAPI, core wiring (task 042dfc5c)
2026-09-21 16:13:41 -04:00
154 changed files with 7767 additions and 19557 deletions
-32
View File
@@ -1,32 +0,0 @@
---
description: Independently verify NestQuest merge gates and merge approved heads
mode: primary
model: openrouter/deepseek/deepseek-v4.1-flash
permission:
edit: deny
write: deny
task: deny
bash:
"*": deny
"/home/overseer/.local/share/nestquest-controller/scripts/nq-approve-merge *": allow
external_directory:
"*": ask
"/tmp/opencode/*": allow
"/home/overseer/.local/share/nestquest-controller/*": allow
---
You are the NestQuest feature-to-dev approver, separate from the controller and
developer sessions. Verify the current PR head equals the independently
reviewed and approved commit,
required checks pass, blocking findings are resolved, branch protection is
satisfied, and Maestro has accurate links. Use only the approver Gitea identity
for the merge. Do not change code or approve a stale head. Use only the
installed nq-approve-merge launcher for Gitea inspection and merging. Merge
only into dev; never merge into main. If any gate is
missing, refuse the merge and give the exact missing evidence. Report the PR,
reviewed head, resulting merge commit, and target branch.
Broker syntax: `nq-approve-merge inspect PR_NUMBER` or
`nq-approve-merge merge PR_NUMBER REVIEWED_HEAD REVIEW_FILE`. Run one broker
command per Bash call. The broker validates the current PR and review again
before merging; do not substitute an ad hoc Gitea command.
-21
View File
@@ -1,21 +0,0 @@
---
description: Implement one assigned NestQuest task in an isolated worktree
mode: primary
model: openrouter/z-ai/glm-5.3-flash
permission:
task: deny
external_directory:
"*": ask
"/tmp/opencode/*": allow
---
You are the NestQuest development agent. Implement only the assigned Maestro
task in the supplied worktree. Follow the task's acceptance criteria and the
repository's instructions. Run relevant tests, commit on the task branch, and
report the commit and test results. Keep changes inside the assigned worktree.
Do not approve your own pull request. You may merge your task PR into the
feature branch only after the independent reviewer approved its exact current
head and the controller verified every task merge gate. Never merge a feature
into dev or a release into main. Do not start another task, change role, or
use another model. Do not read or print credential files except when the
approved task merge requires the existing Gitea development credential.
-60
View File
@@ -1,60 +0,0 @@
---
description: Reconcile NestQuest work and dispatch the next verified action
mode: primary
model: openrouter/deepseek/deepseek-v4.1-flash
permission:
doom_loop: deny
task: deny
external_directory:
"*": ask
"/tmp/opencode/*": allow
"/home/overseer/.local/share/opencode/tool-output/*": allow
---
You are the NestQuest controller. Work in the NestQuest repository. Maestro is
the source of truth for feature and task state; CTXD is the source of truth for
project instructions. At the start of every invocation, read the current
NestQuest context in CTXD, including OPENCODE-ORCHESTRATOR-PROMPT.MD, then
reconcile it with Maestro, Git, pull requests, and checks before dispatching.
Follow the review, identity, and branch rules in that prompt.
If the CTXD project or prompt is unavailable, do not dispatch or merge work;
report the missing source and end with ORCHESTRATOR_STATE: BLOCKED.
One invocation is a controller tick, not an entire feature lifetime. Prefer a
state-changing action over another identical status query. Never repeat an
unchanged tool call three times. If work is asynchronous, wait or inspect a
specific changed result. Do not claim that you are continuing while ending
the turn without naming the next action.
The NESTQUEST_CONTROL_HOME environment variable names the installed launchers
(normally /home/overseer/.local/share/nestquest-controller).
For implementation, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role
developer, a task worktree, title, and task prompt file. For independent
review, run "$NESTQUEST_CONTROL_HOME/scripts/nq-review" with the worktree,
feature base ref, exact head ref, review prompt file, PR number, and an output
file under /tmp/opencode. It publishes an exact-head Gitea approval only after
Claude approves. The developer may merge
a task PR into its feature branch only after independent approval of the exact
head and verification of every task gate. For feature-to-dev approval and
merge, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role approver in
a separate session after the independent review. Save the Claude review output
in a file under /tmp/opencode and give that file and its reviewed head to the
approver. Never substitute a different model or reviewer silently.
When a feature is integrated into dev, reconcile Maestro, then immediately
select and dispatch the next eligible feature. Feature completion is a
transition, not the end of the controller's job. A release awaiting Joshua's
main merge does not prevent work on independent eligible features.
End each invocation with exactly one final line using one of these values:
ORCHESTRATOR_STATE: CONTINUE
ORCHESTRATOR_STATE: QUIESCENT
ORCHESTRATOR_STATE: BLOCKED
Use CONTINUE when there is any eligible or in-flight work; include the feature,
task, and next concrete action immediately above the final line. Use QUIESCENT
only after verifying no eligible or in-flight work remains in Maestro and the
repository. Use BLOCKED only when a specific decision or access is required;
include evidence and the needed human action. If you cannot establish the
state, use BLOCKED. Never emit a success claim based only on a child's summary.
-31
View File
@@ -1,31 +0,0 @@
---
description: Run the NestQuest orchestration loop in this foreground terminal
mode: primary
model: openrouter/deepseek/deepseek-v4.1-flash
permission:
doom_loop: deny
task: deny
external_directory:
"*": ask
"/tmp/opencode/*": allow
"/home/overseer/.local/share/opencode/tool-output/*": allow
---
You are the foreground NestQuest orchestrator. The user explicitly launched
this session to run the development queue now. Read the supplied terminal
orchestrator prompt and current CTXD project context, but follow the
FOREGROUND TERMINAL RUNTIME for this session instead of CTXD's service runtime
section.
Continuously reconcile Maestro, CTXD, Git, pull requests, checks, and child
agent results. Execute the next useful transition in this same session. Do
not finish after dispatch, review, remediation, or merge while eligible work
remains. Use the installed role launchers for developer, reviewer, and
approver isolation. Never start or wait for the systemd controller service.
Never repeat an identical unchanged tool call three times; wait on the
specific child or inspect newly changed evidence.
End only at a global terminal condition, an explicit user stop, a genuine
global access/decision block with no other eligible work, or a host-forced
yield. On a forced yield, return a continuation checkpoint that can be
resumed in the same OpenCode session.
-11
View File
@@ -1,11 +0,0 @@
---
description: Run the NestQuest orchestrator now in this OpenCode terminal
agent: terminal-orchestrator
---
Run the NestQuest project queue now in this foreground OpenCode session.
Read the current CTXD prompt and Maestro state, but use the FOREGROUND
TERMINAL RUNTIME instead of the service runtime. Reconcile repository and
pull-request state, then execute CONTROLLER_TICK repeatedly until a global
terminal condition, explicit user stop, genuine global block, or host-forced
yield. Do not start or wait for the systemd controller.
-77
View File
@@ -1,77 +0,0 @@
# NestQuest foreground orchestrator
OpenCode 1.18.32 loads the named agents from `.opencode/agents/`. Their
models match the current NestQuest CTXD prompt: DeepSeek V4.1 Flash for the
foreground orchestrator and approver, and GLM 5.3 Flash for development.
Run orchestration directly from a shell:
```bash
nq-orchestrate
nq-orchestrate mission.md
cat mission.md | nq-orchestrate -
nq-orchestrate --print-prompt > nestquest-orchestrator.md
```
The launcher renders the project prompt with the foreground terminal runtime,
stops the dormant legacy service, refuses immediately when another NestQuest
TUI is open, and then replaces itself with OpenCode using
`terminal-orchestrator`. There is no background polling process and no wait
for a controller tick. Exit or interrupt the TUI to stop the foreground run;
resume that OpenCode session when a host-forced yield needs continuation.
`scripts/nq-agent` launches developer and approver sessions with fixed agent
definitions and no `--auto` flag. Headless OpenCode runs use `--pure` so the
global idle-notification plugin does not notify on every controller tick.
The approver's shell permission permits only the installed
`nq-approve-merge` broker for Gitea. That broker requires exact-head
Claude approval evidence, an exact-head Gitea approval by `review_agent`,
the `dev` target, a mergeable PR, and passing configured checks before it
requests a squash merge with Gitea's atomic `head_commit_id` guard.
`scripts/nq-review` launches the independent
Claude review in a noninteractive read-only session in the task worktree,
checks the head again afterward, saves
the output under `/tmp/opencode`, and posts the Gitea approval.
The approver verifies the reviewed head again before a merge.
The terminal launcher uses a stable copy of this configuration and scripts
at `/home/overseer/.local/share/nestquest-controller`; it does not depend on
which task branch is checked out in the primary NestQuest directory. Install
that copy, run `scripts/nq-controller --check`, and verify the OpenCode
agents (terminal-orchestrator, developer, and approver)
with `opencode debug agent <name>` while `OPENCODE_CONFIG_DIR` points at the
installed `.opencode` directory. Install `scripts/nq-orchestrate` in
`~/.local/bin/`. For the global slash command, also install
`.opencode/agents/terminal-orchestrator.md` under
`~/.config/opencode/agents/` and
`deploy/opencode-global-commands/start-nestquest-development.md` under
`~/.config/opencode/commands/`. The old `nestquest-controller.service` remains static and
inactive only for migration safety; foreground launch stops it.
Inside an already-open OpenCode TUI, `/start-development` (or the global
`/start-nestquest-development`) switches to the same foreground
`terminal-orchestrator` behavior immediately. The shell launcher is the
preferred entry point when supplying a mission prompt file.
`templates/ORCHESTRATOR-PROMPT.template.md` and
`templates/nestquest.profile.json` make the current CTXD cycle prompt
adaptable to another project. See `templates/README.md` before publishing a
rendered prompt for a new project.
The CTXD preflight reads the configured HTTP/MCP server's NestQuest prompt.
It deliberately does not inspect `/home/overseer/.ctx/ctxd.db`, which belongs
to a different local store and lacks this project.
All four agent credentials are currently readable by the same Unix account.
The Gitea review and merge gates enforce normal workflow sequencing, but
they are not a hard isolation boundary against a process running as that
account. Strong isolation requires separate OS identities and protected
credentials for the controller, reviewer, and approver.
The terminal runtime repeatedly executes CONTROLLER_TICK within the foreground
session. Neither a feature merge nor a progress report ends the run while
eligible work remains. A host-forced yield returns a continuation checkpoint
that can be resumed in the same OpenCode session.
Do not put tokens in this repository or in unit files. Existing OpenCode MCP
connections supply CTXD and Maestro. Rotate those MCP tokens as a coordinated
operation across every consumer before revoking the old values.
+56 -132
View File
@@ -159,50 +159,9 @@ Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
Run these steps in order to display the NestQuest panel on a TouchHub
wall device. The panel is an HA Lovelace dashboard, so the kiosk browser
is simply a Home Assistant client. The path below is zero-config: the
integration registers its panel dashboard itself on setup, so no YAML
and no hand-built views are involved.
is simply a Home Assistant client.
1. **Install the integration and add the config entry.** Install
NestQuest via HACS or manually (see Installation above) and restart
Home Assistant. Then, in *Settings → Devices & Services*, click
**Add Integration**, search for **NestQuest**, and submit the flow —
it has no fields; it just creates the single config entry. Then open
the entry, click **Configure**, and fill the panel-plane API
connection in the options flow:
- **NestQuest API base URL** — the full `http://host:port` URL of
your NestQuest API service (default `http://127.0.0.1:8000`).
- **Panel service token** — the token the panel presents to the API
service as its Bearer credential. It may stay empty until you have
one; a pasted token must be unpadded.
The form's remaining fields (panel idle timeout, coordinator update
interval, last-good snapshot staleness) have defaults that serve the
panel and need no change here.
2. **The panel dashboard appears automatically.** On setup the
integration registers one storage-mode Lovelace dashboard: a sidebar
entry titled **NestQuest** at the URL path `/nestquest`, whose stored
config is nothing but the **`custom:nestquest-party` strategy**
(`strategy: { type: custom:nestquest-party }`). The strategy
generates the views at render time from the household roster:
- **The Party** — the party-board view at `/nestquest/party`; the
dashboard root `/nestquest` shows it as the first view.
- one quest-log view per child at `/nestquest/<child-slug>`
("Ada's Quest Log", …), in roster order.
Nothing is hand-configured: no YAML, no manual views, no
`child_order`, no `quest_log_path`/`board_path` — the board and the
quest logs compute their navigation paths from the dashboard's own
URL — and every generated view is already in **Panel** layout (see
the two constraints below). If the Lovelace surfaces are unavailable
(for example when Lovelace runs in YAML mode), setup logs a warning
and skips the registration; create the dashboard manually with the
same strategy as described in the manual alternative below.
3. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
a new user for the wall device. Make it **non-admin**, and ensure it is
not in the NestQuest admin allowlist. The allowlist blocks the NestQuest
**admin** operations — `uncomplete_quest`, `create_quest_definition`,
@@ -215,101 +174,66 @@ and no hand-built views are involved.
Assistant user it may also reach other permitted Home Assistant entities
and services.
4. **Open the dashboard and point TouchHub at it.** Open
`http://<home-assistant>/nestquest` in a browser first and check the
party board renders with your children's crests. Then, in the
TouchHub launcher, add the **Home Assistant Lovelace** app to the
dock and paste the dashboard URL from step 2 — the dashboard root,
`/nestquest` (TouchHub loads that URL directly as the kiosk landing
page). Set TouchHub **Auto-Return** to a value GREATER than the
quest log's `idle_return_seconds` (default 40s) — recommend **60s or
higher**. If Auto-Return is at or below 40s, TouchHub returns to its
launcher before the quest log's idle return can navigate back to the
party board.
2. **Build a dashboard with only the NestQuest panel cards.** Create a
Lovelace dashboard whose **URL slug is `nestquest`** (Settings →
Dashboards → Edit → URL slug). A functional panel needs **two** views
in that dashboard, each with its own card config from
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
paths (the path segment after the dashboard slug):
5. **Log the kiosk session in.** Sign in on the device as the dedicated
kiosk user from step 3, and leave that session logged in.
- **View 1 path `board`** (URL `/nestquest/board`) — party board
(`type: custom:nestquest-party-board-card`): the household quest
board. Set `weather_entity` (optional; the dock falls back to
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
child's slug in display order.
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
the party board hops to. Set `board_path: /nestquest/board` (the
party-board view to return to), `weather_entity`,
`idle_return_seconds` (must be shorter than TouchHub Auto-Return),
`confirm_timeout_seconds`, and `complete_screen_seconds`. The quest
log reads the selected child's slug from the view's URL path, so the
board → log hop is a plain Lovelace navigation.
Each view's layout MUST be set to **Panel** (Edit Dashboard → pencil on the
view → View type: Panel). Home Assistant's default Masonry layout constrains
the 1080px-tall NestQuest card to a narrow column; Panel (single-card)
layout is what renders the card full-screen at 1920×1080.
The dashboard contains only the NestQuest panel card for each view —
no other cards, no admin controls.
The `quest_log_path` and `board_path` values must match the dashboard
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
as the dashboard slug with view paths `board` and `log`, the card
configs are already correct. If you pick different names, substitute
your actual paths in **both** card configs — e.g. a dashboard slug
`chores` with view paths `main` and `history` needs
`quest_log_path: /chores/history` and `board_path: /chores/main`.
Navigation between the two views fails (the crest tap or back button
lands on a 404) if the card config paths do not match the dashboard
slug and view paths exactly.
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
3. **Point TouchHub at it.** In the TouchHub launcher, add the **Home
Assistant Lovelace** app to the dock and paste the dashboard URL from
step 2 — the party-board view URL, e.g. `/nestquest/board` (TouchHub
loads that URL directly as the kiosk landing page). Set TouchHub
**Auto-Return** to a value GREATER than the quest log's
`idle_return_seconds` (default 40s) — recommend **60s or higher**. If
Auto-Return is at or below 40s, TouchHub returns to its launcher before
the quest log's idle return can navigate back to the party board.
4. **Log the kiosk session in.** Sign in on the device as the dedicated
kiosk user from step 1, and leave that session logged in.
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
Lovelace cards, not an iframe, so no Home Assistant framing setting is
needed.
### The two constraints
- **Panel layout.** Every view must be in **Panel** (single-card)
layout: Home Assistant's default Masonry layout constrains the
1080px-tall NestQuest card to a narrow column, while Panel renders the
card full-screen at 1920×1080. The zero-config path already satisfies
the requirement — the strategy generates every view as
`type: panel` — while the manual alternative below requires setting
the layout by hand on each view (Edit Dashboard → pencil on the view
→ View type: Panel).
- **TouchHub Auto-Return.** TouchHub's **Auto-Return** must be greater
than the quest log's `idle_return_seconds` (default 40s) — recommend
**60s or higher** — so the launcher does not reclaim the wall device
before the quest log's own idle return navigates back to the party
board.
### Manual alternative: build the dashboard by hand
If you prefer not to use the zero-config dashboard above — or its
registration was skipped — configure a Lovelace dashboard whose **URL
slug is `nestquest`** (Settings → Dashboards → Edit → URL slug)
yourself. Two options, in decreasing order of convenience:
- **Use the strategy.** Give the dashboard
```yaml
strategy:
type: custom:nestquest-party
```
and no views of your own — the strategy generates the party-board and
quest-log views (each already in Panel layout) from the household
roster at render time. Optional `url_path` and `weather_entity` keys
may sit beside `strategy:`.
- **Build the two views by hand.** A functional panel needs **two**
views in that dashboard, each with its own card config from
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
paths (the path segment after the dashboard slug):
- **View 1 path `board`** (URL `/nestquest/board`) — party board
(`type: custom:nestquest-party-board-card`): the household quest
board. Set `weather_entity` (optional; the dock falls back to
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
child's slug in display order.
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
the party board hops to. Set `board_path: /nestquest/board` (the
party-board view to return to), `weather_entity`,
`idle_return_seconds` (must be shorter than TouchHub Auto-Return —
see the two constraints above), `confirm_timeout_seconds`, and
`complete_screen_seconds`. The quest log reads the selected child's
slug from the view's URL path, so the board → log hop is a plain
Lovelace navigation.
The dashboard contains only the NestQuest panel card for each view —
no other cards, no admin controls. Each view's layout MUST be set to
**Panel** — see the two constraints above for why; the strategy
generates it for you, hand-built views do not get it for free.
The `quest_log_path` and `board_path` values must match the dashboard
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
as the dashboard slug with view paths `board` and `log`, the card
configs are already correct. If you pick different names, substitute
your actual paths in **both** card configs — e.g. a dashboard slug
`chores` with view paths `main` and `history` needs
`quest_log_path: /chores/history` and `board_path: /chores/main`.
Navigation between the two views fails (the crest tap or back button
lands on a 404) if the card config paths do not match the dashboard
slug and view paths exactly.
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
## Developer Setup and Testing
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
+21 -129
View File
@@ -1,135 +1,27 @@
# NestQuest Release Notes
## Version 0.6.0 — 2026-09-23
## Unreleased
### 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`.
- **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.
## Version 0.5.0 — 2026-09-18
-13
View File
@@ -1,13 +0,0 @@
# NestQuest Admin PWA — runtime configuration (Vite env).
# Copy to .env (or .env.local) and fill in real values. NEVER commit .env.
# Authentik OIDC provider. Defaults to the issuer below when unset.
VITE_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/
# REQUIRED. OAuth2/OIDC client id of the PUBLIC (PKCE) Authentik application.
# No default: the app shows a configuration error screen when this is missing.
# Never set a client_secret — this is a browser SPA using PKCE only.
VITE_AUTHENTIK_CLIENT_ID=
# NestQuest API base URL.
VITE_API_BASE_URL=https://nestquest.cubecraftlabs.com
-2
View File
@@ -1,2 +0,0 @@
node_modules/
dist/
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>NestQuest Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-3024
View File
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,27 +0,0 @@
{
"name": "nestquest-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"jsdom": "^30.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5",
"vitest": "^3.2.4"
}
}
-117
View File
@@ -1,117 +0,0 @@
/* Shell layout — mobile-first (390x844 reference), plain CubeCraft styling. */
.admin-shell {
display: flex;
flex-direction: column;
min-height: 100%;
}
.admin-header {
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
padding: 14px 16px;
}
.admin-title {
margin: 0;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.02em;
}
.admin-subline {
margin: 2px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.admin-panel {
flex: 1;
padding: 14px 16px 92px; /* bottom padding clears the 70px tab bar */
}
.admin-placeholder {
margin: 0;
padding: 13px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: 8px;
font-size: 13.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.admin-tabbar {
position: fixed;
inset: auto 0 0;
display: flex;
min-height: calc(var(--nq-a-tabbar-height) + 12px);
padding: 8px 4px calc(12px + env(safe-area-inset-bottom));
background: var(--nq-a-surface);
border-top: 1px solid var(--nq-a-border);
}
.admin-tab {
flex: 1;
border: 0;
background: none;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
cursor: pointer;
padding: 6px 4px;
}
.admin-tab.active {
font-weight: 700;
color: var(--nq-brand-blue);
}
/* Auth screens (login, errors, refusal). */
.admin-login,
.admin-error,
.admin-refusal,
.admin-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 100%;
padding: 32px 16px;
text-align: center;
}
.admin-login-button {
margin-top: 8px;
padding: 12px 24px;
border: none;
border-radius: 8px;
background: var(--nq-a-accent, #2563eb);
color: #fff;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.admin-error,
.admin-refusal {
color: var(--nq-a-ink, inherit);
}
.admin-refusal h1,
.admin-error h1 {
margin: 0;
font-size: 20px;
}
.admin-refusal p,
.admin-error p {
margin: 0;
max-width: 34em;
font-size: 14px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
-33
View File
@@ -1,33 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import App from "./App";
describe("NestQuest Admin shell", () => {
afterEach(cleanup);
it("renders the title and all four tab labels", () => {
render(<App />);
expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
}
});
it("starts on the Today placeholder and switches on tab click", () => {
render(<App />);
expect(screen.getByRole("tabpanel").textContent).toContain(
"Today placeholder",
);
fireEvent.click(screen.getByRole("tab", { name: "History" }));
expect(screen.getByRole("tabpanel").textContent).toContain(
"History placeholder",
);
expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe(
"true",
);
});
});
-49
View File
@@ -1,49 +0,0 @@
import { useState } from "react";
import "./App.css";
const TABS = [
{ id: "today", label: "Today" },
{ id: "tasks", label: "Tasks" },
{ id: "schedule", label: "Schedule" },
{ id: "history", label: "History" },
] as const;
type TabId = (typeof TABS)[number]["id"];
export default function App() {
const [active, setActive] = useState<TabId>("today");
return (
<div className="admin-shell">
<header className="admin-header">
<h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Admin console</p>
</header>
<main
className="admin-panel"
role="tabpanel"
aria-labelledby={`tab-${active}`}
>
<p className="admin-placeholder">
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
arrive in later tasks.
</p>
</main>
<nav className="admin-tabbar" aria-label="Admin sections">
{TABS.map((tab) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
type="button"
role="tab"
aria-selected={tab.id === active}
className={tab.id === active ? "admin-tab active" : "admin-tab"}
onClick={() => setActive(tab.id)}
>
{tab.label}
</button>
))}
</nav>
</div>
);
}
-73
View File
@@ -1,73 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import AppRoutes from "./AppRoutes";
import { storeTokens } from "./auth/oidc";
import { config } from "./config";
// The bundled config reads real env at import time; seed the shared module's
// clientId directly so AppRoutes sees a configured app in these tests.
(config as { clientId: string | undefined }).clientId = "test-client-id";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("AppRoutes auth gating", () => {
afterEach(() => {
cleanup();
sessionStorage.clear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
window.history.replaceState({}, "", "/");
});
it("renders the login screen when unauthenticated at /", () => {
render(<AppRoutes />);
expect(screen.getByTestId("login-screen")).toBeTruthy();
expect(
screen.getByRole("button", { name: /sign in with authentik/i }),
).toBeTruthy();
});
it("renders the four-tab shell after a successful authenticated probe", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
});
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
}
});
it("renders the explicit refusal when the API answers 403", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
vi.stubGlobal("fetch", fetchMock);
render(<AppRoutes />);
await waitFor(() => {
expect(screen.getByTestId("refusal-screen")).toBeTruthy();
});
expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy();
});
it("shows the configuration error screen when the client id is missing", () => {
const original = config.clientId;
(config as { clientId: string | undefined }).clientId = undefined;
try {
render(<AppRoutes />);
expect(screen.getByTestId("config-error-screen")).toBeTruthy();
} finally {
(config as { clientId: string | undefined }).clientId = original;
}
});
});
-176
View File
@@ -1,176 +0,0 @@
/**
* Minimal path-based routing without react-router-dom: the app only needs two
* paths (`/` shell and `/auth/callback`), so a tiny window.location switch is
* enough and keeps the bundle small. Change screens listen to popstate.
*/
import { useEffect, useState } from "react";
import { ConfigError, config } from "./config";
import {
AuthError,
buildAuthorizeUrl,
clearAuth,
handleCallback,
isAuthenticated,
} from "./auth/oidc";
import { ApiForbiddenError, apiFetch } from "./api/client";
import AdminShell from "./App";
type Screen =
| { kind: "loading" }
| { kind: "login" }
| { kind: "config-error"; message: string }
| { kind: "auth-error"; message: string }
| { kind: "refusal"; message: string }
| { kind: "app" };
function currentPath(): string {
return window.location.pathname.replace(/\/+$/, "") || "/";
}
async function probeApi(setScreen: (screen: Screen) => void) {
// A cheap authenticated probe so a 403 surfaces as an explicit refusal
// right after login instead of a silent empty shell.
try {
await apiFetch("/api/v1/children", {}, config);
setScreen({ kind: "app" });
} catch (error) {
if (error instanceof ConfigError) {
setScreen({ kind: "config-error", message: error.message });
return;
}
if (error instanceof ApiForbiddenError) {
setScreen({ kind: "refusal", message: error.message });
return;
}
// Network or other failures still allow the shell to render; API screens
// handle their own errors in later tasks.
setScreen({ kind: "app" });
}
}
function LoginScreen({ onLogin }: { onLogin: () => void }) {
return (
<div className="admin-login" data-testid="login-screen">
<h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Sign in to continue</p>
<button type="button" className="admin-login-button" onClick={onLogin}>
Sign in with Authentik
</button>
</div>
);
}
export default function AppRoutes() {
const [screen, setScreen] = useState<Screen>({ kind: "loading" });
useEffect(() => {
// Re-evaluate on navigation (popstate) — routing is path based.
const onPop = () => setScreen({ kind: "loading" });
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
useEffect(() => {
let cancelled = false;
const path = currentPath();
if (!config.clientId) {
setScreen({
kind: "config-error",
message:
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
});
return;
}
if (path === "/auth/callback") {
(async () => {
try {
await handleCallback(window.location.search, config);
if (cancelled) return;
window.history.replaceState({}, "", "/");
await probeApi((next) => !cancelled && setScreen(next));
} catch (error) {
if (cancelled) return;
const message =
error instanceof AuthError || error instanceof Error
? error.message
: "Authentication failed.";
setScreen({ kind: "auth-error", message });
}
})();
return () => {
cancelled = true;
};
}
if (isAuthenticated()) {
void probeApi((next) => !cancelled && setScreen(next));
} else {
setScreen({ kind: "login" });
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startLogin = async () => {
try {
const url = await buildAuthorizeUrl(config);
window.location.assign(url);
} catch (error) {
setScreen({
kind: "config-error",
message: error instanceof Error ? error.message : "Login failed.",
});
}
};
switch (screen.kind) {
case "loading":
return (
<div className="admin-loading" role="status" data-testid="loading-screen">
Loading…
</div>
);
case "login":
return <LoginScreen onLogin={startLogin} />;
case "config-error":
return (
<div className="admin-error" role="alert" data-testid="config-error-screen">
<h1>Configuration error</h1>
<p>{screen.message}</p>
</div>
);
case "auth-error":
return (
<div className="admin-error" role="alert" data-testid="auth-error-screen">
<h1>Sign-in failed</h1>
<p>{screen.message}</p>
<button
type="button"
onClick={() => {
clearAuth();
window.history.replaceState({}, "", "/");
setScreen({ kind: "loading" });
}}
>
Try again
</button>
</div>
);
case "refusal":
return (
<div className="admin-refusal" role="alert" data-testid="refusal-screen">
<h1>Access refused</h1>
<p>{screen.message}</p>
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
Sign out
</button>
</div>
);
case "app":
return <AdminShell />;
}
}
-94
View File
@@ -1,94 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ApiForbiddenError,
ApiUnauthorizedError,
apiFetch,
} from "./client";
import { storeTokens } from "../auth/oidc";
import type { AppConfig } from "../config";
const app: AppConfig = {
issuer: "https://auth.example.com/application/o/nestquest",
clientId: "test-client-id",
apiBaseUrl: "https://api.example.com",
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
tokenEndpoint: "https://auth.example.com/application/o/token/",
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
redirectUri: "http://localhost:5173/auth/callback",
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("API client", () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("attaches the Bearer token to API calls", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
await apiFetch("/api/v1/children", {}, app, fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://api.example.com/api/v1/children");
expect(init.headers.Authorization).toBe("Bearer at-123");
});
it("throws ApiForbiddenError on 403 (authenticated but refused)", async () => {
storeTokens({ access_token: "at-123", expires_in: 600 });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
await expect(
apiFetch("/api/v1/children", {}, app, fetchMock as any),
).rejects.toBeInstanceOf(ApiForbiddenError);
});
it("triggers a login redirect on 401 with no refresh token", async () => {
storeTokens({ access_token: "stale", expires_in: 600 });
const assign = vi.fn();
vi.stubGlobal("location", { ...window.location, assign });
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
await expect(
apiFetch("/api/v1/children", {}, app, fetchMock as any),
).rejects.toBeInstanceOf(ApiUnauthorizedError);
expect(assign).toHaveBeenCalledTimes(1);
const url = new URL(assign.mock.calls[0][0] as string);
expect(url.origin + url.pathname).toBe(
"https://auth.example.com/application/o/authorize/",
);
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
});
it("preserves non-401/403 statuses on the post-refresh retry (500 is not mislabeled)", async () => {
storeTokens({ access_token: "stale", refresh_token: "rt-1", expires_in: 600 });
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("", { status: 401 })) // initial API call
.mockResolvedValueOnce(
jsonResponse({ access_token: "fresh", expires_in: 600 }), // refresh grant
)
.mockResolvedValueOnce(new Response("", { status: 500 })); // retry
const response = await apiFetch("/api/v1/children", {}, app, fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls[1][0]).toBe(app.tokenEndpoint);
expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe("Bearer fresh");
// A server failure must surface as the real status, not ApiUnauthorizedError.
expect(response.status).toBe(500);
});
});
-93
View File
@@ -1,93 +0,0 @@
/**
* Minimal fetch wrapper for the NestQuest API.
*
* - Attaches `Authorization: Bearer <access_token>` when a token is stored.
* - 401: not authenticated (or token expired) -> trigger a new login.
* - 403: authenticated but refused -> throws ApiForbiddenError so the UI can
* render an explicit refusal message.
*/
import { config, type AppConfig } from "../config";
import {
buildAuthorizeUrl,
getToken,
isExpired,
refreshAccessToken,
} from "../auth/oidc";
export class ApiForbiddenError extends Error {
constructor(message = "Your account is not in the nestquest-admins group.") {
super(message);
this.name = "ApiForbiddenError";
}
}
export class ApiUnauthorizedError extends Error {
constructor(message = "Not authenticated.") {
super(message);
this.name = "ApiUnauthorizedError";
}
}
export interface ApiRequestOptions extends Omit<RequestInit, "headers"> {
headers?: Record<string, string>;
}
/**
* Redirect the browser to Authentik to start a new login.
* Exported for testing injection.
*/
export async function triggerLogin(app: AppConfig = config): Promise<void> {
const url = await buildAuthorizeUrl(app);
window.location.assign(url);
}
export async function apiFetch(
path: string,
options: ApiRequestOptions = {},
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<Response> {
if (isExpired() && (await refreshAccessToken(app, fetchImpl))) {
// token renewed; fall through and use the fresh one
}
const token = getToken();
const headers = {
...(options.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const response = await fetchImpl(`${app.apiBaseUrl}${path}`, {
...options,
headers,
});
if (response.status === 401) {
// Not authenticated: either no token or an unusable one.
if (await refreshAccessToken(app, fetchImpl)) {
const retryToken = getToken();
const retryResponse = await fetchImpl(`${app.apiBaseUrl}${path}`, {
...options,
headers: {
...(options.headers ?? {}),
...(retryToken ? { Authorization: `Bearer ${retryToken}` } : {}),
},
});
if (retryResponse.status === 403) throw new ApiForbiddenError();
if (retryResponse.ok) return retryResponse;
// Only 401 means "unauthorized"; preserve every other status (e.g. a
// 500 from the API) so callers can react to the real response.
if (retryResponse.status === 401) throw new ApiUnauthorizedError();
return retryResponse;
}
await triggerLogin(app);
throw new ApiUnauthorizedError();
}
if (response.status === 403) {
// Authenticated, but Authentik's token does not belong to nestquest-admins.
throw new ApiForbiddenError();
}
return response;
}
-173
View File
@@ -1,173 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildAuthorizeUrl,
clearAuth,
getToken,
handleCallback,
logout,
storeTokens,
} from "./oidc";
import type { AppConfig } from "../config";
function testApp(): AppConfig {
return {
issuer: "https://auth.example.com/application/o/nestquest",
clientId: "test-client-id",
apiBaseUrl: "https://api.example.com",
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
tokenEndpoint: "https://auth.example.com/application/o/token/",
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
redirectUri: "http://localhost:5173/auth/callback",
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("OIDC flow", () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("buildAuthorizeUrl carries all required params and stores state/verifier", async () => {
const app = testApp();
const url = new URL(await buildAuthorizeUrl(app));
expect(url.origin + url.pathname).toBe(app.authorizeEndpoint);
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("client_id")).toBe("test-client-id");
expect(url.searchParams.get("redirect_uri")).toBe(app.redirectUri);
expect(url.searchParams.get("scope")).toBe("openid email profile");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(url.searchParams.get("state")).toMatch(/^[A-Za-z0-9_-]+$/);
expect(sessionStorage.getItem("nestquest.oidc_state")).toBe(
url.searchParams.get("state"),
);
expect(sessionStorage.getItem("nestquest.code_verifier")).toMatch(/^[A-Za-z0-9-._~]{43,128}$/);
});
it("handleCallback rejects a mismatched state", async () => {
await buildAuthorizeUrl(testApp());
sessionStorage.setItem("nestquest.oidc_state", "different-state");
const fetchMock = vi.fn();
await expect(
handleCallback("?code=abc&state=tampered", testApp(), fetchMock as any),
).rejects.toThrow(/state mismatch/i);
expect(fetchMock).not.toHaveBeenCalled();
expect(getToken()).toBeUndefined();
});
it("handleCallback exchanges the code with the correct form body and stores the token", async () => {
await buildAuthorizeUrl(testApp());
const verifier = sessionStorage.getItem("nestquest.code_verifier")!;
const state = sessionStorage.getItem("nestquest.oidc_state")!;
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
access_token: "at-123",
refresh_token: "rt-456",
id_token: "id-789",
expires_in: 300,
}),
);
await handleCallback(`?code=abc&state=${state}`, testApp(), fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://auth.example.com/application/o/token/");
expect(init.method).toBe("POST");
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
const body = new URLSearchParams(init.body);
expect(body.get("grant_type")).toBe("authorization_code");
expect(body.get("code")).toBe("abc");
expect(body.get("redirect_uri")).toBe(testApp().redirectUri);
expect(body.get("client_id")).toBe("test-client-id");
expect(body.get("code_verifier")).toBe(verifier);
expect(body.has("client_secret")).toBe(false);
expect(getToken()).toBe("at-123");
// one-time values consumed
expect(sessionStorage.getItem("nestquest.oidc_state")).toBeNull();
expect(sessionStorage.getItem("nestquest.code_verifier")).toBeNull();
});
it("handleCallback surfaces provider errors", async () => {
await expect(
handleCallback("?error=access_denied&error_description=nope", testApp(), vi.fn() as any),
).rejects.toThrow(/nope|access_denied/);
});
it("clearAuth removes all stored auth data", () => {
storeTokens({ access_token: "a", refresh_token: "r", expires_in: 60 });
clearAuth();
expect(getToken()).toBeUndefined();
expect(sessionStorage.length).toBe(0);
});
it("logout clears auth and redirects to the end-session endpoint", async () => {
storeTokens({ access_token: "a", id_token: "idt", expires_in: 60 });
const assign = vi.fn();
const original = window.location;
vi.stubGlobal("location", { ...original, assign, origin: "http://localhost:5173" });
await logout(testApp());
expect(assign).toHaveBeenCalledTimes(1);
const url = new URL(assign.mock.calls[0][0] as string);
expect(url.toString()).toBe(
"https://auth.example.com/application/o/nestquest/end-session/?id_token_hint=idt&client_id=test-client-id&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173",
);
expect(getToken()).toBeUndefined();
vi.unstubAllGlobals();
});
it("buildAuthorizeUrl resolves the authorize endpoint via discovery for a non-default issuer", async () => {
const issuer = "https://sso.discovery.example/application/o/discovered";
const discoveryDoc = {
issuer,
authorization_endpoint: "https://sso.discovery.example/application/o/authorize/",
token_endpoint: "https://sso.discovery.example/application/o/token/",
userinfo_endpoint: "https://sso.discovery.example/application/o/userinfo/",
end_session_endpoint: `${issuer}/end-session/`,
jwks_uri: `${issuer}/jwks/`,
};
const fetchMock = vi.fn().mockImplementation((url: string) =>
Promise.resolve(
String(url).endsWith("/.well-known/openid-configuration")
? jsonResponse(discoveryDoc)
: jsonResponse({}),
),
);
const app: AppConfig = {
issuer,
clientId: "test-client-id",
apiBaseUrl: "https://api.example.com",
redirectUri: "http://localhost:5173/auth/callback",
};
const url = new URL(await buildAuthorizeUrl(app, fetchMock as any));
// The authorize endpoint comes from the discovery document — Authentik
// serves it outside the app-specific issuer path, so it is NOT
// `${issuer}/authorize/` and must never be a hardcoded production host.
expect(url.origin + url.pathname).toBe(discoveryDoc.authorization_endpoint);
expect(url.searchParams.get("client_id")).toBe("test-client-id");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0][0])).toBe(
`${issuer}/.well-known/openid-configuration`,
);
});
});
-225
View File
@@ -1,225 +0,0 @@
/**
* OIDC authorization-code + PKCE flow for a PUBLIC client.
*
* Never sends a client_secret anywhere. Tokens are stored in sessionStorage
* (per-tab, cleared on tab close) — never localStorage.
*/
import { config, resolveEndpoints, type AppConfig, type ResolvedAppConfig } from "../config";
import {
computeCodeChallenge,
generateCodeVerifier,
generateState,
} from "./pkce";
const STORAGE_KEYS = {
accessToken: "nestquest.access_token",
refreshToken: "nestquest.refresh_token",
expiresAt: "nestquest.expires_at",
idToken: "nestquest.id_token",
state: "nestquest.oidc_state",
codeVerifier: "nestquest.code_verifier",
} as const;
export class AuthError extends Error {}
export interface TokenResponse {
access_token?: string;
refresh_token?: string;
id_token?: string;
expires_in?: number;
token_type?: string;
}
function makeScopes(): string {
return "openid email profile";
}
/**
* Build the Authentik authorization redirect URL, storing state + verifier in
* sessionStorage so the callback can validate them. Endpoints are resolved
* from the issuer's OIDC discovery document when not set on the config.
*/
export async function buildAuthorizeUrl(
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<string> {
if (!app.clientId) {
throw new AuthError(
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
);
}
const resolved = await resolveEndpoints(app, fetchImpl);
const state = generateState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = await computeCodeChallenge(codeVerifier);
sessionStorage.setItem(STORAGE_KEYS.state, state);
sessionStorage.setItem(STORAGE_KEYS.codeVerifier, codeVerifier);
const url = new URL(resolved.authorizeEndpoint);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", app.clientId);
url.searchParams.set("redirect_uri", app.redirectUri);
url.searchParams.set("scope", makeScopes());
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}
/**
* Handle the OAuth callback: validate state, exchange the code at the token
* endpoint (PKCE, form-urlencoded, no client_secret), store tokens.
*/
export async function handleCallback(
query: URLSearchParams | string = window.location.search,
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
const params =
typeof query === "string" ? new URLSearchParams(query) : query;
const providerError = params.get("error");
if (providerError) {
throw new AuthError(
params.get("error_description") || `Provider returned error: ${providerError}`,
);
}
const code = params.get("code");
const state = params.get("state");
if (!code || !state) {
throw new AuthError("Callback is missing the code or state parameter.");
}
const storedState = sessionStorage.getItem(STORAGE_KEYS.state);
const codeVerifier = sessionStorage.getItem(STORAGE_KEYS.codeVerifier);
if (!storedState || storedState !== state || !codeVerifier) {
throw new AuthError("State mismatch — possible CSRF; refusing to log in.");
}
if (!app.clientId) {
throw new AuthError(
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
);
}
const resolved: ResolvedAppConfig = await resolveEndpoints(app, fetchImpl);
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: app.redirectUri,
client_id: app.clientId,
code_verifier: codeVerifier,
});
const response = await fetchImpl(resolved.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!response.ok) {
throw new AuthError(`Token exchange failed with HTTP ${response.status}`);
}
const tokens = (await response.json()) as TokenResponse;
if (!tokens.access_token) {
throw new AuthError("Token response did not include an access token.");
}
storeTokens(tokens);
// One-time use values are consumed on success.
sessionStorage.removeItem(STORAGE_KEYS.state);
sessionStorage.removeItem(STORAGE_KEYS.codeVerifier);
}
export function storeTokens(tokens: TokenResponse): void {
sessionStorage.setItem(STORAGE_KEYS.accessToken, tokens.access_token!);
if (tokens.refresh_token) {
sessionStorage.setItem(STORAGE_KEYS.refreshToken, tokens.refresh_token);
}
if (tokens.id_token) {
sessionStorage.setItem(STORAGE_KEYS.idToken, tokens.id_token);
}
if (typeof tokens.expires_in === "number") {
sessionStorage.setItem(
STORAGE_KEYS.expiresAt,
String(Date.now() + tokens.expires_in * 1000),
);
}
}
export function getToken(): string | undefined {
return sessionStorage.getItem(STORAGE_KEYS.accessToken) ?? undefined;
}
export function getRefreshToken(): string | undefined {
return sessionStorage.getItem(STORAGE_KEYS.refreshToken) ?? undefined;
}
export function getIdToken(): string | undefined {
return sessionStorage.getItem(STORAGE_KEYS.idToken) ?? undefined;
}
export function isAuthenticated(): boolean {
return Boolean(getToken());
}
/** True once the stored access token has passed its (approximate) expiry. */
export function isExpired(): boolean {
const expiresAt = sessionStorage.getItem(STORAGE_KEYS.expiresAt);
if (!expiresAt) return false;
return Date.now() >= Number(expiresAt);
}
/**
* Refresh the access token with the refresh token grant. Returns false when
* no refresh token is stored or the grant fails (caller should re-login).
*/
export async function refreshAccessToken(
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!refreshToken || !app.clientId) return false;
const resolved = await resolveEndpoints(app, fetchImpl);
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: app.clientId,
});
const response = await fetchImpl(resolved.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!response.ok) return false;
const tokens = (await response.json()) as TokenResponse;
if (!tokens.access_token) return false;
storeTokens(tokens);
return true;
}
export function clearAuth(): void {
for (const key of Object.values(STORAGE_KEYS)) {
sessionStorage.removeItem(key);
}
}
/** Sign out: clear local auth, then send the browser to the end-session endpoint. */
export async function logout(
app: AppConfig = config,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
const resolved = await resolveEndpoints(app, fetchImpl);
const idTokenHint = getIdToken();
clearAuth();
const params = new URLSearchParams();
if (idTokenHint) params.set("id_token_hint", idTokenHint);
if (app.clientId) params.set("client_id", app.clientId);
params.set("post_logout_redirect_uri", window.location.origin);
window.location.assign(`${resolved.endSessionEndpoint}?${params.toString()}`);
}
-41
View File
@@ -1,41 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
base64UrlEncode,
computeCodeChallenge,
generateCodeVerifier,
generateState,
isLikelyValidVerifier,
} from "./pkce";
describe("PKCE helpers", () => {
it("base64UrlEncode has no padding and url-safe characters", () => {
// bytes chosen to force "+" and "/" and padding in plain base64
const bytes = new Uint8Array([0xfb, 0xff, 0xbf, 0xff]);
const encoded = base64UrlEncode(bytes);
expect(encoded).not.toMatch(/[+/=]/);
});
it("computes the RFC 7636 appendix-B S256 vector", async () => {
const verifier =
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const challenge = await computeCodeChallenge(verifier);
expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
});
it("generates verifiers with valid charset and length", () => {
for (let i = 0; i < 20; i++) {
const verifier = generateCodeVerifier();
expect(isLikelyValidVerifier(verifier)).toBe(true);
}
});
it("rejects verifiers outside 43-128 chars or with reserved chars", () => {
expect(isLikelyValidVerifier("short")).toBe(false);
expect(isLikelyValidVerifier("a".repeat(129))).toBe(false);
expect(isLikelyValidVerifier("a".repeat(43) + "+")).toBe(false);
});
it("generates distinct random states", () => {
expect(generateState()).not.toBe(generateState());
});
});
-55
View File
@@ -1,55 +0,0 @@
/**
* PKCE (RFC 7636) helpers for a public SPA client.
*
* Uses the Web Crypto API, which requires a SECURE CONTEXT: the app must be
* served over https or http://localhost, otherwise crypto.subtle is
* unavailable and login cannot work.
*/
const VERIFIER_CHARSET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
/** base64url-encode bytes with NO padding. */
export function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function randomBase64Url(byteLength: number): string {
const bytes = new Uint8Array(byteLength);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
/**
* Random code_verifier: 43-128 chars, unreserved charset only.
* 32 random bytes -> 43 base64url chars, which is the minimum allowed length.
*/
export function generateCodeVerifier(): string {
return randomBase64Url(32);
}
/** S256 code_challenge = base64url(SHA-256(verifier)), no padding. */
export async function computeCodeChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
return base64UrlEncode(new Uint8Array(digest));
}
/** Random opaque state value for CSRF protection. */
export function generateState(): string {
return randomBase64Url(16);
}
export function isLikelyValidVerifier(verifier: string): boolean {
return (
verifier.length >= 43 &&
verifier.length <= 128 &&
[...verifier].every((char) => VERIFIER_CHARSET.includes(char))
);
}
-215
View File
@@ -1,215 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
ConfigError,
discoveryUrlForIssuer,
readConfig,
resolveEndpoints,
resolveEndpointsFromDiscovery,
type AppConfig,
} from "./config";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
// Mirrors Authentik's layout: authorize/token/userinfo live OUTSIDE the
// application-specific issuer path, end-session/jwks under it.
function discoveryDocument(issuer: string) {
const normalized = issuer.replace(/\/+$/, "");
const origin = new URL(normalized).origin;
return {
issuer,
authorization_endpoint: `${origin}/application/o/authorize/`,
token_endpoint: `${origin}/application/o/token/`,
userinfo_endpoint: `${origin}/application/o/userinfo/`,
end_session_endpoint: `${normalized}/end-session/`,
jwks_uri: `${normalized}/jwks/`,
};
}
describe("config", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("readConfig keeps the production issuer as the default", () => {
const cfg = readConfig({});
expect(cfg.issuer).toBe("https://auth.cubecraftlabs.com/application/o/nestquest");
expect(cfg.clientId).toBeUndefined();
});
it("readConfig normalizes a trailing slash off a custom issuer", () => {
expect(
readConfig({ VITE_AUTHENTIK_ISSUER: "https://sso.custom.example/application/o/custom/" })
.issuer,
).toBe("https://sso.custom.example/application/o/custom");
});
it("readConfig hardcodes no OIDC endpoints — they come from discovery", () => {
const cfg = readConfig({});
expect(cfg.authorizeEndpoint).toBeUndefined();
expect(cfg.tokenEndpoint).toBeUndefined();
expect(cfg.userinfoEndpoint).toBeUndefined();
expect(cfg.endSessionEndpoint).toBeUndefined();
expect(cfg.jwksUri).toBeUndefined();
});
it("builds the discovery URL for issuers with and without a trailing slash", () => {
expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app/")).toBe(
"https://sso.example.com/application/o/app/.well-known/openid-configuration",
);
expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app")).toBe(
"https://sso.example.com/application/o/app/.well-known/openid-configuration",
);
});
it("resolves every endpoint from the discovery document of a non-default issuer", async () => {
const issuer = "https://sso.other-host.example/application/o/other-app";
const doc = discoveryDocument(issuer);
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
const app: AppConfig = {
issuer,
clientId: "client-id",
apiBaseUrl: "https://api.example.com",
redirectUri: "http://localhost:5173/auth/callback",
};
const resolved = await resolveEndpoints(app, fetchMock as unknown as typeof fetch);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://sso.other-host.example/application/o/other-app/.well-known/openid-configuration",
);
// Every endpoint comes verbatim from the (non-default) discovery document.
expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
expect(resolved.tokenEndpoint).toBe(doc.token_endpoint);
expect(resolved.userinfoEndpoint).toBe(doc.userinfo_endpoint);
expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
expect(resolved.jwksUri).toBe(doc.jwks_uri);
// Proves no hardcoded production host leaked into the resolved endpoints.
for (const endpoint of [
resolved.authorizeEndpoint,
resolved.tokenEndpoint,
resolved.userinfoEndpoint,
resolved.endSessionEndpoint,
resolved.jwksUri,
]) {
expect(endpoint).not.toMatch(/cubecraftlabs/);
}
});
it("resolves discovery for an issuer configured with a trailing slash", async () => {
const issuer = "https://sso.slashy.example/application/o/slashy/";
const doc = discoveryDocument(issuer);
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
const resolved = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
expect(fetchMock).toHaveBeenCalledWith(
"https://sso.slashy.example/application/o/slashy/.well-known/openid-configuration",
);
expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
});
it("caches discovery per issuer for the session", async () => {
const issuer = "https://sso.cached.example/application/o/cached";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
const first = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
const second = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
});
it("surfaces a clear configuration error when discovery returns an HTTP error", async () => {
const issuer = "https://sso.http-error.example/application/o/broken";
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
const error = await resolveEndpointsFromDiscovery(issuer, fetchMock as any).then(
() => null,
(error: unknown) => error,
);
expect(error).toBeInstanceOf(ConfigError);
expect((error as Error).message).toContain(issuer);
expect((error as Error).message).toContain("HTTP 500");
});
it("surfaces a clear configuration error when discovery is unreachable", async () => {
const issuer = "https://sso.unreachable.example/application/o/unreachable";
const fetchMock = vi.fn().mockRejectedValue(new TypeError("network down"));
await expect(
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
).rejects.toBeInstanceOf(ConfigError);
});
it("surfaces a clear configuration error when the discovery document is incomplete", async () => {
const issuer = "https://sso.incomplete.example/application/o/incomplete";
const doc = discoveryDocument(issuer) as Record<string, string>;
delete doc.end_session_endpoint;
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
await expect(
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
).rejects.toThrow(/end_session_endpoint/);
});
it("rejects a discovery document advertising a different issuer", async () => {
const issuer = "https://sso.mismatch.example/application/o/mismatch";
const doc = {
...discoveryDocument(issuer),
issuer: "https://sso.elsewhere.example/application/o/elsewhere",
};
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
await expect(
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
).rejects.toThrow(/issuer mismatch/i);
});
it("the bundled config resolves its endpoints from discovery at runtime", async () => {
const issuer = "https://sso.bundled.example/application/o/bundled";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
const bundled = readConfig({ VITE_AUTHENTIK_ISSUER: `${issuer}/`, VITE_AUTHENTIK_CLIENT_ID: "bundled-client" });
expect(bundled.authorizeEndpoint).toBeUndefined();
const resolved = await resolveEndpoints(bundled, fetchMock as any);
expect(resolved.authorizeEndpoint).toBe(
"https://sso.bundled.example/application/o/authorize/",
);
expect(resolved.tokenEndpoint).toBe("https://sso.bundled.example/application/o/token/");
expect(resolved.userinfoEndpoint).toBe(
"https://sso.bundled.example/application/o/userinfo/",
);
expect(resolved.endSessionEndpoint).toBe(
"https://sso.bundled.example/application/o/bundled/end-session/",
);
expect(resolved.jwksUri).toBe("https://sso.bundled.example/application/o/bundled/jwks/");
});
it("does not re-fetch discovery when the config already carries endpoints", async () => {
const app: AppConfig = {
issuer: "https://auth.example.com/application/o/nestquest",
clientId: "client-id",
apiBaseUrl: "https://api.example.com",
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
tokenEndpoint: "https://auth.example.com/application/o/token/",
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
redirectUri: "http://localhost:5173/auth/callback",
};
const fetchMock = vi.fn();
const resolved = await resolveEndpoints(app, fetchMock as any);
expect(fetchMock).not.toHaveBeenCalled();
expect(resolved).toBe(app);
});
});
-221
View File
@@ -1,221 +0,0 @@
/**
* Runtime configuration, read from Vite env (import.meta.env).
* See .env.example for the variables and their defaults.
*
* OIDC endpoints are NOT hardcoded here. They are resolved lazily from the
* issuer's OIDC discovery document (see resolveEndpoints), because Authentik
* serves authorize/token/userinfo OUTSIDE the application-specific issuer
* path (issuer is /application/o/<slug>/, authorize is /application/o/authorize/).
* Both hardcoding the production host and naively concatenating onto the
* issuer would break non-default issuers; discovery is the only correct way.
*/
export interface AppConfig {
issuer: string;
clientId: string | undefined;
apiBaseUrl: string;
/** Resolved from the issuer's OIDC discovery document — see resolveEndpoints(). */
authorizeEndpoint?: string;
tokenEndpoint?: string;
userinfoEndpoint?: string;
endSessionEndpoint?: string;
jwksUri?: string;
redirectUri: string;
}
/** AppConfig whose OIDC endpoints have been filled in by resolveEndpoints(). */
export type ResolvedAppConfig = AppConfig &
Required<
Pick<
AppConfig,
"authorizeEndpoint" | "tokenEndpoint" | "userinfoEndpoint" | "endSessionEndpoint"
>
>;
export interface DiscoveredEndpoints {
issuer: string;
authorizeEndpoint: string;
tokenEndpoint: string;
userinfoEndpoint: string;
endSessionEndpoint: string;
jwksUri: string;
}
/** Raised when OIDC discovery fails or the configuration is unusable. */
export class ConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "ConfigError";
}
}
const REQUIRED_ENDPOINT_NAMES = [
"authorizeEndpoint",
"tokenEndpoint",
"userinfoEndpoint",
"endSessionEndpoint",
] as const;
export function readConfig(env: Record<string, string | undefined> = import.meta.env as any): AppConfig {
const issuer = (
env.VITE_AUTHENTIK_ISSUER || "https://auth.cubecraftlabs.com/application/o/nestquest/"
).replace(/\/$/, "");
const clientId = env.VITE_AUTHENTIK_CLIENT_ID;
const apiBaseUrl = env.VITE_API_BASE_URL || "https://nestquest.cubecraftlabs.com";
return {
issuer,
// No default: a missing client id must surface as a configuration error
// screen, not a broken redirect to Authentik.
clientId: clientId || undefined,
apiBaseUrl,
// authorize/token/userinfo/end-session endpoints are resolved from the
// issuer's discovery document at runtime — never hardcoded here.
redirectUri: `${window.location.origin}/auth/callback`,
};
}
export const config: AppConfig = readConfig();
interface DiscoveryDocument {
issuer?: unknown;
authorization_endpoint?: unknown;
token_endpoint?: unknown;
userinfo_endpoint?: unknown;
end_session_endpoint?: unknown;
jwks_uri?: unknown;
}
function normalizeIssuer(issuer: string): string {
return issuer.replace(/\/+$/, "");
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/** Discovery document location for an issuer, tolerant of a trailing slash. */
export function discoveryUrlForIssuer(issuer: string): string {
return `${normalizeIssuer(issuer)}/.well-known/openid-configuration`;
}
async function fetchDiscoveredEndpoints(
issuer: string,
fetchImpl: typeof fetch,
): Promise<DiscoveredEndpoints> {
const normalizedIssuer = normalizeIssuer(issuer);
const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`;
let response: Response;
try {
response = await fetchImpl(discoveryUrl);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new ConfigError(
`OIDC discovery request to ${discoveryUrl} failed (${reason}). ` +
"Check VITE_AUTHENTIK_ISSUER and network reachability.",
);
}
if (!response.ok) {
throw new ConfigError(
`OIDC discovery failed: ${discoveryUrl} returned HTTP ${response.status}. ` +
"Check VITE_AUTHENTIK_ISSUER.",
);
}
let doc: DiscoveryDocument;
try {
doc = (await response.json()) as DiscoveryDocument;
} catch {
throw new ConfigError(
`OIDC discovery document at ${discoveryUrl} is not valid JSON. Check VITE_AUTHENTIK_ISSUER.`,
);
}
const authorizationEndpoint = nonEmptyString(doc.authorization_endpoint);
const tokenEndpoint = nonEmptyString(doc.token_endpoint);
const userinfoEndpoint = nonEmptyString(doc.userinfo_endpoint);
const endSessionEndpoint = nonEmptyString(doc.end_session_endpoint);
const jwksUri = nonEmptyString(doc.jwks_uri);
const discoveredIssuer = nonEmptyString(doc.issuer);
const endpointFields: Array<[string, string | undefined]> = [
["authorization_endpoint", authorizationEndpoint],
["token_endpoint", tokenEndpoint],
["userinfo_endpoint", userinfoEndpoint],
["end_session_endpoint", endSessionEndpoint],
["jwks_uri", jwksUri],
];
const missing = endpointFields.filter(([, value]) => !value).map(([name]) => name);
if (missing.length > 0) {
throw new ConfigError(
`OIDC discovery document at ${discoveryUrl} is missing: ${missing.join(", ")}. ` +
"The issuer must point at an OIDC provider that publishes these endpoints.",
);
}
if (discoveredIssuer && normalizeIssuer(discoveredIssuer) !== normalizedIssuer) {
throw new ConfigError(
`OIDC discovery issuer mismatch: ${discoveryUrl} advertises "${discoveredIssuer}" ` +
`but VITE_AUTHENTIK_ISSUER is "${normalizedIssuer}".`,
);
}
return {
issuer: normalizedIssuer,
authorizeEndpoint: authorizationEndpoint!,
tokenEndpoint: tokenEndpoint!,
userinfoEndpoint: userinfoEndpoint!,
endSessionEndpoint: endSessionEndpoint!,
jwksUri: jwksUri!,
};
}
/**
* Session cache: one discovery fetch per issuer per SPA session. Failed
* lookups are evicted so a transient network error can be retried instead of
* poisoning the whole session.
*/
const discoveryCache = new Map<string, Promise<DiscoveredEndpoints>>();
export function resolveEndpointsFromDiscovery(
issuer: string,
fetchImpl: typeof fetch = fetch,
): Promise<DiscoveredEndpoints> {
const normalizedIssuer = normalizeIssuer(issuer);
const cached = discoveryCache.get(normalizedIssuer);
if (cached) return cached;
const pending = fetchDiscoveredEndpoints(normalizedIssuer, fetchImpl);
discoveryCache.set(normalizedIssuer, pending);
pending.catch(() => {
discoveryCache.delete(normalizedIssuer);
});
return pending;
}
/**
* Return `app` with all OIDC endpoints filled in. When the config already
* carries them (pre-resolved config, tests) this is a no-op; otherwise the
* issuer's discovery document is fetched (cached for the session) and its
* authorization/token/userinfo/end-session/jwks endpoints are used.
*/
export async function resolveEndpoints(
app: AppConfig,
fetchImpl: typeof fetch = fetch,
): Promise<ResolvedAppConfig> {
const missing = REQUIRED_ENDPOINT_NAMES.filter((name) => !app[name]);
if (missing.length === 0) {
return app as ResolvedAppConfig;
}
const discovered = await resolveEndpointsFromDiscovery(app.issuer, fetchImpl);
return {
...app,
authorizeEndpoint: discovered.authorizeEndpoint,
tokenEndpoint: discovered.tokenEndpoint,
userinfoEndpoint: discovered.userinfoEndpoint,
endSessionEndpoint: discovered.endSessionEndpoint,
jwksUri: discovered.jwksUri,
};
}
-39
View File
@@ -1,39 +0,0 @@
/* NestQuest Admin — global tokens + base.
Plain CubeCraft styling, mobile-first, 390x844 reference
(design/ADMIN-SPEC.md). Values mirror design/tokens/nestquest-admin-tokens.css,
re-scoped to :root for the standalone PWA. */
:root {
--nq-brand-blue: #3b3ab8;
--nq-brand-purple: #a035cc;
--nq-a-page: #f4f4f6;
--nq-a-surface: #ffffff;
--nq-a-border: #e4e4ea;
--nq-a-divider: #ededf1;
--nq-a-ink: #18181d;
--nq-a-ink-secondary: #52525e;
--nq-a-ink-tertiary: #70707e;
--nq-a-font: Nunito, system-ui, sans-serif;
--nq-a-tabbar-height: 70px;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
height: 100%;
}
body {
font-family: var(--nq-a-font);
background: var(--nq-a-page);
color: var(--nq-a-ink);
-webkit-font-smoothing: antialiased;
}
-10
View File
@@ -1,10 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import AppRoutes from "./AppRoutes";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<AppRoutes />
</StrictMode>,
);
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"useDefineForClassFields": true
},
"include": ["src"]
}
-9
View File
@@ -1,9 +0,0 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
},
});
+1 -12
View File
@@ -48,7 +48,6 @@ from api.database import DatabaseState, make_database
from api.events import TransitionPublisher
from api.routes_admin import router as admin_router
from api.routes_panel import router as panel_router
from api.scheduler import MissedSweepScheduler
def _build_lifespan(db_path: str):
@@ -58,11 +57,7 @@ def _build_lifespan(db_path: str):
path in without threading it through FastAPI's lifespan signature.
The lifespan also installs the app's ONE in-process transition
publisher on ``app.state.publisher`` (see :mod:`api.events`) so
every route and SSE subscriber shares that single instance, and
starts the daily missed-sweep scheduler
(:class:`api.scheduler.MissedSweepScheduler`) against the same
database and publisher — cancelled before the connection closes on
shutdown.
every route and SSE subscriber shares that single instance.
"""
@asynccontextmanager
@@ -70,16 +65,10 @@ def _build_lifespan(db_path: str):
state = make_database(db_path)
app.state.db = state
app.state.publisher = TransitionPublisher()
scheduler = MissedSweepScheduler(
state.database, publisher=app.state.publisher
)
app.state.sweep_scheduler = scheduler
try:
await state.open_and_migrate()
scheduler.start()
yield
finally:
await scheduler.stop()
await state.close()
return lifespan
-50
View File
@@ -116,11 +116,6 @@ core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot")
#: ``uncomplete_instance``) the panel's write routes call.
core_completion = importlib.import_module(f"{_CORE_MODULE_NAME}.completion")
#: The read-only history layer (``core.history.query_history``,
#: ``export_history_csv``, ``rows_to_csv``) the admin plane's history
#: query and CSV export routes call.
core_history = importlib.import_module(f"{_CORE_MODULE_NAME}.history")
#: The transition-event builders (``core.events.build_quest_completed_event``
#: et al.) the panel's write routes build their SSE events with.
core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events")
@@ -129,17 +124,6 @@ core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events")
#: the snapshot builder's ``settings`` parameter takes.
core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
#: The durable settings store (``core.settings_store.load_settings`` /
#: ``update_settings``) the admin plane's settings routes read and
#: write — the API's OWN settings source: a validated JSON document in
#: the existing ``nestquest_meta_state`` table, resolved through the
#: same core settings validators (the integration's HA config-entry
#: options remain a temporary separate source until Feature 18 wires
#: the client to the API).
core_settings_store = importlib.import_module(
f"{_CORE_MODULE_NAME}.settings_store"
)
#: The children business layer (``core.children.create_child`` et al.)
#: the admin plane's children routes call.
core_children = importlib.import_module(f"{_CORE_MODULE_NAME}.children")
@@ -177,45 +161,13 @@ core_dao_presence = importlib.import_module(
f"{_CORE_MODULE_NAME}.dao_presence"
)
#: The materialization walk (``core.materialize.materialize``,
#: ``regenerate_for_definition``, ``regenerate_for_child``) the admin
#: plane's regenerate route calls — the same walk the integration's
#: config/presence changes trigger.
core_materialize = importlib.import_module(f"{_CORE_MODULE_NAME}.materialize")
#: The core constants module (``core.const.DEFAULT_HORIZON_DAYS``) the
#: regenerate route sizes the household rolling horizon with.
core_const = importlib.import_module(f"{_CORE_MODULE_NAME}.const")
#: The typed child DAO (``core.dao_children.ChildrenDao.get``) the
#: regenerate route reads a child's existence with before delegating.
core_dao_children = importlib.import_module(
f"{_CORE_MODULE_NAME}.dao_children"
)
#: The typed definition DAO (``core.dao_rules.QuestDefinitionsDao.get``)
#: the regenerate route reads a definition's existence with before
#: delegating.
core_dao_rules = importlib.import_module(f"{_CORE_MODULE_NAME}.dao_rules")
#: The HA-free missed sweep (``core.sweep.run_missed_sweep`` +
#: ``SWEEP_WATERMARK_KEY``) the admin plane's trigger route calls and
#: the daily scheduler runs — the ONE sweep policy the integration's
#: bus shim shares.
core_sweep = importlib.import_module(f"{_CORE_MODULE_NAME}.sweep")
__all__ = [
"core",
"core_children",
"core_completion",
"core_const",
"core_dao_children",
"core_dao_presence",
"core_dao_rules",
"core_db",
"core_events",
"core_history",
"core_materialize",
"core_migrations",
"core_presence",
"core_presence_management",
@@ -223,6 +175,4 @@ __all__ = [
"core_recurrence",
"core_snapshot",
"core_settings",
"core_settings_store",
"core_sweep",
]
+3 -790
View File
@@ -59,103 +59,6 @@ layer the HA services share, so the two planes cannot drift:
design) and regenerates that override's child. It answers
``{"status": "ok"}``.
The uncomplete and regenerate routes (task da0226b3) follow the same
pattern over :mod:`nestquest_core.completion` and
:mod:`nestquest_core.materialize`:
- ``POST /instances/{instance_id}/uncomplete`` reverses ONE completion:
the handler takes the actor from the VERIFIED JWT (``actor_source``
``'user'`` with ``actor_user_id`` = the token's ``sub`` — the D-008
user-actor shape, so the reversal is attributed to the admin who
called it) and calls :func:`nestquest_core.completion.uncomplete_instance`.
The append-only discipline lives in the core: a reversal APPENDS an
``uncompleted`` event after the original ``completed`` row — the
original is never edited or deleted — and the instance's derived
state (recomputed from the latest event) returns to ``open``. On an
ACTUAL reversal (``CompletionResult.appended`` True) the route
publishes the ``nestquest_quest_uncompleted`` transition through
:func:`api.transitions.publish_quest_uncompleted` (the payload built
by the core builder); an already-open no-op publishes nothing. The
response carries the derived ``state`` and whether THIS call
``appended``.
- ``POST /regenerate`` re-materializes the rolling horizon through the
same core path the integration's config/presence changes use. The
body names ONE scope: ``household`` (the default —
:func:`nestquest_core.materialize.materialize` over
``[today, today + const.DEFAULT_HORIZON_DAYS]``, the core's default
horizon; the API config carries no horizon knob of its own),
``definition`` (:func:`nestquest_core.materialize.regenerate_for_definition`
— deletes the definition's open future instances and re-runs the
walk), or ``child``
(:func:`nestquest_core.materialize.regenerate_for_child`).
The upsert is idempotent, so re-running never duplicates an
instance. The response carries the scope, the scoped id (if any)
and the core's upsert ``count``.
The history routes (task 2d2dda53) follow the same pattern over
:mod:`nestquest_core.history` — the ONE read-only history layer, which
owns every rule so the two routes cannot drift:
- ``GET /history?filter=all|reversals|missed&start=YYYY-MM-DD&end=YYYY-MM-DD``
returns the history rows for ONE filter over the CLOSED due-date
range ``[start, end]``: ``all`` (every completion event — completed
AND uncompleted — whose instance is due in the range), ``reversals``
(the uncompleted events only) and ``missed`` (the DERIVED past-due
open instances, computed by the core's own derive-state rule —
``missed`` is never a stored event_type and nothing is appended).
The range scopes by the instance's ``due_date`` (the Feature 13
rule: history asks what happened to the tasks due that day), not the
event's wall-clock stamp. The filter and date-range policies live
in the core (strict ``YYYY-MM-DD``, ``end >= start``, a
``ValueError`` naming the offending field); the handler is a thin
adapter that threads ONE clock read (:func:`_local_now`, the missed
derivation's ``today``) and serializes the core's rows.
- ``GET /history.csv?...`` exports the SAME filtered range as CSV:
ONE :func:`nestquest_core.history.export_history_csv` call — the
same query path the JSON route uses — answered as ``text/csv`` with
a ``Content-Disposition`` attachment filename and the documented,
stable header row (Admin spec §5 event-row fields).
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
document in the existing ``nestquest_meta_state`` key/value table); the
integration's Home-Assistant config-entry options remain a temporary,
SEPARATE source until Feature 18 wires the client to the API — the two
are deliberately not unified here:
- ``GET /settings`` returns the current effective settings: all ten
documented fields (the rolling horizon, the day rollover time, the
notify target, the three notification times and the four enable
toggles) — the defaults on a fresh database.
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
JSON object of settings field names to new values, passed STRAIGHT
to :func:`nestquest_core.settings_store.update_settings` — the
unknown-field rejection, the per-field validation (the core settings
validators: a positive int, strict ``HH:MM`` strings, real booleans)
and the validate-everything-before-write atomicity all live in the
core, so a rejected update never changes the stored settings. A
JSON ``null`` value resets its field to the default (the settings
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 ``(event_type, payload)`` pair
DIRECTLY on the app's ONE transition publisher — the sweep-built
payload, never re-fetched (the core commits its watermark before
returning, so a failed re-fetch after it would lose the missed event
forever), the same publish-what-the-core-built shape
api/scheduler.py uses. 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):
@@ -191,36 +94,6 @@ deliberately narrow):
:func:`_raise_presence_override_error`, keyed on the
``override_id:`` prefix the core's unknown-id error carries (the
same convention as the quest-definition 404).
- The UNCOMPLETE route maps the core completion layer's unknown-
``instance_id`` error (prefixed ``instance_id:``, the same
convention the panel complete route keys on) to 404; every other
core ``ValueError`` — a rejected actor shape, which a verified token
without a usable ``sub`` claim produces — is 422: the credential
authenticated but the reversal it asks for names no subject to
attribute. Anything ELSE re-raises: no unexpected failure is
swallowed into a 4xx.
- The REGENERATE route's only 404s are the unknown scope ids, decided
by ONE existence read (the definitions/children DAO ``get``) BEFORE
any core call — the core's regenerate path itself takes no existence
stance. Every core ``ValueError`` (a rejected horizon, a malformed
date — unreachable from this body model, but mapped anyway) is 422;
anything else re-raises.
- The HISTORY routes have no path ids, so they have no 404 case: a
missing query parameter (the request's shape) is FastAPI's 422
before any handler code runs, and every core ``ValueError`` — an
unknown filter, a non-strict or inverted date range, each naming the
offending field (``filter`` / ``start`` / ``end``) — is 422
(:func:`_raise_history_error`): well-formed but semantically
invalid, the same failure class Pydantic reports. Only
``ValueError`` is caught, so an unexpected failure re-raises rather
than becoming a 4xx.
- The SETTINGS routes have no path ids either, so they have no 404
case: a body that is not a JSON object is FastAPI's 422 before any
handler code runs, and every core ``ValueError`` — an unknown field
or an invalid value, each naming the offending field, raised BEFORE
any write so the stored settings are never half-changed — is 422
(:func:`_raise_settings_error`). Only ``ValueError`` is caught, so
an unexpected failure re-raises rather than becoming a 4xx.
PATCH edit semantics: the body model's fields are optional and only
SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so
@@ -231,30 +104,19 @@ silently ignored.
"""
from __future__ import annotations
import datetime
from typing import Annotated, Literal, NoReturn
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, field_validator, model_validator
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from api import transitions as api_transitions
from api.auth import require_admin
from api.database import DatabaseState
from api.nestquest_core import (
core_children,
core_completion,
core_const,
core_dao_children,
core_dao_rules,
core_history,
core_materialize,
core_presence,
core_presence_management,
core_quest_definitions,
core_recurrence,
core_settings,
core_settings_store,
core_sweep,
)
#: All admin-plane routes share this router; the ONE admin dependency
@@ -533,156 +395,6 @@ class AdminPresenceOverrideResponse(BaseModel):
note: str | None
class AdminUncompleteResponse(BaseModel):
"""The result of ``POST /api/v1/admin/instances/{id}/uncomplete``.
``state`` is the instance's derived state AFTER the reversal —
``open``, or ``missed`` when it is past due — and ``appended``
reports whether THIS call appended the reversal event (``False``
for an already-open no-op).
"""
instance_id: int
state: str
appended: bool
class AdminRegenerateRequest(BaseModel):
"""The body of ``POST /api/v1/admin/regenerate``.
ONE scope per request: ``household`` (the default) re-materializes
every active definition over the rolling horizon; ``definition``
and ``child`` scope the rebuild to one id. The model enforces the
scope/id pairing so the handler never has to guess: an id without
its scope, a scope without its id, or a zero/negative id is a
validation error (422) before any handler code runs.
"""
scope: Literal["household", "definition", "child"] = "household"
definition_id: int | None = None
child_id: int | None = None
@field_validator("definition_id", "child_id")
@classmethod
def _positive(cls, value: int | None) -> int | None:
"""Reject zero and negative ids with a validation error."""
if value is not None and value < 1:
raise ValueError(
"definition_id and child_id must be positive integers"
)
return value
@model_validator(mode="after")
def _scope_matches_ids(self) -> "AdminRegenerateRequest":
"""Reject an id that does not belong to the requested scope."""
if self.scope == "household":
if self.definition_id is not None or self.child_id is not None:
raise ValueError(
"scope 'household' takes no definition_id or child_id"
)
elif self.scope == "definition":
if self.definition_id is None:
raise ValueError("scope 'definition' requires definition_id")
if self.child_id is not None:
raise ValueError("scope 'definition' must not carry child_id")
else:
if self.child_id is None:
raise ValueError("scope 'child' requires child_id")
if self.definition_id is not None:
raise ValueError(
"scope 'child' must not carry definition_id"
)
return self
class AdminRegenerateResponse(BaseModel):
"""What a regenerate request rebuilt.
``scope`` echoes the request; ``definition_id``/``child_id`` carry
the scoped id (both ``None`` for the household); ``count`` is the
number of instances the core walk upserted — the core's idempotent
count, so a re-run refreshes rows in place and never duplicates.
"""
scope: str
definition_id: int | None
child_id: int | None
count: int
class AdminHistoryRow(BaseModel):
"""One history row as the admin plane serializes it.
Mirrors :class:`nestquest_core.history.HistoryRow` — the Admin spec
§5 event-row fields, the same set the CSV export's header carries.
A stored event carries its UTC ``occurred_at`` stamp and the
``completed``/``uncompleted`` ``event_type``; a derived missed row
(``missed`` is NEVER a stored event_type) carries ``event_type``
``'missed'``, ``occurred_at`` ``None`` (there is no event) and the
``nightly sweep`` actor.
"""
occurred_at: str | None
event_type: str
child_name: str
child_id: int
quest_title: str
instance_id: int
window: str
due_date: str
due_time: str | None
actor: str
was_on_time: bool | None
class AdminHistoryResponse(BaseModel):
"""The history query's answer.
``filter``/``start``/``end`` echo the request, ``count`` is the row
count, and ``rows`` are the core's rows in its deterministic order
(``due_date``, then instance, then event id — the per-instance
append order, so a completion and its reversal keep their order).
"""
filter: str
start: str
end: str
count: int
rows: list[AdminHistoryRow]
class AdminSettingsResponse(BaseModel):
"""The effective settings as the admin plane serializes them.
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
— all TEN documented fields, resolved (every absent stored field
already fell back to its default in the core). The same shape both
the GET and the PATCH route answer with.
"""
horizon_days: int
day_rollover_time: str
notify_target: str
morning_summary_time: str
afternoon_reminder_time: str
end_of_day_report_time: str
morning_summary_enabled: bool
afternoon_reminder_enabled: bool
end_of_day_report_enabled: bool
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"
@@ -766,98 +478,6 @@ def _raise_presence_override_error(error: ValueError) -> NoReturn:
raise HTTPException(status_code=422, detail=str(error)) from error
#: 404 detail for an instance id the core layer reports as non-existent.
_QUEST_INSTANCE_NOT_FOUND_DETAIL = "Quest instance not found"
def _raise_instance_error(error: ValueError) -> NoReturn:
"""Map a core completion ``ValueError`` onto 404 or 422 and raise it.
The uncomplete route's mapping, keyed the same way the panel
complete route keys its 404: the core names a missing instance by
prefixing its error with ``instance_id:``
(:func:`nestquest_core.completion.uncomplete_instance` on an
unknown id), and that one case is this route's 404. Every other
``ValueError`` — a rejected actor shape, which a verified token
without a usable ``sub`` claim produces — is 422 with the core's
message as the detail. The route catches ONLY ``ValueError``, so
an unexpected failure propagates untouched.
"""
if str(error).startswith("instance_id:"):
raise HTTPException(
status_code=404, detail=_QUEST_INSTANCE_NOT_FOUND_DETAIL
) from error
raise HTTPException(status_code=422, detail=str(error)) from error
def _raise_regenerate_error(error: ValueError) -> NoReturn:
"""Map a core materialization ``ValueError`` onto 422 and raise it.
The regenerate route's 404s are decided BEFORE the core call (the
handler's own existence reads of the scoped id), so every
``ValueError`` that survives to here is a rejected argument — a
malformed horizon or date the core refuses before any write — and
is 422 with the core's message as the detail. Nothing else is
caught: an unexpected failure re-raises rather than becoming a 4xx.
"""
raise HTTPException(status_code=422, detail=str(error)) from error
def _raise_history_error(error: ValueError) -> NoReturn:
"""Map a core history ``ValueError`` onto 422 and raise it.
The history routes have no path ids, so they have no 404 case:
every ``ValueError`` that survives to here is a rejected argument —
an unknown filter, a non-strict date, or an inverted range, each
raised by the core naming the offending field (``filter`` /
``start`` / ``end``) BEFORE any read — and is 422 with the core's
message as the detail. Only ``ValueError`` is caught by the
handlers, so an unexpected failure re-raises rather than becoming a
4xx.
"""
raise HTTPException(status_code=422, detail=str(error)) from error
def _raise_settings_error(error: ValueError) -> NoReturn:
"""Map a core settings-store ``ValueError`` onto 422 and raise it.
The settings routes have no path ids either, so they have no 404
case: every ``ValueError`` that survives to here is a rejected
change — an unknown field or an invalid value, each raised by the
core naming the offending field BEFORE any write, so the stored
settings are never half-changed — and 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 _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.
Same discipline as the panel route's clock read: a single
``now().astimezone()`` read anchors the household-local date the
regenerate horizon and the uncomplete stamp derive from, so the
calendar date can never disagree inside one request. Kept as a
module function so tests pin the instant the same way the panel
route's ``_local_now`` is pinned.
"""
return datetime.datetime.now().astimezone()
def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule:
"""Build the core ScheduleRule from the request's ``rule`` object.
@@ -1322,411 +942,4 @@ async def admin_delete_presence_override(
return {"status": "ok"}
@router.post(
"/instances/{instance_id}/uncomplete",
summary="Reverse one completion (append a reversal event)",
response_model=AdminUncompleteResponse,
)
async def admin_uncomplete_instance(
instance_id: int,
request: Request,
claims: Annotated[dict[str, object], Depends(require_admin)],
) -> AdminUncompleteResponse:
"""Reverse one completion; the original event is never touched.
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). ``claims`` is the SAME
verified-claims dict that dependency produced (FastAPI's
per-request dependency cache), and the reversal is attributed to
it: ``actor_source="user"`` with ``actor_user_id`` = the token's
``sub`` — the D-008 user-actor shape, ``actor_child_id`` never
sent — so the completion log records WHICH admin reversed it.
Thin adapter: ONE timezone-aware clock read (:func:`_local_now`)
and ONE :func:`nestquest_core.completion.uncomplete_instance` call
— the append-only discipline (a reversal APPENDS an ``uncompleted``
event after the original ``completed`` row, which is never edited
or deleted), the already-open no-op and the derived-state
recomputation all live in the core layer.
On an ACTUAL reversal (``CompletionResult.appended`` True) the
route publishes the ``nestquest_quest_uncompleted`` transition on
the SSE stream through
:func:`api.transitions.publish_quest_uncompleted` (the payload is
built by the core builder, never hand-built here; fan-out is
fire-and-forget, see api/events.py). An already-open no-op
(``appended`` False) publishes nothing.
Response: the derived ``state`` after the reversal — ``open``, or
``missed`` when the instance is past due — and whether THIS call
``appended``. Error mapping (:func:`_raise_instance_error`): an
unknown instance is 404; every other core ``ValueError`` is 422.
"""
state: DatabaseState = request.app.state.db
database = state.database
now = _local_now()
sub = claims.get("sub")
try:
result = await core_completion.uncomplete_instance(
database,
instance_id,
actor_source="user",
actor_user_id=sub if isinstance(sub, str) else None,
now=now,
)
except ValueError as error:
_raise_instance_error(error)
if result.appended:
# An ACTUAL reversal: announce the transition once. A no-op
# (already open) publishes nothing.
await api_transitions.publish_quest_uncompleted(
database, instance_id, request.app.state.publisher
)
return AdminUncompleteResponse(
instance_id=instance_id,
state=str(result),
appended=result.appended,
)
@router.post(
"/regenerate",
summary="Re-materialize the rolling horizon for one scope",
response_model=AdminRegenerateResponse,
)
async def admin_regenerate(
body: AdminRegenerateRequest, request: Request
) -> AdminRegenerateResponse:
"""Re-materialize the rolling horizon for the requested scope.
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
materialization path the integration's config/presence changes
trigger, one scope per request:
- ``household`` (the default): ONE
:func:`nestquest_core.materialize.materialize` call over
``[today, today + const.DEFAULT_HORIZON_DAYS]`` — the core's
default horizon, which sizes the window here because the API
config carries no horizon knob of its own. The walk is an
idempotent upsert: re-running refreshes rows in place and never
duplicates an instance.
- ``definition``: ONE existence read
(:class:`~nestquest_core.dao_rules.QuestDefinitionsDao.get` — an
unknown id is 404 BEFORE any core call, which the core's
regenerate path itself takes no stance on), then
:func:`nestquest_core.materialize.regenerate_for_definition`.
- ``child``: the same shape through
:class:`~nestquest_core.dao_children.ChildrenDao.get` and
:func:`nestquest_core.materialize.regenerate_for_child`.
The ONE clock read (:func:`_local_now`) threads the household-local
``today`` into every core call so the walk's no-past guard and the
delete cutoff share one anchor. Every core call returns the
upsert ``count``, which the response passes through. Error
mapping (:func:`_raise_regenerate_error`): every core
``ValueError`` is 422; the scope-id 404s are decided above.
"""
state: DatabaseState = request.app.state.db
database = state.database
today = _local_now().date()
if body.scope == "household":
end_date = today + datetime.timedelta(
days=core_const.DEFAULT_HORIZON_DAYS
)
try:
count = await core_materialize.materialize(
database,
today.isoformat(),
end_date.isoformat(),
today=today,
)
except ValueError as error:
_raise_regenerate_error(error)
return AdminRegenerateResponse(
scope="household", definition_id=None, child_id=None,
count=count,
)
if body.scope == "definition":
definition_id = body.definition_id
# The request model's scope/id consistency validator already
# guarantees the id for this scope; this only narrows the type.
assert definition_id is not None
definition = await core_dao_rules.QuestDefinitionsDao(
database
).get(definition_id)
if definition is None:
raise HTTPException(
status_code=404, detail=_QUEST_DEFINITION_NOT_FOUND_DETAIL
)
try:
count = await core_materialize.regenerate_for_definition(
database, definition_id, today=today
)
except ValueError as error:
_raise_regenerate_error(error)
return AdminRegenerateResponse(
scope="definition",
definition_id=definition_id,
child_id=None,
count=count,
)
child_id = body.child_id
# Same model guarantee as the definition scope above.
assert child_id is not None
child = await core_dao_children.ChildrenDao(database).get(child_id)
if child is None:
raise HTTPException(status_code=404, detail=_CHILD_NOT_FOUND_DETAIL)
try:
count = await core_materialize.regenerate_for_child(
database, child_id, today=today
)
except ValueError as error:
_raise_regenerate_error(error)
return AdminRegenerateResponse(
scope="child", definition_id=None, child_id=child_id, count=count
)
def _history_row_response(row: core_history.HistoryRow) -> AdminHistoryRow:
"""Serialize one core HistoryRow into the documented payload."""
return AdminHistoryRow(
occurred_at=row.occurred_at,
event_type=row.event_type,
child_name=row.child_name,
child_id=row.child_id,
quest_title=row.quest_title,
instance_id=row.instance_id,
window=row.window,
due_date=row.due_date,
due_time=row.due_time,
actor=row.actor,
was_on_time=row.was_on_time,
)
def _settings_response(
settings: core_settings.NestQuestSettings,
) -> AdminSettingsResponse:
"""Serialize one core NestQuestSettings into the documented payload.
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
stays pinned to the ten documented fields even if the core
dataclass later grows another one.
"""
return AdminSettingsResponse(
horizon_days=settings.horizon_days,
day_rollover_time=settings.day_rollover_time,
notify_target=settings.notify_target,
morning_summary_time=settings.morning_summary_time,
afternoon_reminder_time=settings.afternoon_reminder_time,
end_of_day_report_time=settings.end_of_day_report_time,
morning_summary_enabled=settings.morning_summary_enabled,
afternoon_reminder_enabled=settings.afternoon_reminder_enabled,
end_of_day_report_enabled=settings.end_of_day_report_enabled,
celebration_enabled=settings.celebration_enabled,
)
@router.get(
"/history",
summary="Query the completion history for one filter and range",
response_model=AdminHistoryResponse,
)
async def admin_history_query(
request: Request,
start: str,
end: str,
filter: str = "all",
) -> AdminHistoryResponse:
"""Return the history rows for one filter over the closed range.
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: ``filter``
(default ``all``), ``start`` and ``end`` go straight to
:func:`nestquest_core.history.query_history` as STRINGS — the
filter policy (``all`` / ``reversals`` / ``missed``) and the
date-range policy (strict ``YYYY-MM-DD``, ``end >= start``) live in
the core, which raises a ``ValueError`` naming the offending field;
the handler only threads ONE clock read (:func:`_local_now`) as the
missed derivation's ``today`` and serializes the returned rows.
``start``/``end`` are required query parameters (missing is
FastAPI's 422 before any handler code runs); every core
``ValueError`` is 422 (:func:`_raise_history_error`).
"""
state: DatabaseState = request.app.state.db
today = _local_now().date()
try:
rows = await core_history.query_history(
state.database, filter, start, end, today=today
)
except ValueError as error:
_raise_history_error(error)
return AdminHistoryResponse(
filter=filter,
start=start,
end=end,
count=len(rows),
rows=[_history_row_response(row) for row in rows],
)
@router.get(
"/history.csv",
summary="Export the completion history as CSV",
response_class=Response,
)
async def admin_history_csv(
request: Request,
start: str,
end: str,
filter: str = "all",
) -> Response:
"""Export the SAME filtered history range as a CSV attachment.
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 ONE
:func:`nestquest_core.history.export_history_csv` call — the same
query path the JSON route uses, so the CSV can never drift from the
query's filtered range — with the core's validation policy and the
:mod:`csv`-based serialization (stable documented header row, RFC
4180 quoting) living in the core. The answer is ``text/csv`` with
a ``Content-Disposition`` attachment filename naming the range;
the range is core-validated strict ``YYYY-MM-DD`` by the time the
filename is built, so it is injection-safe. Error mapping is the
JSON route's (:func:`_raise_history_error`).
"""
state: DatabaseState = request.app.state.db
today = _local_now().date()
try:
csv_text = await core_history.export_history_csv(
state.database, filter, start, end, today=today
)
except ValueError as error:
_raise_history_error(error)
filename = f"nestquest-history-{start}-to-{end}.csv"
return Response(
content=csv_text,
media_type="text/csv",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
},
)
@router.get(
"/settings",
summary="Read the effective settings",
response_model=AdminSettingsResponse,
)
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
"""Return the current effective settings (all ten documented fields).
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
:func:`nestquest_core.settings_store.load_settings` call — the API
owns settings in the database (the integration's HA options remain
a temporary separate source until Feature 18 wires the client to
the API), and the stored-document decode, the per-field validation
and the defaults-for-absent-fields fallback all live in the core.
A corrupt stored document never fails the read: the core falls
back to the defaults (per field where possible).
"""
state: DatabaseState = request.app.state.db
settings = await core_settings_store.load_settings(state.database)
return _settings_response(settings)
@router.patch(
"/settings",
summary="Update the supplied settings fields",
response_model=AdminSettingsResponse,
)
async def admin_update_settings(
changes: dict[str, object], request: Request
) -> AdminSettingsResponse:
"""Update the supplied settings fields and return the new state.
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: the body — a
JSON object of settings field names to new values — goes STRAIGHT
to :func:`nestquest_core.settings_store.update_settings`, which
rejects an unknown field, resolves each supplied field through the
core settings validators (a JSON ``null`` resets its field to the
default) and merges onto the current effective settings only after
every value has validated — so a rejected update never changes the
stored settings. Every core ``ValueError`` is 422
(:func:`_raise_settings_error`); anything else re-raises.
"""
state: DatabaseState = request.app.state.db
try:
settings = await core_settings_store.update_settings(
state.database, changes
)
except ValueError as error:
_raise_settings_error(error)
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_type, payload)`` pair is published DIRECTLY
on the app's ONE transition publisher (``request.app.state.publisher``,
fan-out fire-and-forget, see api/events.py) — the exact payload the
sweep built, never re-fetched and never hand-built here, the same
publish-what-the-core-built shape api/scheduler.py uses. This is a
correctness rule, not a style preference: the core commits the
idempotency watermark BEFORE returning, so a re-fetch-based publish
loop opens a loss window — a concurrent regenerate deleting the
instance, or a transient database error, would raise uncaught (a
bare 500) AFTER the watermark advanced, and no future sweep would
ever re-examine that instance. Publishing the built payload leaves
no such window, and every event of one run keeps the ONE shared
``occurred_at`` stamp the sweep computed for it. 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:
request.app.state.publisher.publish(event_type, payload)
return AdminMissedSweepResponse(fired=len(events))
__all__ = ["router"]
-173
View File
@@ -1,173 +0,0 @@
"""Daily missed-sweep background scheduler for the API service.
The API service has no Home Assistant, so nobody re-fires the
integration's day-rollover listener for it: this module owns the API
plane's own background job. One :class:`MissedSweepScheduler` per app
(installed on ``app.state`` by the lifespan) sleeps until the
configured ``day_rollover_time`` — read fresh from the settings store
at the top of EVERY cycle, so an admin's settings change applies on
the next scheduling decision without a restart — then runs the
HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for the
API host's local date and publishes each returned
``nestquest_quest_missed`` event on the app's ONE transition publisher
(the same SSE stream every route publishes through).
All sweep policy lives in the core (the watermark, the
``[watermark, today)`` window, the no-completion-event rule): this
scheduler only decides WHEN. A same-night rerun — e.g. two workers,
or a settings change moving the rollover past itself — fires nothing:
the core's watermark makes the run an empty no-op.
Testability: ``clock`` (an aware-now callable) and ``sleep`` (an
awaitable delay) are injectable, so tests pin the time, fast-forward
through the wait, and observe the computed delays without waiting a
real day. Cancellation: :meth:`stop` cancels the loop task and waits
for it, so the lifespan's shutdown is clean and a mid-run sweep is
interrupted before the connection closes. A failed iteration (a
database hiccup, a rejected setting) is logged and retried after a
fixed backoff rather than silently killing the job.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
from api.nestquest_core import core_settings_store, core_sweep
LOGGER = logging.getLogger(__name__)
#: Backoff after a failed scheduling cycle (settings read or sweep
#: error), so a persistent failure retries every minute instead of
#: spinning or dying.
_RETRY_BACKOFF_SECONDS = 60.0
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
"""Seconds from ``now`` until the next occurrence of ``target``.
``target`` is a plain ``HH:MM`` time of day: the next wall-clock
occurrence strictly AFTER ``now`` (an equal time schedules for
tomorrow, so the just-run sweep is never instantly re-run).
"""
scheduled = now.replace(
hour=target.hour, minute=target.minute, second=0, microsecond=0
)
if scheduled <= now:
scheduled += datetime.timedelta(days=1)
return (scheduled - now).total_seconds()
class MissedSweepScheduler:
"""Run the missed sweep once per day at ``day_rollover_time``."""
def __init__(
self,
database: object,
*,
publisher: object | None = None,
clock: object | None = None,
sleep: object | None = None,
) -> None:
"""Create the scheduler (not yet running).
``database`` is the app's live :class:`~api.database.NestQuestDatabase`.
``publisher`` is the app's ONE
:class:`~api.events.TransitionPublisher` (publishing is skipped
when ``None``). ``clock`` defaults to the API host's local
aware now (``datetime.datetime.now().astimezone()``) — the same
single-clock discipline the routes' ``_local_now`` uses;
``sleep`` defaults to :func:`asyncio.sleep`.
"""
self._database = database
self._publisher = publisher
self._clock = clock if clock is not None else (
lambda: datetime.datetime.now().astimezone()
)
self._sleep = sleep if sleep is not None else asyncio.sleep
self._task: asyncio.Task | None = None
def start(self) -> None:
"""Start the background loop (idempotent: a running one stays)."""
if self._task is None or self._task.done():
self._task = asyncio.create_task(
self._run_forever(), name="nestquest-missed-sweep"
)
async def stop(self) -> None:
"""Cancel the loop and wait for it to finish (safe if never started)."""
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
@property
def running(self) -> bool:
"""Whether the background loop task is alive."""
return self._task is not None and not self._task.done()
async def _run_forever(self) -> None:
"""Schedule → sweep → repeat, forever (until cancelled)."""
while True:
try:
settings = await core_settings_store.load_settings(
self._database
)
rollover = datetime.time.fromisoformat(
settings.day_rollover_time
)
except Exception:
# Not silent: the failure is logged and retried after
# the backoff, so one bad settings document or a
# transient database error never kills the scheduler.
LOGGER.exception(
"NestQuest missed-sweep scheduler failed to resolve "
"the day rollover time; retrying in %gs",
_RETRY_BACKOFF_SECONDS,
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
continue
delay = _delay_seconds(rollover, self._clock())
LOGGER.info(
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
settings.day_rollover_time,
delay,
)
await self._sleep(delay)
try:
await self._run_once(self._clock().date())
except Exception:
# Not silent either: the sweep error is logged and the
# loop backs off before the next cycle, whose watermark
# re-read makes an already-swept day a no-op.
LOGGER.exception(
"NestQuest scheduled missed sweep failed; retrying in "
"%gs",
_RETRY_BACKOFF_SECONDS,
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
async def _run_once(self, today: datetime.date) -> None:
"""Run ONE sweep for ``today`` and publish what it built.
A failure propagates to :meth:`_run_forever`, which logs it and
backs off before the next cycle; the watermark makes a re-run
of an already-swept day announce nothing twice.
"""
events = await core_sweep.run_missed_sweep(
self._database, today=today
)
for event_type, payload in events:
if self._publisher is not None:
self._publisher.publish(event_type, payload)
LOGGER.info(
"NestQuest scheduled missed sweep for %s published %d event(s)",
today.isoformat(),
len(events),
)
__all__ = ["MissedSweepScheduler"]
+483 -136
View File
@@ -1,78 +1,375 @@
"""The NestQuest integration (Feature 18: a thin client of the NestQuest API service).
The integration owns NO database: the API service (Feature 16) owns the
SQLite file, the migrations, and every business rule. Setup builds the
entry's ONE shared API client, the coordinator that polls the panel
snapshot through it, and the SSE subscription that re-fires the API's
transition frames on the HA bus, registers the single
``nestquest.complete_quest`` proxy service, registers the zero-config
panel dashboard (Feature 20), and forwards the sensor and
binary_sensor platforms.
"""
"""The NestQuest integration."""
from __future__ import annotations
import asyncio
import datetime
import inspect
import sqlite3
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_change
from .const import (
CONF_SNAPSHOT_STALENESS,
CONF_ADMIN_USER_IDS,
CONF_UPDATE_INTERVAL,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
PLATFORMS,
)
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
from .dashboard import async_register_dashboard
from .core.settings import NestQuestSettings
from .frontend import async_register_frontend
from .coordinator import NestQuestCoordinator
from .db import NestQuestDatabase, make_database
from .materialize import materialize as _materialize_run
from .migrations import apply_migrations
from .services import async_deregister_services, async_register_services
from .sse import NestQuestEventStream
from .store import async_get_db_path
from .admin_allowlist import seed_setup_admin
from .sweep import run_missed_sweep
_LOGGER = LOGGER
#: SQLite reports "file is not a database" for corruption in every
#: sqlite3 version this integration targets; the version-specific
#: "encrypted or malformed" spelling (3.41+) maps to the same failure.
_CORRUPTION_MARKERS = (
"file is not a database",
"database disk image is malformed",
"encrypted or malformed",
"is not a database",
)
#: Primary sqlite error codes that mean "the file is corrupt" (SQLite
#: result codes, https://www.sqlite.org/c3ref/c_abort.html). Extended
#: codes carry the primary code in their low byte, so the match is on
#: errcode & 0xff.
_CORRUPT_PRIMARY_CODES = frozenset({11, 26}) # SQLITE_CORRUPT, SQLITE_NOTADB
def _is_corruption_error(error: BaseException) -> bool:
"""Return True when ``error`` indicates a corrupt database file.
Classification order: sqlite_errorcode (Python 3.11+) is primary
and locale-independent — SQLITE_CORRUPT and SQLITE_NOTADB, matched
on the primary byte so extended codes (SQLITE_CORRUPT_VTAB etc.)
classify correctly too. Message matching is the fallback for
older Pythons: the corruption spellings are the documented English
texts; a non-English locale could evade them, but the code check
already covers every locale on supported Pythons.
"""
if not isinstance(error, sqlite3.DatabaseError):
return False
errorcode = getattr(error, "sqlite_errorcode", None)
if errorcode is not None and (errorcode & 0xFF) in _CORRUPT_PRIMARY_CODES:
return True
message = str(error).lower()
return any(marker in message for marker in _CORRUPTION_MARKERS)
async def _preflight_existing_file(
hass: HomeAssistant, db_path: Path
) -> None:
"""Detect corruption in an EXISTING file before it is opened read-write.
Opening read-write persists WAL mode — for a rollback-journal file
that rewrites the header byte 18/19 and can create -wal/-shm
sidecars — which would modify a corrupt file before detection and
break the leave-untouched guarantee. This preflight therefore
validates the WHOLE file with a READ-ONLY connection (mode=ro +
immutable=1 URI, PRAGMA integrity_check, run on the executor like
every other DB call): corruption anywhere fails there, before
anything is written. ``immutable=1`` is SQLite's documented
guarantee that the connection performs NO writes and creates NO
sidecars — even a WAL-header file gets its -wal/-shm reads served
without creating the shared-memory file. Missing/empty files skip
the preflight (they are created fresh by the real open). All
filesystem stat/URI work runs inside the executor job with the
probe so the event loop never touches disk.
"""
def _probe() -> None:
# The file-state check and URI construction live inside the
# executor job: exists/stat/resolve are disk I/O.
if not db_path.exists():
return
# A zero-byte file is neither missing nor a valid database: it
# is a corrupt (truncated-to-nothing) existing file, likely an
# interrupted first write. The leave-untouched contract applies
# to it too: refuse rather than silently initialize over it.
if db_path.stat().st_size == 0:
raise sqlite3.DatabaseError(
"existing database file is empty (zero bytes): file is "
"not a database"
)
# Properly escape the path: '#'/'?'/'%' in a directory or file
# name would otherwise be parsed as URI fragments/parameters/
# escapes. immutable=1: SQLite guarantees no writes and no
# sidecar creation from this connection.
uri = db_path.resolve().as_uri() + "?mode=ro&immutable=1"
conn = sqlite3.connect(uri, uri=True)
try:
row = conn.execute("PRAGMA integrity_check").fetchone()
finally:
conn.close()
verdict = row[0] if row else "missing"
if verdict != "ok":
# A non-ok integrity verdict IS corruption: raise with the
# verdict text (which matches the corruption markers) and
# the NOTADB code so classification is code-based.
raise sqlite3.DatabaseError(
f"integrity_check verdict {verdict!r} on read-only "
f"probe: file is not a database"
)
try:
# Every DB-touching call goes through the executor (the mock
# harness's async_add_executor_job is awaitable too).
await hass.async_add_executor_job(_probe)
except sqlite3.DatabaseError as err:
# Non-ok integrity verdicts are corruption, full stop: there is
# no benign reading of a failed whole-file integrity check, so
# ANY DatabaseError out of the probe that carries a corruption
# marker or code propagates as corruption. Only exotic probe
# failures that classify as non-corruption (e.g. a transient
# 'database is locked' on the read) are downgraded to a debug
# log, letting the real open path surface them.
if _is_corruption_error(err):
raise
raise sqlite3.DatabaseError(
f"read-only corruption probe of {db_path} failed: {err}"
) from err
async def _async_open_database(
hass: HomeAssistant, db_path: Path
) -> NestQuestDatabase:
"""Open the NestQuest database, mapping failure modes for setup.
Three startup cases (Feature 02 done-condition):
- file missing: SQLite creates it fresh on open — no special path,
migrations stamp version 1 on the next step.
- file present and valid: opens normally.
- file present but corrupt: log an ERROR with the path, close
anything opened, and raise ConfigEntryNotReady so HA retries —
the file is left byte-for-byte untouched so the owner can
restore a backup. No code path here deletes or overwrites an
existing database file.
Corruption can surface at EITHER stage: a truncated header fails
in the read-only preflight (before the file is ever opened
read-write), while damaged later pages fail during migrations —
the preflight guarantees no WAL sidecar or header write happens
on a corrupt file before it is detected. Both are classified the
same way.
"""
try:
await _preflight_existing_file(hass, db_path)
database = await make_database(hass).open(db_path)
except asyncio.CancelledError:
raise
except sqlite3.DatabaseError as err:
if _is_corruption_error(err):
_LOGGER.error(
"NestQuest database at %s is corrupt: %s. Refusing to "
"start with a corrupt database; the file was left "
"untouched so a backup can be restored. Home Assistant "
"will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database corrupt: {db_path}"
) from err
# open() failed for a non-corruption reason (locked file,
# permission problem): retryable, but nothing was opened.
_LOGGER.error(
"NestQuest database at %s failed to open: %s. Home "
"Assistant will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database unavailable: {db_path}"
) from err
try:
await apply_migrations(database)
except (sqlite3.DatabaseError, RuntimeError) as err:
await database.close()
if _is_corruption_error(err):
_LOGGER.error(
"NestQuest database at %s is corrupt: %s. Refusing to "
"start with a corrupt database; the file was left "
"untouched so a backup can be restored. Home Assistant "
"will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database corrupt: {db_path}"
) from err
# A non-corruption failure (e.g. a locked file, a future schema
# version) is also a retryable setup problem, but without the
# corruption framing.
_LOGGER.error(
"NestQuest database at %s failed to open: %s. Home "
"Assistant will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database unavailable: {db_path}"
) from err
except asyncio.CancelledError:
await database.close()
raise
return database
@dataclass
class NestQuestRuntimeData:
"""Runtime data stored on a NestQuest config entry."""
entry_id: str
settings: NestQuestSettings
database: NestQuestDatabase
remove_update_listener: Callable[[], Any]
remove_time_change_listener: Callable[[], Any]
coordinator: Any = None
event_stream: NestQuestEventStream | None = None
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
"""Return every Home Assistant owner-account user id.
The last-resort admin seed: when no persisted allowlist copy and no
flow-context user survive to first setup, the HA owner accounts are
seeded so the household is never left with zero admins (an empty
allowlist fails closed, which would lock the household out of its
own integration). Owner accounts can already do everything in HA,
so granting NestQuest admin is not a privilege escalation.
"""
auth = getattr(hass, "auth", None)
if auth is None:
return []
users = await auth.async_get_users()
owners = [
user.id
for user in users
if getattr(user, "is_owner", False) and getattr(user, "id", None)
]
return owners
async def _run_horizon_materialization(
hass: HomeAssistant,
database: NestQuestDatabase,
settings: NestQuestSettings,
) -> None:
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
"today" is computed in HA local time (``hass.config.time_zone``) rather
than the system clock, so the horizon tracks the household's own day.
The walk itself runs through :func:`~.materialize.materialize`, whose DB
access is executor-only.
This is the ONE generation path: the startup backfill, the daily rollover
listener and the ``regenerate`` service all funnel through it, so there is
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
silent default; every caller already passes the entry's validated
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
window itself comes from :meth:`settings.horizon_window` so the helper is
real and shared (not recomputed inline here).
"""
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
start, end = settings.horizon_window(today)
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
def _register_day_rollover_listener(
hass: HomeAssistant,
database: NestQuestDatabase,
settings: NestQuestSettings,
) -> Callable[[], Any]:
"""Register the daily rollover listener and return its remover.
Fires once per day at the configured local ``day_rollover_time``:
first re-materializes the rolling horizon
``[today, today + horizon_days]`` through the shared
:func:`_run_horizon_materialization` path (the entry's configured,
validated ``horizon_days`` carried on ``settings``), then runs the
Feature 11 missed sweep — announcing yesterday's still-open quests
as missed AFTER today's instances exist, watermark-guarded so a
re-fire of the listener the same night announces nothing twice.
"""
hour, minute = settings.day_rollover_hour_minute
async def _run_materialization(_now: datetime.datetime) -> None:
await _run_horizon_materialization(hass, database, settings)
await run_missed_sweep(hass, database)
return async_track_time_change(
hass, _run_materialization, hour=hour, minute=minute
)
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
"""Return a live runtime record with a coordinator, or None.
"""Return a live runtime record with a connected database, or None.
The domain-global service (the panel completion proxy) has no
database of its own; it resolves whichever config entry's runtime
data is currently live at call time, so it cannot hold a stale
handle to an unloaded entry's coordinator.
The domain-global services (e.g. ``regenerate``) have no database of
their own; they resolve whichever config entry's runtime data is
currently live at call time, so none of them can hold a stale handle
to a closed connection after another entry unloads. The record also
carries that entry's validated ``settings`` (e.g. the configured
horizon) so service handlers read horizon days off it rather than
re-reading HA config.
"""
for runtime_data in hass.data.get(DOMAIN, {}).values():
if getattr(runtime_data, "coordinator", None) is not None:
database = getattr(runtime_data, "database", None)
if database is not None and database.connected:
return runtime_data
return None
def _register_services(hass: HomeAssistant) -> None:
"""Register the domain-global NestQuest service once.
def _make_regenerate_handler(hass: HomeAssistant):
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
The service is DOMAIN-global — registered exactly once (guarded by
``has_service``) and NOT bound to any config entry — so the panel
completion proxy resolves the currently-live runtime at call time
async def _regenerate(_call: Any) -> None:
runtime_data = _find_live_runtime_data(hass)
if runtime_data is None:
_LOGGER.warning(
"NestQuest regenerate requested but no config entry has a "
"live database; ignoring"
)
return
await _run_horizon_materialization(
hass, runtime_data.database, runtime_data.settings
)
return _regenerate
def _register_services(hass: HomeAssistant) -> None:
"""Register domain-global NestQuest services once.
Services are DOMAIN-global — registered exactly once (guarded by
``has_service`` on regenerate) and NOT bound to any config entry —
so each handler resolves the currently-live database at call time
and a name collision or duplicate setup is impossible. Removal is
handled by the unload path only once the LAST entry is removed
(see :func:`_deregister_services`).
"""
async_register_services(hass, find_runtime=_find_live_runtime_data)
async_register_services(
hass,
find_runtime=_find_live_runtime_data,
regenerate_handler=_make_regenerate_handler(hass),
)
def _deregister_services(hass: HomeAssistant) -> None:
@@ -89,14 +386,48 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up NestQuest from a config entry."""
await async_register_frontend(hass)
# The zero-config panel dashboard (Feature 20): register the
# storage-mode strategy dashboard if no dashboard under the stable
# url_path exists yet. Unavailability of the Lovelace dashboards
# collection (YAML mode, lovelace not loaded) logs a warning and
# NEVER fails setup; an existing dashboard — ours or the user's —
# is left untouched, so a reload cannot duplicate it.
await async_register_dashboard(hass)
hass.data.setdefault(DOMAIN, {})
existing = hass.data[DOMAIN].get(entry.entry_id)
if existing is not None:
db = getattr(existing, "database", None)
if db is None or not db.connected:
# Reopen through the SAME safety path as first setup: a
# record without a live connection (a previous setup's
# close-on-failure, or a hand-replaced file) must get the
# missing/valid/corrupt classification too, never a raw
# sqlite3 error and never skipped migrations — and the
# same allowlist seeding, so a reopened fresh file is
# never ownerless.
db = await _async_open_database(
hass, await async_get_db_path(hass)
)
try:
stored_admin_ids = (
entry.options.get(CONF_ADMIN_USER_IDS)
or entry.data.get(CONF_ADMIN_USER_IDS)
)
context = getattr(entry, "context", None)
context_user_id = (
context.get("user_id")
if isinstance(context, dict)
else None
)
await seed_setup_admin(
db,
stored_admin_ids=stored_admin_ids,
context_user_id=context_user_id,
owner_ids=await _async_owner_user_ids(hass),
)
except BaseException:
# A seeding failure must not leak the just-opened
# connection: close it before the error propagates, so
# HA's setup retry starts from a clean handle (the
# first-setup path closes symmetrically).
await db.close()
raise
existing.database = db
entry.runtime_data = existing
return True
async def _async_update_listener(
listener_hass: HomeAssistant, listener_entry: ConfigEntry
@@ -104,94 +435,109 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Reload the entry when its options change."""
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
database = await _async_open_database(hass, await async_get_db_path(hass))
remove_update_listener: Callable[[], Any] | None = None
remove_time_change_listener: Callable[[], Any] | None = None
coordinator: Any = None
event_stream: NestQuestEventStream | None = None
try:
# Seed the admin allowlist on first setup so the owner is never
# locked out: an empty database takes the persisted admin copy
# — the options flow's saved list first (it is the current
# one), else the config flow's user from entry data — and only
# then the HA user still present on the entry context. No-op
# on a non-empty list (fail-closed narrowing is deliberate).
stored_admin_ids = (
entry.options.get(CONF_ADMIN_USER_IDS)
or entry.data.get(CONF_ADMIN_USER_IDS)
)
context = getattr(entry, "context", None)
context_user_id = (
context.get("user_id")
if isinstance(context, dict)
else None
)
owner_ids = await _async_owner_user_ids(hass)
await seed_setup_admin(
database,
stored_admin_ids=stored_admin_ids,
context_user_id=context_user_id,
owner_ids=owner_ids,
)
# Build the explicit settings object from entry.options ONCE: the
# horizon materialization and the daily-rollover listener both
# consume it, so core never reads HA config itself. A malformed
# stored option falls back PER FIELD (via from_options_resilient)
# so a single bad sibling option cannot reset the user's
# horizon_days or day_rollover_time — the two fields core
# consumes — to their defaults.
settings = NestQuestSettings.from_options_resilient(entry.options)
remove_update_listener = entry.add_update_listener(_async_update_listener)
remove_time_change_listener = _register_day_rollover_listener(
hass, database, settings
)
# Backfill any days the daily listener missed while the integration
# was off (or freshly installed): run the one idempotent walk over
# [today, today + horizon_days] once at setup, using the entry's
# configured horizon. This runs through the SAME path as the daily
# listener and regenerate service.
await _run_horizon_materialization(hass, database, settings)
# The missed sweep runs at startup too (Feature 11): if HA was
# down at the rollover time, the previous night's still-open
# quests are announced as missed now instead of waiting for
# the next midnight. Watermark-guarded, so this is a no-op
# when the night's sweep already ran.
await run_missed_sweep(hass, database)
_register_services(hass)
# ONE API client for the entry, shared by the coordinator's
# snapshot polling, the SSE subscription, and the completion
# proxy (the client is stateless — one session, one connection
# pool). An entry with no panel token yet gets the
# typed-error stub (unavailable entities, never a setup crash).
api_client = coordinator_client_from_entry(hass, entry)
# The shared coordinator: one refresh cycle every entity reads
# from (CONF_UPDATE_INTERVAL seconds, default five minutes).
# It polls the API service's panel snapshot; a failed poll
# keeps the entities rendering the last-good snapshot within
# CONF_SNAPSHOT_STALENESS, unavailable past it.
# The shared Feature 10 coordinator: one refresh cycle every
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
# five minutes). Created AFTER services so a failure below
# unwinds them through the except path's listener handling.
coordinator = NestQuestCoordinator(
hass,
entry_id=entry.entry_id,
api_client=api_client,
database=database,
settings=settings,
update_interval_seconds=entry.options.get(
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
),
staleness_seconds=entry.options.get(
CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS
),
)
# The panel event subscription (the SSE task): a background
# task consumes the API service's transition-event stream and
# re-fires the four documented HA bus events. It reuses the
# coordinator's client, so it only starts when that client
# actually offers a stream — an unconfigured-token stub (or a
# test snapshot client without ``stream_events``) leaves it
# unstarted, and the typed-error path already covers
# availability for those entries.
if callable(getattr(api_client, "stream_events", None)):
event_stream = NestQuestEventStream(hass, api_client)
event_stream.start()
await coordinator.async_config_entry_first_refresh()
runtime_data = NestQuestRuntimeData(
entry_id=entry.entry_id,
settings=settings,
database=database,
remove_update_listener=remove_update_listener,
remove_time_change_listener=remove_time_change_listener,
coordinator=coordinator,
event_stream=event_stream,
)
# The runtime record is registered BEFORE the first refresh:
# the service proxy resolves the entry's runtime state through
# hass.data, and it must find the live coordinator there —
# never a half-set-up entry. The platforms forward below reads
# the coordinator off the entry (the HA runtime-data contract);
# a forward failure below unwinds it through the except path.
hass.data[DOMAIN][entry.entry_id] = runtime_data
# runtime_data is assigned BEFORE the platforms forward so each
# platform's async_setup_entry reads its coordinator off the
# entry (the HA runtime-data contract); a forward failure below
# unwinds it through the except path.
entry.runtime_data = runtime_data
# The first refresh polls the API snapshot; an API failure
# (unconfigured token, API service down) does NOT crash setup:
# the coordinator reports last_update_success=False, entities
# come up unavailable, and HA's standard retry cycle applies
# once the API is reachable. HA's real
# ``async_config_entry_first_refresh`` wraps ANY failed update
# pass in ConfigEntryNotReady — the original error survives
# only as ``__cause__``, never as the raised exception — so the
# catch keys on ConfigEntryNotReady, NOT on the typed API
# error. Everything else (a platform-forward failure,
# cancellation) still unwinds through the except block below.
try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
pass
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
except BaseException:
# A failure anywhere after the listener was registered must not
# leak it (or the SSE task): unwind EVERYTHING acquired before
# the failure, keeping the original error as the one raised.
# Removal is best-effort — a failing step must not mask the
# setup error that triggered it.
if event_stream is not None:
# A failure anywhere after the database is open must not leak the
# listeners already registered against that (now-closed) database:
# unwind EVERY remover we acquired before the failure, keeping the
# original error as the one raised. Removal is best-effort — a
# failing remover must not mask the setup error that triggered it.
for remove_listener in (
remove_time_change_listener,
remove_update_listener,
):
if remove_listener is None:
continue
try:
await event_stream.stop()
except BaseException:
pass
if remove_update_listener is not None:
try:
remove_update_listener()
result = remove_listener()
if inspect.isawaitable(result):
await result
except BaseException:
pass
# A coordinator created before the failure holds entity
# listeners pointed at the never-registered runtime — shut it
# down so no scheduled refresh outlives the failed setup.
# listeners pointed at the database being closed below —
# shut it down FIRST so no scheduled refresh can ever run
# against a closed connection.
if coordinator is not None:
try:
await coordinator.async_shutdown()
@@ -199,20 +545,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
pass
# A platform-forward failure reached this unwind AFTER
# runtime_data was assigned: clear it so no later unload path
# re-invokes the already-consumed update-listener remover
# through the stale record.
# closes the already-closed database through the stale record.
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
# The runtime record was ALSO registered in hass.data before
# the first refresh: remove it here on ANY failure, or a later
# unload picks up the stale, already-torn-down record
# (hass.data wins over entry.runtime_data there) and
# re-invokes the already-consumed update-listener remover.
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
await database.close()
raise
# The runtime record was already registered before the first
# refresh; re-assert the (unchanged) record so the return path
# stays readable as one registration site.
assert remove_update_listener is not None
assert remove_time_change_listener is not None
hass.data[DOMAIN][entry.entry_id] = runtime_data
return True
@@ -227,50 +566,58 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if runtime_data is None:
runtime_data = getattr(entry, "runtime_data", None)
# Platforms unload FIRST (mirroring HA conventions): entity
# listeners must not outlive the coordinator the teardown below
# shuts down.
# listeners must not outlive the database and coordinator the
# teardown below closes.
platforms_unloaded = await hass.config_entries.async_unload_platforms(
entry, PLATFORMS
)
if platforms_unloaded is False and runtime_data is not None:
# Home Assistant KEPT one or more platforms loaded — their
# entities still live and must keep their coordinator. Restore
# entities still live and must keep their database. Restore
# the runtime record popped above and report failure so HA
# retries the unload later instead of leaving live entities
# backed by a torn-down coordinator.
# backed by a closed database.
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
return False
unload_error: BaseException | None = None
if runtime_data is not None:
# The SSE subscription is stopped FIRST: its task must be gone
# (no frame can fire) before anything else is torn down.
event_stream = getattr(runtime_data, "event_stream", None)
if event_stream is not None:
stop_stream = getattr(event_stream, "stop", None)
if stop_stream is not None:
await stop_stream()
remove_update_listener = getattr(
runtime_data, "remove_update_listener", None
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
remove_time_change_listener = getattr(
runtime_data, "remove_time_change_listener", None
)
try:
if remove_update_listener is not None:
result = remove_update_listener()
if inspect.isawaitable(result):
await result
except BaseException as err:
# A failing remover must not skip the coordinator shutdown
# below; the first error is retained and re-raised only
# after the teardown ran.
if unload_error is None:
unload_error = err
for remove_listener in (
remove_update_listener,
remove_time_change_listener,
):
if remove_listener is None:
continue
try:
result = remove_listener()
if inspect.isawaitable(result):
await result
except BaseException as err:
# Cancel EVERY listener even when an earlier remover
# raises: a skipped remover would leave a daily
# callback firing against a closed database after
# unload. The first error is retained and re-raised
# only after both removers (and the DB close) ran.
if unload_error is None:
unload_error = err
finally:
# The coordinator's entity listeners must stop here: a
# scheduled refresh must never outlive the unload.
# The coordinator's entity listeners must stop BEFORE the
# database closes: a scheduled refresh starting during the
# close await would touch a cleared connection. (The
# platform unload above already removed the entities; this
# clears the coordinator's own remaining listeners.)
coordinator = getattr(runtime_data, "coordinator", None)
if coordinator is not None:
shutdown = getattr(coordinator, "async_shutdown", None)
if shutdown is not None:
await shutdown()
database = getattr(runtime_data, "database", None)
if database is not None:
await database.close()
# Domain-global services are torn down only once the FINAL entry
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
# entry is still loaded.
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.admin_allowlist` for the integration package.
The admin allowlist business layer lives in
:mod:`custom_components.nestquest.core.admin_allowlist` (extracted in
task 510c1f78); this module re-exports it so existing
``from .admin_allowlist import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
from .core.admin_allowlist import * # noqa: F401,F403
-414
View File
@@ -1,414 +0,0 @@
"""Panel-plane API client for the NestQuest integration (Feature 18).
The integration talks to the NestQuest API service (Feature 16) over
HTTP instead of reading the database: this module is the ONE place that
speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's
household snapshot) and ``POST /api/v1/panel/instances/{id}/complete``
(completion as the tapped child profile) — authenticated with the
static panel service token as a Bearer credential on every request.
Transport-injected by construction: the constructor takes the base URL,
the panel token, and the HTTP session to use. The session speaks the
aiohttp ``ClientSession`` surface (``request(method, url, headers=...,
json=...)`` returning an async context manager whose response carries
``status`` and ``await text()``), so tests stub the transport with NO
network and NO aiohttp installed; production builds the client through
:func:`client_from_entry`, which hands in Home Assistant's shared
aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``). The module itself imports NEITHER
homeassistant NOR aiohttp at runtime (aiohttp appears only behind
``TYPE_CHECKING`` for annotations), so the client is usable anywhere —
the HA coupling lives in the one production factory.
Every failure is typed: a transport failure (connection refused, DNS
failure, timeout — the exchange never produced a response) and every
non-2xx HTTP response raise :class:`NestQuestApiError`. The error
carries the HTTP status where there is one (so 401 and 404 are
distinguishable at the call site) and ``None`` when there is not; the
original transport error is chained as ``__cause__`` — nothing is
swallowed. There are NO retries and the client logs nothing: refresh
cadence is the coordinator's concern (the next task), and the panel
token is a secret that must never reach a log line or an error
message — the error texts name the method, path, and status only.
The panel plane's SSE event stream (``GET /api/v1/panel/events``) is
exposed by :meth:`NestQuestApiClient.stream_events` — an async
generator yielding ``(event_type, payload)`` per Server-Sent Events
frame (``event: <type>`` / ``data: <json>`` lines separated by a blank
line). Opening the stream gets the same one-timeout, typed-error
contract as every other call; reading it has NO body-wide timeout (a
healthy stream is long-lived — the subscription manager owns
reconnection and backoff).
"""
from __future__ import annotations
import asyncio
import inspect
import json
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from .const import (
CONF_API_BASE_URL,
CONF_PANEL_TOKEN,
DEFAULT_API_BASE_URL,
DEFAULT_PANEL_TOKEN,
)
if TYPE_CHECKING:
import aiohttp
__all__ = [
"COMPLETE_INSTANCE_PATH",
"DEFAULT_TIMEOUT_SECONDS",
"EVENTS_PATH",
"NestQuestApiError",
"NestQuestApiClient",
"SNAPSHOT_PATH",
"client_from_entry",
"is_valid_base_url",
"resolve_api_config",
]
#: The panel plane's snapshot route (Feature 16).
SNAPSHOT_PATH = "/api/v1/panel/snapshot"
#: The panel plane's SSE event-stream route (Feature 16): each
#: transition arrives as an ``event: <type>`` / ``data: <json>`` frame.
EVENTS_PATH = "/api/v1/panel/events"
#: The panel plane's completion route template (Feature 16); the
#: ``{instance_id}`` placeholder is filled per call.
COMPLETE_INSTANCE_PATH = "/api/v1/panel/instances/{instance_id}/complete"
#: Request timeout, in seconds. One attempt, one timeout: a hung API
#: call surfaces as a typed error for the coordinator to report, never
#: a retry loop.
DEFAULT_TIMEOUT_SECONDS = 10.0
class NestQuestApiError(Exception):
"""A failed NestQuest API exchange, raised for every failure mode.
``status`` carries the HTTP status when a response arrived (a
non-2xx response — 401 for a bad token, 404 for an unknown
instance) and is ``None`` when the exchange failed at the transport
level (connection refused, DNS failure, timeout) — so callers
distinguish the two with ``is_transport_error``. The original
transport error, where there is one, is chained as ``__cause__``.
Messages carry the method, path, and status — never the request
headers, so the panel token can never leak through an error.
"""
def __init__(self, message: str, *, status: int | None = None) -> None:
super().__init__(message)
self.status = status
@property
def is_transport_error(self) -> bool:
"""True when the exchange failed before any response arrived."""
return self.status is None
def is_valid_base_url(value: object) -> bool:
"""Return True when ``value`` is a usable API base URL.
A full ``http://`` or ``https://`` URL with a host — the exact
shape the options flow validates (form ergonomics) and the client
constructor enforces (fail fast, never build requests against a
scheme-less or host-less string).
"""
if not isinstance(value, str):
return False
candidate = value.strip()
if not candidate:
return False
parsed = urlparse(candidate)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def resolve_api_config(entry: Any) -> tuple[str, str]:
"""Resolve ``(base_url, panel_token)`` off a config entry.
Precedence mirrors the options flow's stored-values resolution:
``entry.options`` first (the live options-flow copy), then
``entry.data`` (the config-flow-era copy), then the const defaults.
The token is a secret: callers must never log the returned pair.
"""
options = getattr(entry, "options", None) or {}
data = getattr(entry, "data", None) or {}
def _pick(key: str, default: str) -> str:
if key in options:
return options[key]
if key in data:
return data[key]
return default
return (
_pick(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
_pick(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
)
def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
"""Build the production client for a config entry.
Reads the configured base URL and panel token off the entry
(:func:`resolve_api_config`) and hands the client Home Assistant's
SHARED aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``) so panel calls ride HA's one connection
pool. The helper import is deferred into this function on purpose:
the test harness is mock-only and the module must import (and the
client must be stub-transport testable) without homeassistant or
aiohttp installed.
"""
from homeassistant.helpers.aiohttp_client import async_get_clientsession
base_url, panel_token = resolve_api_config(entry)
return NestQuestApiClient(
base_url, panel_token, async_get_clientsession(hass)
)
class NestQuestApiClient:
"""Async client for the NestQuest API service's panel plane.
ONE instance per config entry (the coordinator task wires it into
the refresh cycle). The client is deliberately thin: it adds the
Bearer credential and the base URL, enforces one timeout per
request, and turns every failure into :class:`NestQuestApiError` —
no business logic, no retries, no caching.
"""
def __init__(
self,
base_url: str,
panel_token: str,
session: aiohttp.ClientSession,
*,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
) -> None:
"""Store the connection settings; the session is injected.
``session`` is an aiohttp ``ClientSession`` in production and a
stub transport in tests. ``base_url`` must be a full http(s)
URL and ``panel_token`` must be non-empty — a client built
without either can only fail later at request time with a
less-explicable error, so construction fails fast instead.
"""
if not is_valid_base_url(base_url):
raise ValueError(
f"base_url must be a full http(s) URL, got {base_url!r}"
)
if not isinstance(panel_token, str) or not panel_token.strip():
raise ValueError("panel_token must be a non-empty string")
if (
not isinstance(timeout_seconds, (int, float))
or isinstance(timeout_seconds, bool)
or timeout_seconds <= 0
):
raise ValueError(
f"timeout_seconds must be a positive number, got {timeout_seconds!r}"
)
self._base_url = base_url.strip().rstrip("/")
self._panel_token = panel_token
self._session = session
self._timeout_seconds = float(timeout_seconds)
async def get_snapshot(self) -> dict[str, Any]:
"""Fetch and parse today's household snapshot.
Returns the JSON the snapshot route returns (``today_iso``,
``cycle_day``, and the per-child list — see
``api/routes_panel.py`` for the documented shape). Raises
:class:`NestQuestApiError` on transport failure, timeout, or a
non-2xx response.
"""
return await self._request_json("GET", SNAPSHOT_PATH)
async def complete_instance(
self, instance_id: int, actor_child_id: int
) -> dict[str, Any]:
"""Complete one quest instance as the tapped child profile.
POSTs ``{"actor_child_id": actor_child_id}`` to the complete
route and returns the parsed response body (the route answers
``{"status": ...}`` both for a first completion and for an
idempotent repeat, so a retried panel tap succeeds). Raises
:class:`NestQuestApiError` on transport failure, timeout, or a
non-2xx response — 401 for a wrong token, 404 for an unknown
instance.
"""
path = COMPLETE_INSTANCE_PATH.format(instance_id=instance_id)
return await self._request_json(
"POST", path, json_body={"actor_child_id": actor_child_id}
)
async def stream_events(
self,
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
"""Yield ``(event_type, payload)`` for every panel SSE frame.
Opens ``GET /api/v1/panel/events`` with the Bearer credential
and parses the Server-Sent Events framing the API service
emits: ``event: <type>`` and ``data: <json>`` lines closed by a
blank line (one frame per transition event; a frame's ``data``
may span several ``data:`` lines, joined with newlines — the
standard SSE rule). Line splits across chunk boundaries are
buffered, so frames are parsed correctly whatever chunking the
transport produces.
Opening the stream carries the SAME one-timeout, typed-error
contract as every other call: a non-2xx response raises
:class:`NestQuestApiError` with its status and a transport
failure (connection refused, DNS, the open timing out) raises
one with ``status=None`` and the original error chained. The
reading loop itself has NO timeout — a healthy stream is
long-lived — but any failure raised from the transport
mid-stream raises typed as well, so the consumer can reconnect.
A frame whose ``data`` is not valid JSON is a protocol failure
and raises typed, carrying the response status it arrived
with. Cancellation is never converted: closing the generator
(``aclose``, i.e. the consumer's task being cancelled) stops
cleanly and closes the underlying response in every path.
"""
url = f"{self._base_url}{EVENTS_PATH}"
headers = {"Authorization": f"Bearer {self._panel_token}"}
# Opening the response gets ONE request timeout, exactly like
# ``_request_json``; the read loop below runs WITHOUT one — a
# body-wide timeout would kill a healthy long-lived stream.
# The response context manager is entered manually (not via
# ``async with``) because the block must span the yields below;
# the ``finally`` closes it on EVERY exit path.
try:
async with asyncio.timeout(self._timeout_seconds):
stream_cm = self._session.request(
"GET", url, headers=headers, json=None
)
response = await stream_cm.__aenter__()
except asyncio.CancelledError:
# Cancellation is never converted (see ``_request_json``).
raise
except NestQuestApiError:
raise
except Exception as err:
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
status=None,
) from err
status = response.status
if not 200 <= status < 300:
await self._close_stream(stream_cm)
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} failed with HTTP {status}",
status=status,
)
try:
event_type: str | None = None
data_lines: list[str] = []
buffer = ""
async for chunk in response:
if isinstance(chunk, (bytes, bytearray)):
chunk = chunk.decode("utf-8")
buffer += chunk
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
if line.startswith("event:"):
event_type = line[len("event:"):].strip()
elif line.startswith("data:"):
data_lines.append(line[len("data:"):].strip())
elif not line and event_type is not None:
# A blank line ends the frame. A trailing
# incomplete frame (stream ends without its
# closing blank line) is discarded, per the
# SSE spec's treatment of incomplete events.
try:
payload = json.loads("\n".join(data_lines))
except ValueError as err:
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} "
f"delivered a malformed SSE frame: {err}",
status=status,
) from err
yield event_type, payload
event_type = None
data_lines = []
except asyncio.CancelledError:
raise
except NestQuestApiError:
raise
except Exception as err:
raise NestQuestApiError(
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
status=None,
) from err
finally:
await self._close_stream(stream_cm)
@staticmethod
async def _close_stream(stream_cm: Any) -> None:
"""Close an opened streaming response context manager.
Works for aiohttp's response context manager (whose
``__aexit__`` is a coroutine) and for test stubs that return
either a coroutine or a plain value.
"""
result = stream_cm.__aexit__(None, None, None)
if inspect.isawaitable(result):
await result
async def _request_json(
self,
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
) -> Any:
"""Run ONE request and return the parsed JSON body.
The Bearer token rides the Authorization header only. A
non-2xx response raises :class:`NestQuestApiError` with its
status; anything the transport raises (connection, timeout) is
wrapped as a transport-level :class:`NestQuestApiError` with
the original error chained — never swallowed, never retried.
A 2xx body that is not valid JSON is a protocol failure and
raises typed too, carrying the status it arrived with.
"""
url = f"{self._base_url}{path}"
headers = {"Authorization": f"Bearer {self._panel_token}"}
try:
async with asyncio.timeout(self._timeout_seconds):
async with self._session.request(
method, url, headers=headers, json=json_body
) as response:
status = response.status
if not 200 <= status < 300:
raise NestQuestApiError(
f"NestQuest API {method} {path} failed with "
f"HTTP {status}",
status=status,
)
body_text = await response.text()
except asyncio.CancelledError:
# Cancellation is never converted: a shutting-down event
# loop must stop, not read a synthesized API error.
raise
except NestQuestApiError:
raise
except Exception as err:
raise NestQuestApiError(
f"NestQuest API {method} {path} transport failure: {err}",
status=None,
) from err
try:
return json.loads(body_text)
except ValueError as err:
raise NestQuestApiError(
f"NestQuest API {method} {path} returned malformed "
f"JSON: {err}",
status=status,
) from err
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.children` for the integration package.
The children registry business layer lives in
:mod:`custom_components.nestquest.core.children` (extracted in
task 510c1f78); this module re-exports it so existing
``from .children import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.children import * # noqa: F401,F403
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.completion` for the integration package.
The completion business layer lives in
:mod:`custom_components.nestquest.core.completion` (extracted in
task 510c1f78); this module re-exports it so existing
``from .completion import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.completion import * # noqa: F401,F403
+15 -7
View File
@@ -7,7 +7,7 @@ from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN
from .const import CONF_ADMIN_USER_IDS, DOMAIN
class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@@ -30,11 +30,11 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> FlowResult:
"""Handle the user step of the config flow.
The integration needs no configuration to start (the API
connection is set in the options flow), so the user input is
stored as-is. The former admin-allowlist copy is gone: with
the database removed the allowlist lives in the API service's
own store, not on the entry.
The HA user completing the flow is persisted into the entry
data as the initial admin allowlist copy: real HA does not
guarantee the flow context survives to setup, and entry data is
the persisted record first setup seeds from — the owner is
never locked out.
"""
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
if self._async_in_progress() or self._async_current_entries():
@@ -43,4 +43,12 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="user")
return self.async_create_entry(title="NestQuest", data=dict(user_input))
data = dict(user_input)
flow_user_id = (
self.context.get("user_id")
if isinstance(self.context, dict)
else None
)
if flow_user_id:
data[CONF_ADMIN_USER_IDS] = [flow_user_id]
return self.async_create_entry(title="NestQuest", data=data)
+38 -302
View File
@@ -1,4 +1,4 @@
"""The shared NestQuest DataUpdateCoordinator (Feature 10, Feature 18).
"""The shared NestQuest DataUpdateCoordinator (Feature 10).
ONE refresh pass builds the whole entity snapshot: active children,
today's instances with their derived states (open / done / missed via
@@ -7,81 +7,54 @@ per-child rollup counts. Every Feature 10 entity reads
``coordinator.data`` — no entity queries the database itself, so one
refresh updates the whole board coherently.
Since Feature 18 the snapshot no longer comes from the local database:
the coordinator polls the NestQuest API service's panel plane —
``GET /api/v1/panel/snapshot`` through
:class:`~.api_client.NestQuestApiClient` on its update interval — and
RECONSTRUCTS the same core dataclasses the local builder produced
(:class:`~.core.snapshot.NestQuestSnapshot`,
:class:`~.core.snapshot.ChildDaySnapshot`,
:class:`~.core.snapshot.QuestInstanceView`). ``sensor.py`` and
``binary_sensor.py`` read ``coordinator.data`` and
:func:`~.core.snapshot.instance_payload` exactly as before, so every
entity_id and attribute payload shape is unchanged: the panel cards and
the four blueprints keep working untouched.
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
host clock and never UTC, matching the materialization and completion
layers.
One loss is deliberate and documented (D-009 revisited): the panel
payload OMITS missed instances, so a snapshot rebuilt from the API can
no longer carry them — ``admin_instances`` (``include_missed=True``)
now equals ``instances`` (they can hold only open and completed rows).
The admin surface for missed quests is the PWA, which reads the API
service directly; the integration's entities never needed missed rows
for their states or counts (the counts arrive precomputed).
The snapshot assembly itself lives in the Home-Assistant-free
:mod:`custom_components.nestquest.core.snapshot` module
(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin
HA wrapper that resolves the local timezone and reads the settings handed
into its constructor, then delegates to that builder, passing the SAME
HA-local ``now`` it resolved in one clock read. The API service's panel
snapshot route shares the same builder, so both consumers render ONE
snapshot shape.
Failure handling: constructing the client fails fast (ValueError) when
the entry has no panel token configured, and a fetch failure raises the
typed :class:`~.api_client.NestQuestApiError`. Neither crashes
integration setup: the constructor logs a clear message and hands back
a client-shaped stub whose refresh raises the same typed error, and
``_async_update_data`` converts every typed error into
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the
exception a HA ``DataUpdateCoordinator`` records as a failed pass
(``last_update_success = False``, ``data = None``), so the entities
come up unavailable. The typed error is never swallowed silently: it
is logged with its message and chained into the UpdateFailed. (The
conversion matters at setup: HA's real
``async_config_entry_first_refresh`` wraps any failed pass in
``ConfigEntryNotReady``, so a setup that keys on the original
exception type would never match it.)
Presence is resolved through the SAME engine the materializer uses
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
with the public DAO methods and the engine's anchor-date arithmetic
answers ``is this child at this house today`` (D-004 — never ISO week
parity).
Last-good cache and staleness: every successful pass refreshes a cache
of the rebuilt snapshot stamped with the :func:`time.monotonic`
instant of the success. A typed failure is checked against that stamp
BEFORE the pass is declared failed: within the configured staleness
threshold (``CONF_SNAPSHOT_STALENESS``, seconds, default three update
intervals) the pass SUCCEEDS with the cached snapshot — the panel
keeps rendering last-good data and the entities stay available — and
the outage is logged as a warning. Entities go unavailable only once
the cache is older than the threshold (or before the first success);
a later successful fetch refreshes the cache and the entities recover
on their own, without a config-entry reload.
``cycle_day`` reports where today falls in the custody cycle of the
first active child (display order) that HAS a schedule — 1-based day
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's
anchor date. ``0`` means no active child has a schedule at all, so
the admin header degrades to a harmless zero instead of guessing.
"""
from __future__ import annotations
import asyncio
import datetime
import time
from typing import Any
from zoneinfo import ZoneInfo
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config
from .const import (
CONF_UPDATE_INTERVAL,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
MIN_UPDATE_INTERVAL,
)
from .core.db import NestQuestDatabase
from .core.settings import NestQuestSettings
from .core.snapshot import (
ChildDaySnapshot,
NestQuestSnapshot,
QuestInstanceView,
build_snapshot,
)
__all__ = [
@@ -92,131 +65,9 @@ __all__ = [
]
def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
"""Reconstruct the core snapshot dataclasses from the API's JSON.
The panel snapshot route (``api/routes_panel.py``) returns today's
household as plain JSON — ``today_iso``, ``cycle_day``, and the
per-child list with precomputed counts and the instances in the
documented payload shape. This function maps that shape BACK onto
the dataclasses ``sensor.py`` / ``binary_sensor.py`` read, field by
field:
- the API's ``state`` ``"completed"`` maps back to the core view's
``"done"`` (so :func:`~.core.snapshot.instance_payload` renders
``"completed"`` again — the round-trip is identity for open and
completed rows);
- ``overdue``, ``on_time`` (→ ``was_on_time``), ``completed_at``,
``window``, ``due_time``, ``icon`` and the per-child counts pass
through verbatim;
- ``present`` and ``next_present`` pass through verbatim (the API
resolved them through the same presence engine server-side);
- the instance list is rebuilt IN ORDER, so the panel payload the
sensors emit matches the route's order byte for byte.
Because the route omits missed instances (D-009), a snapshot built
here cannot contain any ``missed`` view — see the module docstring.
"""
children: list[ChildDaySnapshot] = []
for child in payload.get("children", []):
views = tuple(
QuestInstanceView(
instance_id=instance["id"],
definition_id=instance["definition_id"],
child_id=instance["child_id"],
title=instance["title"],
icon=instance.get("icon"),
window=instance["window"],
# The API payload does not carry due_date: every row
# is TODAY's instance (the route queries
# [today, today]), so the view's due_date is the
# snapshot's today_iso — the same value the local
# builder stamped on every today row.
due_date=payload["today_iso"],
due_time=instance.get("due_time"),
state=(
"done" if instance["state"] == "completed" else
instance["state"]
),
overdue=instance["overdue"],
completed_at=instance.get("completed_at"),
was_on_time=instance.get("on_time"),
)
for instance in child.get("instances", [])
)
children.append(
ChildDaySnapshot(
child_id=child["child_id"],
child_name=child["child_name"],
instances=views,
present=child["present"],
due_today=child["due_today"],
completed_today=child["completed_today"],
remaining_today=child["remaining_today"],
completion_pct=child["completion_pct"],
next_present=child.get("next_present"),
)
)
return NestQuestSnapshot(
today_iso=payload["today_iso"],
children=tuple(children),
cycle_day=payload["cycle_day"],
)
class _UnconfiguredApiClient:
"""Client-shaped stand-in used when the entry has no panel token.
Constructing a real :class:`~.api_client.NestQuestApiClient` with an
empty token raises ValueError by design (fail fast on a client that
could only ever 401). An entry that simply has not been configured
yet must not crash integration setup, so the coordinator swaps in
this stub: every refresh raises the SAME typed
:class:`~.api_client.NestQuestApiError` a transport failure would,
carrying a clear "API not configured" message;
:meth:`NestQuestCoordinator._async_update_data` converts it to
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, so
the coordinator marks the entities unavailable and the HA standard
retry cycle applies. The error is logged, never silent.
"""
__slots__ = ()
async def get_snapshot(self) -> dict[str, Any]:
"""Always raise: there is no configured API to fetch from."""
raise NestQuestApiError(
"NestQuest API not configured: set the panel token in the "
"integration options"
)
def _monotonic() -> float:
"""The monotonic clock the staleness math reads.
A one-line indirection over :func:`time.monotonic` so the staleness
tests can drive the clock deterministically (patch THIS function,
never the stdlib clock). Monotonic is deliberate: wall-clock jumps
(NTP corrections, suspends) must not shorten or extend the window.
"""
return time.monotonic()
class NestQuestCoordinator(DataUpdateCoordinator):
"""Shared refresh cycle for every NestQuest entity.
One refresh pass = one ``GET /api/v1/panel/snapshot`` through the
API client, reconstructed into the core snapshot dataclasses every
entity reads. A failed pass (unconfigured API, transport failure,
non-2xx response) raises the typed error out of the client;
``_async_update_data`` converts it to
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, the
exception HA's coordinator machinery records as a failed pass: the
coordinator's ``data`` keeps its last good value while
``last_update_success`` goes False — HA marks the entities
unavailable until a later pass succeeds. An unexpected
non-typed error still propagates out of ``_async_update_data``
and is logged (with its traceback) by HA's machinery, the same way.
Forced refreshes (the service paths pushing an immediate update
after a completion) are SERIALIZED per entry: overlapping
complete/uncomplete calls must not interleave their snapshot
@@ -224,15 +75,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
newer one and leave entities wrong until the next poll. Real
HA's coordinator already serializes internally; the lock keeps
the same guarantee on the stand-in and costs nothing upstream.
Last-good cache: a successful pass stamps the rebuilt snapshot
with the :func:`~.coordinator._monotonic` instant of the success;
a typed failure within the configured staleness threshold
(``staleness_seconds``, from ``CONF_SNAPSHOT_STALENESS``) serves
that cached snapshot instead of failing the pass, so a short API
outage keeps the board up. Past the threshold — or before the
first success — the pass fails as above, and a later successful
pass refreshes the cache and recovers the entities on its own.
"""
def __init__(
@@ -240,9 +82,9 @@ class NestQuestCoordinator(DataUpdateCoordinator):
hass: HomeAssistant,
*,
entry_id: str,
api_client: NestQuestApiClient,
database: NestQuestDatabase,
settings: NestQuestSettings,
update_interval_seconds: int | None = None,
staleness_seconds: int | None = None,
) -> None:
interval = update_interval_seconds
if (
@@ -251,19 +93,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
or interval < MIN_UPDATE_INTERVAL
):
interval = DEFAULT_UPDATE_INTERVAL
staleness = staleness_seconds
if (
not isinstance(staleness, int)
or isinstance(staleness, bool)
or staleness < interval
):
# A missing or nonsensical threshold falls back to the
# shipped default; a configured-but-too-short one (below
# even one poll interval) falls back to whichever of
# default/interval spans at least one failed poll, so the
# cache is never dead on arrival. (The options flow
# validates the same rule at the form.)
staleness = max(DEFAULT_SNAPSHOT_STALENESS, interval)
super().__init__(
hass,
LOGGER,
@@ -271,16 +100,9 @@ class NestQuestCoordinator(DataUpdateCoordinator):
update_interval=datetime.timedelta(seconds=interval),
)
self.entry_id = entry_id
self.api_client = api_client
self.database = database
self.settings = settings
self._refresh_lock = asyncio.Lock()
#: The configured last-good window, in SECONDS (const.py:
#: CONF_SNAPSHOT_STALENESS / DEFAULT_SNAPSHOT_STALENESS).
self._staleness_seconds = staleness
#: The last-good cache: the snapshot from the most recent
#: SUCCESSFUL fetch and the :func:`time.monotonic` instant it
#: landed. Both stay None until the first success.
self._last_good_snapshot: NestQuestSnapshot | None = None
self._last_good_monotonic: float | None = None
async def async_refresh(self) -> None:
"""One refresh at a time; a queued refresh runs AFTER the
@@ -289,98 +111,12 @@ class NestQuestCoordinator(DataUpdateCoordinator):
async with self._refresh_lock:
await super().async_refresh()
def _local_now(self) -> datetime.datetime:
time_zone = ZoneInfo(self.hass.config.time_zone)
return datetime.datetime.now(time_zone)
async def _async_update_data(self) -> NestQuestSnapshot:
"""One refresh pass: fetch the panel snapshot and rebuild it.
A successful pass rebuilds the snapshot and refreshes the
last-good cache (the snapshot plus the
:func:`time.monotonic` instant of the success).
A typed API failure (unconfigured token stub, transport
failure, non-2xx response) is first checked against that
stamp: WITHIN the configured staleness threshold (elapsed time
since the last successful fetch, inclusive of the threshold
itself) the pass SUCCEEDS with the cached snapshot — the panel
keeps rendering last-good data, the entities stay available —
and the outage is logged as a clear warning (never silent).
With no cache yet, or past the threshold, the typed error is
converted to
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`
— the exception HA's coordinator machinery records as a failed
pass (``last_update_success = False``, ``data`` unchanged) so
entities go unavailable and the setup-time first refresh
arrives as ``ConfigEntryNotReady`` — with the typed error
logged and chained. Anything else still propagates.
"""
try:
payload = await self.api_client.get_snapshot()
except NestQuestApiError as err:
now = _monotonic()
cached = self._last_good_snapshot
fetched_at = self._last_good_monotonic
if (
cached is not None
and fetched_at is not None
and now - fetched_at <= self._staleness_seconds
):
LOGGER.warning(
"NestQuest API refresh failed (%s); serving the "
"last-good snapshot fetched %.0f s ago, within the "
"%d s staleness threshold",
err,
now - fetched_at,
self._staleness_seconds,
)
return cached
LOGGER.error(
"NestQuest coordinator refresh failed: %s", err
)
raise UpdateFailed(str(err)) from err
snapshot = snapshot_from_api_payload(payload)
self._last_good_snapshot = snapshot
self._last_good_monotonic = _monotonic()
return snapshot
def coordinator_client_from_entry(
hass: HomeAssistant, entry: Any, *, session_factory: Any = None
) -> Any:
"""Build the refresh client for a config entry, tolerating no token.
Reads ``(base_url, panel_token)`` off the entry and constructs the
real client. An entry with no panel token configured (the shipped
default is the empty string) makes the constructor raise ValueError
by design; integration setup must not crash on an unconfigured
entry, so this factory swaps in the typed-error stub instead and
logs ONE clear message. The ValueError itself is never re-raised
past this point — it is converted, with the reason logged.
``session_factory`` overrides where the HTTP session comes from
(production: HA's shared aiohttp session via
:func:`~.api_client.client_from_entry`); it exists so tests can
inject a stub transport without importing aiohttp — the mock-only
test harness has no ``homeassistant.helpers.aiohttp_client``.
"""
base_url, panel_token = resolve_api_config(entry)
if not isinstance(panel_token, str) or not panel_token.strip():
LOGGER.warning(
"NestQuest API panel token is not configured for entry %s: "
"entities will stay unavailable until a token is set in the "
"integration options",
entry.entry_id,
now = self._local_now()
return await build_snapshot(
self.database, self.settings, now
)
return _UnconfiguredApiClient()
if session_factory is not None:
return NestQuestApiClient(
base_url, panel_token, session_factory(hass)
)
from custom_components.nestquest.api_client import client_from_entry
return client_from_entry(hass, entry)
#: Test seam: entry_id → client, consulted by the conftest's client
#: factory BEFORE the production path. Always empty in production
#: (nothing in the integration writes to it); tests register scripted
#: clients through it and clear it between tests.
_COORDINATOR_CLIENT_OVERRIDES: dict[str, Any] = {}
+25 -63
View File
@@ -20,41 +20,44 @@ TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
# panel and admin cards listen for these; since Feature 18 the
# completed/uncompleted/day-complete transitions are published by the
# API service on its SSE stream and re-fired on the bus by the
# integration's subscription, and Feature 11's nightly sweep fires
# quest_missed.
# panel and admin cards listen for these; Feature 10 fires completed,
# uncompleted, and child_day_complete from the service paths, and
# Feature 11's nightly sweep fires quest_missed.
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
EVENT_QUEST_MISSED = "nestquest_quest_missed"
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
#: Canonical domain-global service name (design/ENTITIES-AND-SERVICES.md §2).
#: Since Feature 18 only the panel completion service remains: the admin
#: surface is the PWA and the API service's admin routes, so the former
#: HA admin services (uncomplete, quest-definition and presence
#: management, CSV export, child management, regenerate) are gone.
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
#: plus the existing Feature 07 regenerate service).
SERVICE_COMPLETE_QUEST = "complete_quest"
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
SERVICE_MANAGE_CHILD = "manage_child"
SERVICE_REGENERATE = "regenerate"
DOMAIN_SERVICES: tuple[str, ...] = (
SERVICE_COMPLETE_QUEST,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_UPDATE_QUEST_DEFINITION,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
)
SQLITE_DB_FILENAME = "nestquest.db"
# Zero-config panel dashboard (Feature 20): ONE storage-mode Lovelace
# dashboard registered on setup whose config carries the bundle's
# ``custom:nestquest-party`` strategy (frontend/src/strategy.ts) — the
# views are generated at render time from the household roster. The
# url_path IS the domain: the stable key idempotency is keyed on (a
# reload or a second entry never duplicates it, and a dashboard already
# registered under it — ours or the user's — is left untouched).
DASHBOARD_URL_PATH = DOMAIN
DASHBOARD_TITLE = "NestQuest"
DASHBOARD_ICON = "mdi:party-popper"
DASHBOARD_STRATEGY_TYPE = "custom:nestquest-party"
# Configuration keys and default values
CONF_HORIZON_DAYS = "horizon_days"
DEFAULT_HORIZON_DAYS = 14
@@ -78,21 +81,6 @@ CONF_UPDATE_INTERVAL = "update_interval"
DEFAULT_UPDATE_INTERVAL = 300
MIN_UPDATE_INTERVAL = 30
# Last-good snapshot staleness (Feature 18), in SECONDS: how long the
# coordinator keeps SERVING the last good snapshot after the API stops
# answering. A failed poll whose elapsed time since the last successful
# fetch is within the threshold returns the cached snapshot (the panel
# keeps rendering last-good data, entities stay available); past it the
# poll fails the HA way and the entities go unavailable. The default is
# 3x the default update interval (900 s = 15 minutes): three consecutive
# failed five-minute polls before the board is declared stale — enough
# to ride out an API service restart, short enough not to render a
# days-old board all day. Validation floor: the threshold must be at
# least the configured update interval, or the cache could never span
# even one failed poll.
CONF_SNAPSHOT_STALENESS = "snapshot_staleness"
DEFAULT_SNAPSHOT_STALENESS = 3 * DEFAULT_UPDATE_INTERVAL
# Notification automations (Feature 11). The notify target is a free-text
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
# household fills in — never a hard-coded personal target; the times are
@@ -113,32 +101,6 @@ CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True
# Panel-plane API client (Feature 18): the integration reaches the
# NestQuest API service (Feature 16) over HTTP instead of the database.
# The base URL points at the API service host serving ``/api/v1/panel/*``
# (uvicorn's default host/port is the sensible starting point); the panel
# service token is the static Bearer credential the panel plane requires
# (``NESTQUEST_PANEL_TOKEN`` on the API side). Both are stored on the
# entry like every other setting — options first, then the entry-data
# copy, then defaults. The TOKEN IS A SECRET: it is never logged, never
# echoed into error messages, and never committed. The shipped default
# token IS the empty string (DEFAULT_PANEL_TOKEN) — an entry with no
# configured token is therefore NOT a working client: the options flow
# accepts an empty token as "not configured yet" and only rejects
# malformed (padded/whitespace-only/non-string) values — the actual
# non-emptiness guard is enforced at client construction, where
# NestQuestApiClient fails fast on an empty/whitespace token
# (api_client.py). So a caller building the client from a bare
# entry must either ensure a token is configured first or handle the
# construction ValueError and surface an "API not configured" state —
# an unconfigured entry never yields a client that can only 401.
# This mirrors the API service's fail-closed stance (api/config.py: no
# default token exists).
CONF_API_BASE_URL = "api_base_url"
DEFAULT_API_BASE_URL = "http://127.0.0.1:8000"
CONF_PANEL_TOKEN = "panel_token"
DEFAULT_PANEL_TOKEN = ""
# Quest windows (D-008): a definition may declare several day windows;
# each produces its own instance per day and groups the panel's Quest
# Log into three columns. The clock ranges are stored here so they stay
+1 -35
View File
@@ -41,38 +41,4 @@ 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
)
-405
View File
@@ -1,405 +0,0 @@
"""Read-only history layer for the admin plane (Admin spec §5, task 2d2dda53).
Assembles the History screen's event rows over the append-only
completion log: a query over a CLOSED date range ``[start_date,
end_date]`` for one of three filters —
- ``all`` — every completion event (completed AND uncompleted) whose
instance is due in the range;
- ``reversals`` — the uncompleted events only;
- ``missed`` — the DERIVED missed instances in the range.
Range semantics (Feature 13's rule, kept): the range scopes by the
INSTANCE's ``due_date`` — the day the task was owed — not the event's
wall-clock ``occurred_at``, because history reporting asks "what
happened to the tasks due that day". ``missed`` is derived, never
stored: rows come from :func:`~.completion.list_missed_for_child`
(past-due open instances, the same ``_derived_state`` rule the
coordinator uses), and nothing is ever appended for them.
Row shape: the Admin spec §5 event-row fields, fixed and documented in
:data:`CSV_COLUMNS` — ``occurred_at``, ``event_type``,
``child_name``, ``child_id``, ``quest_title``, ``instance_id``,
``window``, ``due_date``, ``due_time``, ``actor``, ``was_on_time``.
Derived missed rows carry ``event_type`` ``'missed'`` (never a stored
event_type), no ``occurred_at`` (there is no event), ``was_on_time``
``None`` and the spec's ``nightly sweep`` actor. A stored event's
``actor`` follows the spec's meta shape: the ``actor_user_id`` string
for a user event (``joshua``), ``panel (Child)`` naming the tapped
profile for a panel event.
Validation policy (module-level, shared by both the JSON query and the
CSV export): the filter must be one of :data:`FILTERS` and the range
must be two strict ``YYYY-MM-DD`` dates with ``end >= start``;
violations raise ``ValueError`` naming the offending field
(``filter`` / ``start`` / ``end``) BEFORE any read. The whole
household read runs under the task-reentrant connection lock so every
row of one answer is one snapshot — the same discipline
:mod:`.completion` gives its read-decide-write paths.
:func:`rows_to_csv` serializes rows with the :mod:`csv` module (proper
quoting, one stable header row) and :func:`export_history_csv` runs
query + serialization as ONE call, so the CSV export can never drift
from the JSON query's filtered range. No permission check lives here
(that is the admin router's single ``require_admin`` dependency).
"""
from __future__ import annotations
import csv
import datetime
import io
from dataclasses import dataclass
from .completion import _reentrant_connection_lock, list_missed_for_child
from .dao_children import ChildRecord, ChildrenDao
from .dao_instances import (
EVENT_UNCOMPLETED,
CompletionEventRecord,
CompletionEventsDao,
QuestInstanceRecord,
QuestInstancesDao,
)
from .dao_rules import QuestDefinitionsDao, _validate_date
from .db import NestQuestDatabase
#: The ``all`` filter: every completion event in the range.
FILTER_ALL = "all"
#: The ``reversals`` filter: uncompleted events only.
FILTER_REVERSALS = "reversals"
#: The ``missed`` filter: derived missed instances in the range.
FILTER_MISSED = "missed"
#: The filters the query accepts, in the Admin spec's chip order.
FILTERS = (FILTER_ALL, FILTER_REVERSALS, FILTER_MISSED)
#: The ``event_type`` a derived missed row reports. ``missed`` is a
#: DERIVED state, never a stored event_type (Feature 08).
EVENT_MISSED = "missed"
#: The actor a derived missed row reports (Admin spec §5 meta:
#: ``missed · nightly sweep``).
ACTOR_NIGHTLY_SWEEP = "nightly sweep"
#: The CSV column set, in header order — the Admin spec §5 event-row
#: fields. The header row is exactly this tuple, always, so a consumer
#: can key on column names.
CSV_COLUMNS = (
"occurred_at",
"event_type",
"child_name",
"child_id",
"quest_title",
"instance_id",
"window",
"due_date",
"due_time",
"actor",
"was_on_time",
)
@dataclass(frozen=True)
class HistoryRow:
"""One history row: a stored event or a derived missed instance.
The fields are :data:`CSV_COLUMNS` in order. For a derived missed
row ``occurred_at`` is ``None`` (no event exists) and ``was_on_time``
is ``None``; for a stored event ``event_type`` is
``'completed'``/``'uncompleted'`` and the fields come from the event
joined with its instance, child and definition.
"""
occurred_at: str | None
event_type: str
child_name: str
child_id: int
quest_title: str
instance_id: int
window: str
due_date: str
due_time: str | None
actor: str
was_on_time: bool | None
def _validate_filter(filter_name: str) -> str:
"""Return the filter, or raise ValueError naming the field."""
if filter_name not in FILTERS:
raise ValueError(
f"filter must be one of {', '.join(repr(f) for f in FILTERS)}, "
f"got {filter_name!r}"
)
return filter_name
def _validate_range(start_date: str, end_date: str) -> tuple[str, str]:
"""Validate the closed range and return it, else raise naming the field.
Both bounds must be strict ``YYYY-MM-DD`` dates (the shared
:func:`~.dao_rules._validate_date`, so non-padded or re-ordered
shapes are rejected and a non-string raises naming the field) and
``end`` must be on or after ``start``.
"""
_validate_date(start_date, "start")
_validate_date(end_date, "end")
if end_date < start_date:
raise ValueError(
f"end must be on or after start, got {end_date!r} < "
f"{start_date!r}"
)
return start_date, end_date
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the threaded HA-local date, or the host calendar date."""
if today is None:
return datetime.date.today()
return today
def _event_actor(
event: CompletionEventRecord, actor_child: ChildRecord | None
) -> str:
"""Return the spec-meta actor string for one stored event.
A user event reports the ``actor_user_id`` string (the spec's
``uncompleted · joshua (admin)`` shape); a panel event reports
``panel (Child)`` naming the tapped profile. The tapped child is
never hard-deleted, so the lookup below is a belt-and-braces
fallback to the stored id, never a silent rewrite of policy.
"""
if event.actor_source == "user":
return event.actor_user_id or ""
if actor_child is not None:
return f"panel ({actor_child.display_name})"
return f"panel ({event.actor_child_id})"
def _event_row(
event: CompletionEventRecord,
instance: QuestInstanceRecord,
child: ChildRecord,
quest_title: str,
actor_child: ChildRecord | None,
) -> HistoryRow:
"""Join one stored event with its instance, child and definition."""
return HistoryRow(
occurred_at=event.occurred_at,
event_type=event.event_type,
child_name=child.display_name,
child_id=event.child_id,
quest_title=quest_title,
instance_id=event.instance_id,
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
actor=_event_actor(event, actor_child),
was_on_time=event.was_on_time,
)
def _missed_row(
instance: QuestInstanceRecord,
child: ChildRecord,
quest_title: str,
) -> HistoryRow:
"""Build the derived row for one past-due open instance."""
return HistoryRow(
occurred_at=None,
event_type=EVENT_MISSED,
child_name=child.display_name,
child_id=instance.child_id,
quest_title=quest_title,
instance_id=instance.id,
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
actor=ACTOR_NIGHTLY_SWEEP,
was_on_time=None,
)
def _csv_values(row: HistoryRow) -> list[str]:
"""Return one CSV row's cell values in :data:`CSV_COLUMNS` order.
``None`` cells serialize as empty (a missed row has no
``occurred_at``); ``was_on_time`` serializes as ``true``/``false``
when the event carries the flag and empty when it does not.
"""
return [
row.occurred_at or "",
row.event_type,
row.child_name,
str(row.child_id),
row.quest_title,
str(row.instance_id),
row.window,
row.due_date,
row.due_time or "",
row.actor,
"" if row.was_on_time is None else str(row.was_on_time).lower(),
]
def rows_to_csv(rows: list[HistoryRow]) -> str:
"""Serialize history rows to CSV text with the documented header.
The FIRST row is always :data:`CSV_COLUMNS`, so the header is
stable and deterministic; the :mod:`csv` module handles quoting
(titles or names containing commas or quotes are quoted per RFC
4180) and CRLF line endings.
"""
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(CSV_COLUMNS)
for row in rows:
writer.writerow(_csv_values(row))
return buffer.getvalue()
async def query_history(
database: NestQuestDatabase,
filter_name: str,
start_date: str,
end_date: str,
*,
today: datetime.date | None = None,
) -> list[HistoryRow]:
"""Return the history rows for ``filter_name`` over the closed range.
Validates the filter and the strict ``YYYY-MM-DD`` range (raising
``ValueError`` naming ``filter`` / ``start`` / ``end``) BEFORE any
read, then builds the rows:
- ``all`` / ``reversals``: every child's events whose instances are
due in the range (:meth:`~.dao_instances.CompletionEventsDao.list_by_child_and_date_range`,
the Feature 13 due-date scope), ``reversals`` keeping only the
``uncompleted`` rows.
- ``missed``: :func:`~.completion.list_missed_for_child` per child —
the SAME derived-state rule the coordinator uses, never a new one,
and never a stored event.
Rows are ordered deterministically by ``due_date``, then instance,
then event id (the per-instance append order, so a completion and
its reversal keep their order). ``today`` pins the HA-local date
the missed derivation compares due dates against (the host calendar
date when omitted). The whole read runs under the task-reentrant
connection lock — :func:`list_missed_for_child` re-enters it — so
one answer is one snapshot.
"""
filter_name = _validate_filter(filter_name)
start_date, end_date = _validate_range(start_date, end_date)
today_date = _resolve_today(today)
keyed: list[tuple[tuple[str, int, int], HistoryRow]] = []
async with _reentrant_connection_lock(database):
children = await ChildrenDao(database).list_all()
children_by_id = {child.id: child for child in children}
instances = QuestInstancesDao(database)
definitions = QuestDefinitionsDao(database)
events = CompletionEventsDao(database)
titles: dict[int, str] = {}
if filter_name == FILTER_MISSED:
for child in children:
for instance in await list_missed_for_child(
database, child.id, start_date, end_date, today=today_date
):
if instance.definition_id not in titles:
definition = await definitions.get(
instance.definition_id
)
assert definition is not None, (
"a materialized instance's definition exists"
)
titles[instance.definition_id] = definition.title
keyed.append(
(
(instance.due_date, instance.id, 0),
_missed_row(
instance,
child,
titles[instance.definition_id],
),
)
)
keyed.sort(key=lambda pair: pair[0])
return [row for _, row in keyed]
for child in children:
for event in await events.list_by_child_and_date_range(
child.id, start_date, end_date
):
if filter_name == FILTER_REVERSALS and (
event.event_type != EVENT_UNCOMPLETED
):
continue
instance = await instances.get_by_id(event.instance_id)
assert instance is not None, (
"an event's instance is never deleted (append-only "
"history keeps its references)"
)
actor_child = (
children_by_id.get(event.actor_child_id)
if event.actor_child_id is not None
else None
)
if instance.definition_id not in titles:
definition = await definitions.get(instance.definition_id)
assert definition is not None, (
"a materialized instance's definition exists"
)
titles[instance.definition_id] = definition.title
keyed.append(
(
(instance.due_date, instance.id, event.id),
_event_row(
event,
instance,
child,
titles[instance.definition_id],
actor_child,
),
)
)
keyed.sort(key=lambda pair: pair[0])
return [row for _, row in keyed]
async def export_history_csv(
database: NestQuestDatabase,
filter_name: str,
start_date: str,
end_date: str,
*,
today: datetime.date | None = None,
) -> str:
"""Return the filtered history as CSV text, header row included.
ONE query + serialization call so the CSV export carries exactly
the JSON query's filtered range and order — the same
:func:`query_history` validation, rows and :func:`rows_to_csv`
serializer, never a second implementation.
"""
rows = await query_history(
database, filter_name, start_date, end_date, today=today
)
return rows_to_csv(rows)
__all__ = [
"ACTOR_NIGHTLY_SWEEP",
"CSV_COLUMNS",
"EVENT_MISSED",
"EVENT_UNCOMPLETED",
"FILTERS",
"FILTER_ALL",
"FILTER_MISSED",
"FILTER_REVERSALS",
"HistoryRow",
"export_history_csv",
"query_history",
"rows_to_csv",
]
@@ -0,0 +1,46 @@
"""Explicit admin-only versus open service registry (Feature 09).
One mapping, one file: every registered ``nestquest`` service name is
either ``open`` or ``admin_only``. The permission decorator (later
Feature 09 tasks) reads this table; it does not take per-handler
policy arguments.
"""
from __future__ import annotations
from .const import (
DOMAIN_SERVICES,
SERVICE_COMPLETE_QUEST,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_UPDATE_QUEST_DEFINITION,
)
OPEN = "open"
ADMIN_ONLY = "admin_only"
SERVICE_POLICY: dict[str, str] = {
SERVICE_COMPLETE_QUEST: OPEN,
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
SERVICE_REGENERATE: ADMIN_ONLY,
}
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
raise RuntimeError(
"SERVICE_POLICY must name exactly the registered nestquest services, "
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
)
@@ -1,210 +0,0 @@
"""Durable settings store for the NestQuest API service.
The API service has NO Home Assistant config entry, so it cannot build
:class:`~.settings.NestQuestSettings` from ``entry.options`` the way the
integration does. This module gives the API its own durable settings
source: a JSON document kept in the EXISTING ``nestquest_meta_state``
key/value table (:class:`~.dao_meta.MetaStateDao`) under the well-known
key :data:`SETTINGS_META_KEY` — no schema change, no new table —
validated through the SAME field table the settings object is built
from (:data:`~.settings._FIELDS`, the table driving
``from_options``/``from_options_resilient``). No validation rule is
re-implemented here.
OWNERSHIP: the API now owns settings in the database. The
integration's Home-Assistant config-entry options remain a temporary,
SEPARATE source until Feature 18 converts the integration into a thin
API client; the two are deliberately NOT unified here, and neither
side reads the other's values.
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
object holding exactly the TEN settings fields, keyed by field name
(which equals the ``CONF_*`` option key for every field), complete
after the first update:
- ``horizon_days`` — int >= 1,
- ``day_rollover_time``, ``morning_summary_time``,
``afternoon_reminder_time``, ``end_of_day_report_time`` — strict
24-hour ``HH:MM`` strings,
- ``notify_target`` — a string (a padded value is stripped by the
core's normalizer),
- ``morning_summary_enabled``, ``afternoon_reminder_enabled``,
``end_of_day_report_enabled``, ``celebration_enabled`` — real
booleans.
Only values whose core validators ACCEPTED are ever written, so a
stored document always round-trips through the core's option builders.
An absent key (a fresh database) resolves to the all-defaults
settings, and a corrupt document degrades to defaults or per-field
defaults rather than crashing the service (see :func:`load_settings`).
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import Mapping
from typing import Any
from .const import LOGGER
from .dao_meta import MetaStateDao
from .db import NestQuestDatabase
from .settings import NestQuestSettings, _FIELDS
#: The well-known ``nestquest_meta_state`` key holding the settings
#: document. One key, one JSON object — the same table the missed
#: sweep's watermark already uses, so no migration is needed.
SETTINGS_META_KEY = "settings"
#: The per-field resolution table keyed by field name: each row is the
#: ``(default, validator)`` pair the settings module's ``_FIELDS`` table
#: carries for that field. ``update_settings`` resolves each PROVIDED
#: field through its row's validator — the SAME core validators
#: ``from_options`` applies — and never re-implements one here.
#: (Field names equal the ``CONF_*`` option keys, so one dict serves
#: both the API's field names and the stored document's keys.)
_RESOLUTIONS: dict[str, tuple[Any, Any]] = {
key: (default, validator) for _field, key, default, validator in _FIELDS
}
#: The known field names, in the settings module's field order — the
#: allowed keys of an update body and the stored document's key set.
_FIELD_NAMES: tuple[str, ...] = tuple(
key for _field, key, _default, _validator in _FIELDS
)
#: One asyncio.Lock per (connection wrapper, running loop), the missed
#: sweep's pattern: the whole stored-document read → merge → write span
#: is one critical section, so two concurrent updates queue instead of
#: one losing the other's change — the loser re-reads the document
#: INSIDE the lock and merges onto the winner's settings.
_SETTINGS_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
def _settings_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the settings lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _SETTINGS_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_SETTINGS_LOCKS[key] = lock
return lock
async def _load_document(database: NestQuestDatabase) -> dict[str, Any]:
"""Read and decode the stored settings document (empty when unusable).
An absent key — a fresh database that never updated settings — and
an unparseable value (invalid JSON, or JSON that is not an object)
return an empty mapping, which the option builders resolve to the
all-defaults settings; the corrupt cases log a warning first. A
parseable object is returned as stored (its per-field validity is
the builder's concern, not this reader's).
"""
raw = await MetaStateDao(database).get(SETTINGS_META_KEY)
if raw is None:
return {}
try:
document = json.loads(raw)
except ValueError:
LOGGER.warning(
"NestQuest stored settings (meta_state key %r) are not valid "
"JSON; falling back to the default settings",
SETTINGS_META_KEY,
)
return {}
if not isinstance(document, dict):
LOGGER.warning(
"NestQuest stored settings (meta_state key %r) are not a JSON "
"object; falling back to the default settings",
SETTINGS_META_KEY,
)
return {}
return document
def _named(field: str, error: ValueError) -> str:
"""Return a field-named message for a rejected validator value.
The horizon, time and notify-target validators already name their
field in the message; the boolean validator's message is generic
(``expected a boolean, got ...``), so it gets the field prefixed.
Every rejected update's error therefore names its field either way.
"""
message = str(error)
if message.startswith(f"{field} "):
return message
return f"{field}: {message}"
async def load_settings(database: NestQuestDatabase) -> NestQuestSettings:
"""Return the current effective settings stored for ``database``.
Reads :data:`SETTINGS_META_KEY` and resolves the stored document
through the core settings validators, with each absent field
falling back to its default. An absent key and an unparseable
document fall back to the all-defaults settings (via
:func:`_load_document`), and a parseable document with a single
invalid FIELD falls back PER FIELD — through
:meth:`NestQuestSettings.from_options_resilient`, the core's own
resilient builder — so a corrupt sibling never resets the stored
values around it. This never raises: the service must keep
serving reads even over a corrupt store.
"""
document = await _load_document(database)
return NestQuestSettings.from_options_resilient(document)
async def update_settings(
database: NestQuestDatabase, changes: Mapping[str, Any]
) -> NestQuestSettings:
"""Validate ``changes`` and persist them as the effective settings.
``changes`` maps settings field names to new values. EVERY name is
checked against the known field table BEFORE anything is validated
or written: an unknown name raises a ``ValueError`` naming it (and
the known names), leaving the stored settings untouched. Each
PROVIDED field is then resolved from_options-style — its
:data:`~.settings._FIELDS` validator applied, with a JSON ``null``
mapping to the field's default (the settings module's uniform
None-as-absent rule) — and an invalid value raises a ``ValueError``
naming the field, also BEFORE anything is written, so a rejected
update can never leave a half-applied document behind.
The validated changes are merged onto the current effective
settings and the merged (complete, ten-field) document is persisted
in one atomic upsert; the updated settings are returned. The whole
read → merge → write span runs inside the per-database settings
lock, so concurrent updates queue instead of interleaving.
"""
if not isinstance(changes, Mapping):
raise ValueError(
"settings changes must be a JSON object mapping field names "
"to values"
)
unknown = sorted(
str(name) for name in changes if name not in _RESOLUTIONS
)
if unknown:
listed = ", ".join(repr(name) for name in unknown)
known = ", ".join(_FIELD_NAMES)
raise ValueError(
f"unknown settings field(s): {listed}; known fields: {known}"
)
async with _settings_lock(database):
current = await load_settings(database)
document: dict[str, Any] = {
field: getattr(current, field) for field in _FIELD_NAMES
}
for name, raw_value in changes.items():
default, validator = _RESOLUTIONS[name]
try:
document[name] = validator(
default if raw_value is None else raw_value
)
except ValueError as error:
raise ValueError(_named(name, error)) from error
await MetaStateDao(database).set(
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
)
return NestQuestSettings(**document)
-170
View File
@@ -1,170 +0,0 @@
"""The nightly missed-quest sweep, Home-Assistant-free (Feature 11).
This is the HA-free heart of the missed sweep, shared by the two
planes: the Home Assistant integration's :mod:`~..sweep` shim (which
fires the returned events on ``hass.bus``) and the API service (which
publishes them on the SSE stream and can also trigger the run on
demand). It owns the ONE sweep policy:
At the configured day-rollover time (or on an explicit trigger) this
job finds every instance whose ``due_date`` is before the
household-local date, that has NO completion event, and that falls
after the persisted sweep watermark — and RETURNS one
``nestquest_quest_missed`` event per instance with the documented
payload. Like every core module, it never touches the Home Assistant
event bus and never mutates an instance: ``missed`` is derived state,
never an event_type (Feature 08's contract), so the sweep is read-only
over the domain tables.
Idempotency is the watermark: after a run, ``nestquest_meta_state``
records the household-local date the sweep ran, and the next run only
sweeps instances due in ``[watermark, today)``. A second run the same
night therefore fires nothing new, and a run after days of downtime
sweeps exactly the accumulated window once — no per-instance journal,
no re-announcing old misses. An instance swept as missed that is
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 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 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
the integration or from the API host's local clock by the API. This
module never reads a timezone database or config of its own.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
from .const import EVENT_QUEST_MISSED
from .dao_meta import MetaStateDao
from .dao_instances import QuestInstancesDao
from .db import NestQuestDatabase
# Intentional intra-package reuse of the shared payload builder: the
# missed event's payload is the documented §3 instance payload, and
# building it through the ONE builder keeps this sweep's payloads from
# drifting from the completion/uncompleted builders' shape.
from .events import _instance_payload
LOGGER = logging.getLogger(__name__)
#: meta_state key holding the household-local date the sweep last ran.
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
#: 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] = {}
def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the sweep lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _SWEEP_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_SWEEP_LOCKS[key] = lock
return lock
#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape,
#: explicit offset, second precision.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
async def run_missed_sweep(
database: NestQuestDatabase,
*,
today: datetime.date,
) -> list[tuple[str, dict]]:
"""Build one missed event per unswept past-due open instance.
``today`` is the household-local calendar date the sweep runs for
(the caller owns the clock: the integration reads it from
``hass.config.time_zone``, the API from its host's local time).
Returns the ``(event_type, payload)`` tuples to announce, EMPTY
when the watermark already covers ``today`` (a same-night rerun) —
the caller decides how to publish them and announces nothing on
its own.
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)
async with _sweep_lock(database):
watermark = await dao.get(SWEEP_WATERMARK_KEY)
if watermark == today_iso:
LOGGER.debug(
"NestQuest missed sweep already ran for %s", today_iso
)
return []
instances_dao = QuestInstancesDao(database)
rows = await instances_dao.list_missed_unswept(today_iso, watermark)
now_stamp = datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
events: list[tuple[str, dict]] = []
for instance in rows:
events.append(
(
EVENT_QUEST_MISSED,
await _instance_payload(
database, instance.id, now_stamp
),
)
)
# 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
@@ -0,0 +1,12 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_children` for the integration package.
The children/admin DAO lives in
:mod:`custom_components.nestquest.core.dao_children` (extracted in
task d0b691d8); this module re-exports it so existing
``from .dao_children import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_children import * # noqa: F401,F403
from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401
@@ -0,0 +1,12 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_instances` for the integration package.
The instances/completion-events DAO lives in
:mod:`custom_components.nestquest.core.dao_instances`
(extracted in task d0b691d8); this module re-exports it so existing
``from .dao_instances import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_instances import * # noqa: F401,F403
from .core.dao_instances import _resolve_today # noqa: F401
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_meta` for the integration package.
The meta-state DAO lives in
:mod:`custom_components.nestquest.core.dao_meta` (extracted in task
d0b691d8); this module re-exports it so existing
``from .dao_meta import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_meta import * # noqa: F401,F403
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_presence` for the integration package.
The presence DAOs live in
:mod:`custom_components.nestquest.core.dao_presence` (extracted in
task d0b691d8); this module re-exports them so existing
``from .dao_presence import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_presence import * # noqa: F401,F403
+17
View File
@@ -0,0 +1,17 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_rules` for the integration package.
The schedule-rules/quest-definitions DAO lives in
:mod:`custom_components.nestquest.core.dao_rules` (extracted in task
d0b691d8); this module re-exports it so existing
``from .dao_rules import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_rules import * # noqa: F401,F403
from .core.dao_rules import ( # noqa: F401
_UNSET,
_validate_date,
_validate_time,
_validate_window,
)
-215
View File
@@ -1,215 +0,0 @@
"""Zero-config panel dashboard registration for NestQuest (Feature 20).
On setup the integration registers ONE storage-mode Lovelace dashboard
whose CONFIG uses the bundle's ``custom:nestquest-party`` strategy (see
``frontend/src/strategy.ts``): the views themselves are GENERATED at
render time from the household roster, so the stored dashboard config
carries nothing but the strategy reference.
Registration mirrors the REAL two-step sequence the lovelace
integration itself uses when it creates the onboarding map dashboard
(``homeassistant/components/lovelace/__init__.py``,
``_create_map_dashboard``):
1. create the dashboard METADATA through the ``DashboardsCollection``
(``hass.data["lovelace"]["dashboards_collection"]``) — url_path,
title, icon, sidebar visibility. The strategy reference is NOT part
of the create payload: the collection's create schema allows only
the metadata fields, so a ``strategy`` key there fails the whole
create (this was the silent no-op the first implementation shipped).
2. save the dashboard CONFIG — ``{"strategy": ...}`` — through the
per-url_path CONTENT manager that the collection's change listener
registers under ``hass.data["lovelace"]["dashboards"][url_path]``
(a ``LovelaceStorage``), exactly the second step HA performs.
Registration is IDEMPOTENT — a dashboard already under the stable
``url_path`` (ours or a user's) skips BOTH steps and its stored config
is never overwritten — and the Lovelace surfaces being unavailable
(YAML mode, lovelace not loaded) is a logged warning, never a setup
failure.
"""
from __future__ import annotations
import asyncio
from homeassistant.core import HomeAssistant
from .const import (
DASHBOARD_ICON,
DASHBOARD_STRATEGY_TYPE,
DASHBOARD_TITLE,
DASHBOARD_URL_PATH,
LOGGER,
)
_LOGGER = LOGGER
# Single-flight guard for the check-then-create below: HA can set up
# several config entries CONCURRENTLY, and two overlapping setups would
# both pass the exists-check and race on the url_path create. The lock
# is per hass instance (kept as an attribute on the hass object, since
# hass.data is the integrations' shared registry the unload path
# empties) rather than one module-level asyncio.Lock — a bare
# module-level lock binds to whichever event loop first awaits it and
# raises RuntimeError on any other loop, while the race it must prevent
# only exists between setups running on the SAME hass anyway.
_REGISTRATION_LOCK_ATTR = "_nestquest_dashboard_registration_lock"
def _registration_lock(hass: HomeAssistant) -> asyncio.Lock:
"""Return the per-hass registration lock, creating it on first use."""
lock = getattr(hass, _REGISTRATION_LOCK_ATTR, None)
if lock is None:
lock = asyncio.Lock()
setattr(hass, _REGISTRATION_LOCK_ATTR, lock)
return lock
def _lovelace_data(hass: HomeAssistant) -> dict | None:
"""Return ``hass.data["lovelace"]`` when it is the real dict shape.
The lovelace integration stores a plain DICT of surfaces (``mode``,
``dashboards``, ``dashboards_collection``, ``resources``,
``yaml_dashboards``) under the ``lovelace`` key; anything else
(missing key, unexpected object) means the surfaces below cannot be
reached and registration must warn and continue.
"""
lovelace_data = hass.data.get("lovelace")
if not isinstance(lovelace_data, dict):
return None
return lovelace_data
def _dashboards_collection(hass: HomeAssistant):
"""Return the Lovelace dashboards METADATA collection, or None.
The collection lives at ``hass.data["lovelace"]
["dashboards_collection"]`` — NOT under ``["dashboards"]``, which
maps url_path to the per-dashboard CONTENT managers
(``LovelaceStorage``/``LovelaceYAML``). A missing key (lovelace
not loaded) and a missing/None entry (YAML mode never sets the
collection) both mean "cannot create storage dashboards here" —
callers log and continue. The presence of the surface is verified
by capability (``async_create_item`` callable), not by type, so no
lovelace import is needed.
"""
lovelace_data = _lovelace_data(hass)
if lovelace_data is None:
return None
collection = lovelace_data.get("dashboards_collection")
if collection is None or not callable(
getattr(collection, "async_create_item", None)
):
return None
return collection
def _dashboard_store(hass: HomeAssistant, url_path: str):
"""Return a url_path's dashboard CONTENT manager, or None.
After a metadata create, the collection's change listener registers
a ``LovelaceStorage`` for the new dashboard under
``hass.data["lovelace"]["dashboards"][url_path]``; the dashboard
CONFIG (the strategy reference) is saved through THAT manager —
the second step of HA's own dashboard creation.
"""
lovelace_data = _lovelace_data(hass)
if lovelace_data is None:
return None
dashboards = lovelace_data.get("dashboards")
if not isinstance(dashboards, dict):
return None
store = dashboards.get(url_path)
if store is None or not callable(getattr(store, "async_save", None)):
return None
return store
def _item_url_path(item) -> str | None:
"""Read a dashboard item's ``url_path`` through either access shape.
The collection hands back storage items as dicts while builtin
dashboards come back as config objects; the url_path is reachable
through ``item["url_path"]`` or ``item.url_path`` respectively.
"""
url_path = getattr(item, "url_path", None)
if isinstance(url_path, str):
return url_path
get_url_path = getattr(item, "get", None)
if callable(get_url_path):
value = get_url_path("url_path")
if isinstance(value, str):
return value
return None
def _existing_dashboard(items) -> bool:
"""Whether a dashboard under the stable url_path already exists."""
return any(_item_url_path(item) == DASHBOARD_URL_PATH for item in items)
async def async_register_dashboard(hass: HomeAssistant) -> bool:
"""Register the NestQuest panel dashboard exactly once.
Returns True when a dashboard under the stable ``url_path`` exists
after the call (pre-existing or created here), False when the
Lovelace surfaces were unavailable or refused. A dashboard already
registered under the stable ``url_path`` — whether ours from a
previous setup or a user's own — skips BOTH steps and is left
COMPLETELY alone: its stored config is never overwritten, it is
never deleted, never a duplicate. The strategy config is saved
ONLY for the dashboard created in THIS call, whose fresh content
store is empty, so the save cannot clobber anything.
"""
collection = _dashboards_collection(hass)
if collection is None:
_LOGGER.warning(
"Lovelace dashboards collection unavailable; the NestQuest "
"panel dashboard was not registered. Enable the Lovelace "
"integration in storage mode or create a dashboard with "
"strategy %s manually.",
DASHBOARD_STRATEGY_TYPE,
)
return False
try:
# Single-flight: two concurrent entry setups must not both pass
# the exists-check and race on the url_path create.
async with _registration_lock(hass):
# async_items() is a SYNC @callback on the real collection
# (helpers/collection.py ObservableCollection) — not awaited.
if _existing_dashboard(collection.async_items()):
return True
await collection.async_create_item(
{
"url_path": DASHBOARD_URL_PATH,
"title": DASHBOARD_TITLE,
"icon": DASHBOARD_ICON,
"show_in_sidebar": True,
"allow_single_word": True,
}
)
# Step two (mirroring HA's _create_map_dashboard): the
# strategy CONFIG goes through the fresh dashboard's
# content manager, not through the metadata collection.
store = _dashboard_store(hass, DASHBOARD_URL_PATH)
if store is None:
_LOGGER.warning(
"The NestQuest panel dashboard was registered but its "
"Lovelace content manager never appeared; add the "
"strategy %s to the dashboard manually.",
DASHBOARD_STRATEGY_TYPE,
)
return False
await store.async_save(
{"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}
)
except Exception:
_LOGGER.warning(
"Registering the NestQuest panel dashboard in the Lovelace "
"dashboards collection failed; set it up manually with "
"strategy %s.",
DASHBOARD_STRATEGY_TYPE,
exc_info=True,
)
return False
return True
+31
View File
@@ -0,0 +1,31 @@
"""Re-export of :mod:`custom_components.nestquest.core.db` for the integration package.
The async SQLite wrapper lives in :mod:`custom_components.nestquest.core.db` (extracted in task
d0b691d8) and is Home-Assistant-free: :class:`NestQuestDatabase` takes
an executor callable rather than a ``hass`` object. This module
re-exports it and provides :func:`make_database`, a thin adapter that
constructs a wrapper backed by ``hass.async_add_executor_job``, so the
integration constructs the database the same way it always did.
"""
from __future__ import annotations
from typing import Any, TypeVar
from homeassistant.core import HomeAssistant
from .core.db import ExecutionResult, NestQuestDatabase
__all__ = ["ExecutionResult", "NestQuestDatabase", "make_database"]
T = TypeVar("T")
def make_database(hass: HomeAssistant) -> NestQuestDatabase:
"""Construct a :class:`NestQuestDatabase` wired to ``hass``'s executor.
Mirrors the pre-extraction ``NestQuestDatabase(hass)`` call: the
wrapper's executor is ``hass.async_add_executor_job``, so every
sqlite3 call runs on the HA executor thread and the event loop
never blocks.
"""
return NestQuestDatabase(hass.async_add_executor_job)
+79
View File
@@ -0,0 +1,79 @@
"""Re-export / HA wiring of :mod:`custom_components.nestquest.core.events` for the integration package.
The transition-event payload construction lives in
:mod:`custom_components.nestquest.core.events` (extracted in
task 510c1f78) and is HA-free: it builds and RETURNS the events. This
shim keeps the integration's old ``fire_quest_completed`` /
``fire_quest_uncompleted`` API (the complete/uncomplete service
handlers call them with ``hass``), reads the HA-local timezone from
``hass.config.time_zone`` to derive ``today``, asks the core builder
for the events, and fires each on ``hass.bus`` with the documented
payloads. The four blueprints keep working with identical payloads.
The completed event is fired BEFORE the day-complete rule evaluates:
``fire_quest_completed`` asks
:func:`build_quest_completed_event` for the completed event and fires
it immediately, THEN reads the HA-local ``today``, asks
:func:`build_child_day_complete_event` for the day-complete event, and
fires it (if any) afterwards. This restores the pre-extraction
ordering — a failure in the day-complete DB reads or the ZoneInfo
lookup cannot suppress the completed event.
"""
from __future__ import annotations
import datetime
from zoneinfo import ZoneInfo
from .core.db import NestQuestDatabase
from .core.events import (
build_child_day_complete_event,
build_quest_completed_event,
build_quest_uncompleted_events,
)
__all__ = ["fire_quest_completed", "fire_quest_uncompleted"]
async def fire_quest_completed(
hass,
database: NestQuestDatabase,
instance_id: int,
*,
was_on_time: bool | None,
) -> None:
"""Fire ``nestquest_quest_completed`` and, when the completion
cleared the child's whole day, ``nestquest_child_day_complete``.
Thin HA wiring over the HA-free
:func:`custom_components.nestquest.core.events.build_quest_completed_event`
and
:func:`build_child_day_complete_event`: the completed event is
built and fired FIRST (so a failure in the day-complete DB reads
or the HA-local ``today`` lookup cannot suppress it), then the
HA-local ``today`` (the day-complete rule's input) is read from
``hass.config.time_zone`` and the day-complete builder is asked
whether the child's whole day cleared. Identical payloads and
day-complete rule to the pre-extraction implementation.
"""
completed = await build_quest_completed_event(
database,
instance_id,
was_on_time=was_on_time,
)
hass.bus.async_fire(*completed)
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
day_complete = await build_child_day_complete_event(
database, completed[1], today=today
)
if day_complete is not None:
hass.bus.async_fire(*day_complete)
async def fire_quest_uncompleted(
hass, database: NestQuestDatabase, instance_id: int
) -> None:
"""Fire ``nestquest_quest_uncompleted`` for an actual reversal."""
events = await build_quest_uncompleted_events(database, instance_id)
for event_type, payload in events:
hass.bus.async_fire(event_type, payload)
+2 -3
View File
@@ -7,13 +7,12 @@
"config_flow": true,
"dependencies": [
"frontend",
"http",
"lovelace"
"http"
],
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
"integration_type": "hub",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.6.0"
"version": "0.5.0"
}
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.materialize` for the integration package.
The materialization walk lives in
:mod:`custom_components.nestquest.core.materialize` (extracted in
task 510c1f78); this module re-exports it so existing
``from .materialize import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.materialize import * # noqa: F401,F403
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.migrations` for the integration package.
The versioned migration runner lives in
:mod:`custom_components.nestquest.core.migrations`
(extracted in task d0b691d8); this module re-exports it so existing
``from .migrations import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.migrations import * # noqa: F401,F403
+284 -123
View File
@@ -1,15 +1,4 @@
"""Options flow for NestQuest post-setup settings.
Since the DB removal (Feature 18) the integration is a thin client of
the NestQuest API service: the settings that only served the local
database (the generation horizon, the day rollover, the notification
defaults, the admin allowlist) are gone — the API service owns those in
its own settings store, and the automations take their behaviour from
blueprint inputs. What remains is what the CLIENT itself consumes: the
panel-plane API connection (base URL + token), the shared coordinator's
refresh interval, the last-good staleness threshold, and the
panel-facing idle timeout.
"""
"""Options flow for NestQuest post-setup settings."""
from __future__ import annotations
from typing import Any
@@ -17,39 +6,61 @@ from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv
from .const import (
CONF_API_BASE_URL,
CONF_ADMIN_USER_IDS,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_AFTERNOON_REMINDER_TIME,
CONF_CELEBRATION_ENABLED,
CONF_DAY_ROLLOVER_TIME,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_END_OF_DAY_REPORT_TIME,
CONF_HORIZON_DAYS,
CONF_MORNING_SUMMARY_ENABLED,
CONF_MORNING_SUMMARY_TIME,
CONF_NOTIFY_TARGET,
CONF_PANEL_IDLE_TIMEOUT,
CONF_PANEL_TOKEN,
CONF_SNAPSHOT_STALENESS,
CONF_UPDATE_INTERVAL,
DEFAULT_API_BASE_URL,
DEFAULT_AFTERNOON_REMINDER_TIME,
DEFAULT_AUTOMATION_ENABLED,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_END_OF_DAY_REPORT_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_MORNING_SUMMARY_TIME,
DEFAULT_PANEL_IDLE_TIMEOUT,
DEFAULT_PANEL_TOKEN,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
MIN_UPDATE_INTERVAL,
TIME_PATTERN,
)
from .api_client import is_valid_base_url
from .admin_allowlist import list_admin_ids, set_admin_ids
MIN_HORIZON_DAYS = 1
MIN_PANEL_IDLE_TIMEOUT = 30
def _async_validate(
user_input: dict[str, Any],
current: dict[str, Any] | None = None,
) -> dict[str, str]:
"""Validate user input and return field-level errors instead of raising.
def _parse_time(value: str) -> bool:
"""Return True when value is a valid 24-hour HH:MM string."""
return TIME_PATTERN.fullmatch(value) is not None
``current`` carries the stored values (entry options → entry data →
defaults) so a raw submit that sets one field but omits a sibling it
is validated against (the staleness threshold vs. the update
interval) can still check the effective pair; raw submits omitting
BOTH keys keep their stored values untouched.
"""
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
"""Validate user input and return field-level errors instead of raising."""
errors: dict[str, str] = {}
horizon_days = user_input.get(CONF_HORIZON_DAYS)
if (
isinstance(horizon_days, bool)
or not isinstance(horizon_days, int)
or horizon_days < MIN_HORIZON_DAYS
):
errors[CONF_HORIZON_DAYS] = "invalid"
day_rollover_time = user_input.get(CONF_DAY_ROLLOVER_TIME)
if not isinstance(day_rollover_time, str) or not _parse_time(day_rollover_time):
errors[CONF_DAY_ROLLOVER_TIME] = "invalid_time"
panel_idle_timeout = user_input.get(CONF_PANEL_IDLE_TIMEOUT)
if (
isinstance(panel_idle_timeout, bool)
@@ -70,127 +81,270 @@ def _async_validate(
):
errors[CONF_UPDATE_INTERVAL] = "invalid"
# The last-good staleness threshold (Feature 18) is OPTIONAL in raw
# submits the same way: a missing key keeps the current value. It
# must be a positive int, and at least the EFFECTIVE update interval
# (the one this submit carries, else the stored or default one) — a
# threshold shorter than the poll cadence could never serve even one
# cached snapshot.
staleness = user_input.get(CONF_SNAPSHOT_STALENESS)
if staleness is not None and (
isinstance(staleness, bool)
or not isinstance(staleness, int)
or staleness < 1
# The notification section (Feature 11) is OPTIONAL in raw submits
# the same way: a missing key keeps the current value. The three
# times are strict HH:MM; the notify target is a non-empty HA
# service name; the toggles are real booleans.
for time_key in (
CONF_MORNING_SUMMARY_TIME,
CONF_AFTERNOON_REMINDER_TIME,
CONF_END_OF_DAY_REPORT_TIME,
):
errors[CONF_SNAPSHOT_STALENESS] = "invalid"
elif staleness is not None and not errors.get(CONF_UPDATE_INTERVAL):
stored_interval = (current or {}).get(CONF_UPDATE_INTERVAL)
effective_interval = update_interval
if effective_interval is None and (
isinstance(stored_interval, int)
and not isinstance(stored_interval, bool)
and stored_interval >= MIN_UPDATE_INTERVAL
time_value = user_input.get(time_key)
if time_value is not None and (
not isinstance(time_value, str) or not _parse_time(time_value)
):
effective_interval = stored_interval
if effective_interval is None:
effective_interval = DEFAULT_UPDATE_INTERVAL
if staleness < effective_interval:
errors[CONF_SNAPSHOT_STALENESS] = "invalid_staleness"
# The panel-plane API connection (Feature 18) is OPTIONAL in raw
# submits the same way: a missing key keeps the current value. The
# base URL must be a full http(s) URL with a host — the same check
# the client's constructor enforces, so a malformed URL fails at
# the form, not at the first request. The token may be EMPTY (the
# API connection is simply not configured yet) but a non-empty one
# must be pasted unpadded: a padded or whitespace-only value is a
# clipboard mistake that would fail the token check at request
# time.
api_base_url = user_input.get(CONF_API_BASE_URL)
if api_base_url is not None and not is_valid_base_url(api_base_url):
errors[CONF_API_BASE_URL] = "invalid_url"
panel_token = user_input.get(CONF_PANEL_TOKEN)
if panel_token is not None and (
not isinstance(panel_token, str)
or panel_token != panel_token.strip()
errors[time_key] = "invalid_time"
notify_target = user_input.get(CONF_NOTIFY_TARGET)
if notify_target is not None and not isinstance(notify_target, str):
errors[CONF_NOTIFY_TARGET] = "invalid"
elif isinstance(notify_target, str) and notify_target != (
notify_target.strip()
):
errors[CONF_PANEL_TOKEN] = "invalid"
# An EMPTY target is valid — it means "no notifications
# configured yet" and is the shipped default; a value that
# differs from its own trim (padded or whitespace-only) is
# malformed and rejected.
errors[CONF_NOTIFY_TARGET] = "invalid"
for toggle_key in (
CONF_MORNING_SUMMARY_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_CELEBRATION_ENABLED,
):
toggle_value = user_input.get(toggle_key)
if toggle_value is not None and not isinstance(toggle_value, bool):
errors[toggle_key] = "invalid"
return errors
def _build_schema(current: dict[str, Any]) -> vol.Schema:
def _strict_bool(value: object) -> bool:
"""Require a real bool; voluptuous ``bool`` would coerce strings."""
if not isinstance(value, bool):
raise vol.Invalid(f"expected a boolean, got {value!r}")
return value
def _build_schema(
current: dict[str, Any],
*,
admin_choices: dict[str, str] | None = None,
admin_default: list[str] | None = None,
) -> vol.Schema:
"""Build the form schema, pre-filled with the current values.
The API connection (Feature 18) carries the base URL the API client
talks to and the panel service token it presents as the Bearer
credential. Schema-validated submits always carry every field (the
defaults pre-fill from the stored values); raw callers omitting one
keep its stored value (see _async_validate).
The admin picker is a multi-select of EXISTING Home Assistant users
(never a free-text field — the allowlist stores user IDs); its
default is the current database allowlist, so an untouched submit
re-saves exactly who is already allowed. The notification section
(Feature 11) carries the household's notify target, the three
automation times, and four enable toggles — defaults from
:mod:`~.const`, never a hard-coded personal target.
"""
return vol.Schema(
{
vol.Required(
CONF_PANEL_IDLE_TIMEOUT,
default=current[CONF_PANEL_IDLE_TIMEOUT],
): int,
vol.Required(
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
): int,
# The last-good staleness threshold (Feature 18): how long
# the coordinator keeps serving the cached snapshot after
# the API stops answering.
vol.Required(
CONF_SNAPSHOT_STALENESS,
default=current[CONF_SNAPSHOT_STALENESS],
): int,
# Panel-plane API connection (Feature 18): the base URL the
# API client talks to and the panel service token it
# presents as the Bearer credential.
vol.Required(
CONF_API_BASE_URL, default=current[CONF_API_BASE_URL]
): str,
vol.Required(
CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN]
): str,
}
)
fields: dict[Any, Any] = {
vol.Required(
CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS]
): int,
vol.Required(
CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME]
): str,
vol.Required(
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
): int,
vol.Required(
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
): int,
vol.Required(
CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET]
): str,
vol.Required(
CONF_MORNING_SUMMARY_TIME,
default=current[CONF_MORNING_SUMMARY_TIME],
): str,
vol.Required(
CONF_AFTERNOON_REMINDER_TIME,
default=current[CONF_AFTERNOON_REMINDER_TIME],
): str,
vol.Required(
CONF_END_OF_DAY_REPORT_TIME,
default=current[CONF_END_OF_DAY_REPORT_TIME],
): str,
vol.Required(
CONF_MORNING_SUMMARY_ENABLED,
default=current[CONF_MORNING_SUMMARY_ENABLED],
): _strict_bool,
vol.Required(
CONF_AFTERNOON_REMINDER_ENABLED,
default=current[CONF_AFTERNOON_REMINDER_ENABLED],
): _strict_bool,
vol.Required(
CONF_END_OF_DAY_REPORT_ENABLED,
default=current[CONF_END_OF_DAY_REPORT_ENABLED],
): _strict_bool,
vol.Required(
CONF_CELEBRATION_ENABLED,
default=current[CONF_CELEBRATION_ENABLED],
): _strict_bool,
}
if admin_choices is not None:
# HA's supported multi-select validator: the frontend can
# serialize the field as a real picker of the given choices.
fields[
vol.Optional(
CONF_ADMIN_USER_IDS, default=list(admin_default or [])
)
] = cv.multi_select(admin_choices)
return vol.Schema(fields)
class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
"""Handle the options flow for NestQuest."""
def _async_database(self):
"""Return the runtime database for this entry, or None.
Real HA only opens the options flow for an existing entry, so
the runtime record (and its database) exists; the None branch
covers direct construction in tests and degraded states.
"""
hass_data = getattr(self.hass, "data", None)
if not isinstance(hass_data, dict):
return None
record = hass_data.get(DOMAIN, {}).get(self.config_entry.entry_id)
database = getattr(record, "database", None)
return database if getattr(database, "connected", False) else None
async def _async_admin_choices(self) -> dict[str, str]:
"""Map HA user id -> display name for the picker.
Real Home Assistant always provides ``hass.auth``; a missing
one (direct construction in tests, degraded harness) degrades
to an empty picker rather than crashing the form.
"""
auth = getattr(self.hass, "auth", None)
if auth is None:
return {}
users = await auth.async_get_users()
return {
user.id: user.name
for user in users
if getattr(user, "id", None)
}
async def _async_current_admin_ids(self) -> list[str]:
"""Return the current allowlist, or [] without a database."""
database = self._async_database()
if database is None:
return []
return await list_admin_ids(database)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the NestQuest options."""
admin_choices = await self._async_admin_choices()
admin_default = await self._async_current_admin_ids()
if user_input is None:
return self.async_show_form(
step_id="init",
data_schema=_build_schema(self._current_values()),
data_schema=_build_schema(
self._current_values(),
admin_choices=admin_choices,
admin_default=admin_default,
),
)
errors = _async_validate(user_input, self._current_values())
errors = _async_validate(user_input)
# The admin ids are validated only when the key is present:
# schema-validated submits always carry it (vol.Optional's
# default fills from the current allowlist), while raw callers
# omitting the key keep the field untouched.
admin_ids_present = CONF_ADMIN_USER_IDS in user_input
admin_ids = user_input.get(CONF_ADMIN_USER_IDS)
if errors:
return self.async_show_form(
step_id="init",
data_schema=_build_schema(self._current_values()),
data_schema=_build_schema(
self._current_values(),
admin_choices=admin_choices,
admin_default=admin_default,
),
errors=errors,
)
if admin_ids_present and (
not isinstance(admin_ids, list)
or any(not isinstance(value, str) for value in admin_ids)
):
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
elif admin_ids_present and not admin_ids:
# An empty allowlist fails closed: saving zero admins is
# refused, never silently applied.
errors[CONF_ADMIN_USER_IDS] = "no_admins"
elif admin_ids_present and any(
value not in admin_choices for value in admin_ids
):
# The schema enforces membership for real HA submits; this
# backs the raw-input path up so an unknown id can never
# reach the allowlist.
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
elif admin_ids_present and len(set(admin_ids)) != len(admin_ids):
# set_admin_ids refuses duplicates with ValueError; a flow
# submission must surface that as a field error instead of
# an unhandled crash.
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
if errors:
return self.async_show_form(
step_id="init",
data_schema=_build_schema(
self._current_values(),
admin_choices=admin_choices,
admin_default=admin_default,
),
errors=errors,
)
# Every remaining key is stored the fall-back-to-current way, so
# raw submits that predate a section keep that section's stored
# values.
# Apply the picker's selection to the allowlist when the
# runtime database is reachable; the selection is always
# stored on the entry as the disaster-recovery copy.
database = self._async_database()
if database is not None and admin_ids_present:
await set_admin_ids(database, admin_ids)
data = {
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
CONF_UPDATE_INTERVAL: user_input.get(
CONF_UPDATE_INTERVAL,
self._current_values()[CONF_UPDATE_INTERVAL],
),
}
# The notification section (Feature 11) stores the household's
# choices the same fall-back-to-current way, so raw submits
# that predate the section keep their stored values.
current = self._current_values()
data: dict[str, Any] = {}
for key in (
CONF_PANEL_IDLE_TIMEOUT,
CONF_UPDATE_INTERVAL,
CONF_SNAPSHOT_STALENESS,
CONF_API_BASE_URL,
CONF_PANEL_TOKEN,
for notification_key in (
CONF_NOTIFY_TARGET,
CONF_MORNING_SUMMARY_TIME,
CONF_AFTERNOON_REMINDER_TIME,
CONF_END_OF_DAY_REPORT_TIME,
CONF_MORNING_SUMMARY_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_CELEBRATION_ENABLED,
):
data[key] = user_input.get(key, current[key])
data[notification_key] = user_input.get(
notification_key, current[notification_key]
)
if admin_ids_present:
data[CONF_ADMIN_USER_IDS] = admin_ids
elif admin_default:
# HA replaces entry.options wholesale, so an omitted picker
# field must carry the CURRENT allowlist forward: dropping
# it would leave only the stale config-flow copy in entry
# data, and a later database loss would resurrect admins
# the owner has since removed.
data[CONF_ADMIN_USER_IDS] = list(admin_default)
return self.async_create_entry(title="NestQuest", data=data)
def _current_values(self) -> dict[str, Any]:
@@ -199,11 +353,18 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
entry_data = getattr(self.config_entry, "data", None) or {}
values: dict[str, Any] = {}
for key, default in (
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
(CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS),
(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
(CONF_NOTIFY_TARGET, ""),
(CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME),
(CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME),
(CONF_END_OF_DAY_REPORT_TIME, DEFAULT_END_OF_DAY_REPORT_TIME),
(CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED),
):
if key in options:
values[key] = options[key]
@@ -211,4 +372,4 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
values[key] = entry_data[key]
else:
values[key] = default
return values
return values
@@ -0,0 +1,90 @@
"""Permission gate for NestQuest services (Feature 09).
A wrapper factory, applied to EVERY registered service at registration
time (no handler is exempt), reads ``call.context.user_id``, consults
the :data:`~.service_policy.SERVICE_POLICY` registry and
:func:`~.admin_allowlist.is_admin`, and raises
:class:`homeassistant.exceptions.Unauthorized` for an admin-only
operation called by a non-admin — including a call with NO user
context, which fails closed. ``complete_quest`` (policy ``open``)
proceeds regardless of the allowlist.
Logging (later Feature 09 tasks): every denied admin-only call logs a
warning and every successful one logs a debug line, both carrying the
service, the user id (or ``none``), and a UTC timestamp.
"""
from __future__ import annotations
import datetime
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from homeassistant.exceptions import Unauthorized
from .admin_allowlist import is_admin
from .service_policy import ADMIN_ONLY
LOGGER = logging.getLogger(__name__)
ServiceHandler = Callable[[Any], Awaitable[None]]
def _context_user_id(call: Any) -> str | None:
context = getattr(call, "context", None)
user_id = getattr(context, "user_id", None) if context is not None else None
if isinstance(user_id, str) and user_id:
return user_id
return None
def _stamp() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def permission_gate(
service: str,
policy: str,
database_of: Callable[[Any], Any],
) -> Callable[[ServiceHandler], ServiceHandler]:
"""Wrap ``handler`` with the Feature 09 permission check.
``database_of`` resolves the live database from the call-time
runtime (the same ``find_runtime`` the handler uses), so the gate
never opens its own connection. The verdict is fail-closed:
absent allowlist row, unknown id, no user context — all not admin.
"""
def _wrap(handler: ServiceHandler) -> ServiceHandler:
if policy != ADMIN_ONLY:
return handler
async def _gated(call: Any) -> None:
user_id = _context_user_id(call)
allowed = user_id is not None and await is_admin(
database_of(call), user_id
)
if not allowed:
LOGGER.warning(
"NestQuest denied %s: caller user_id=%s at %s",
service,
user_id if user_id is not None else "none",
_stamp(),
)
raise Unauthorized(
f"User '{user_id if user_id is not None else 'none'}' "
f"is not allowed to call nestquest.{service}"
)
LOGGER.debug(
"NestQuest allowed %s: caller user_id=%s at %s",
service,
user_id,
_stamp(),
)
await handler(call)
return _gated
return _wrap
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.presence` for the integration package.
The presence and custody model lives in
:mod:`custom_components.nestquest.core.presence` (extracted in
task 510c1f78); this module re-exports it so existing
``from .presence import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.presence import * # noqa: F401,F403
@@ -0,0 +1,10 @@
"""Re-export of :mod:`custom_components.nestquest.core.presence_management` for the integration package.
The presence-write business layer lives in
:mod:`custom_components.nestquest.core.presence_management` (added for
task e4a9d31b); this module re-exports it so the integration's service
handlers call the ONE implementation the API admin plane shares.
"""
from __future__ import annotations
from .core.presence_management import * # noqa: F401,F403
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.quest_definitions` for the integration package.
The quest definitions business layer lives in
:mod:`custom_components.nestquest.core.quest_definitions` (extracted in
task 510c1f78); this module re-exports it so existing
``from .quest_definitions import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
from .core.quest_definitions import * # noqa: F401,F403
+10
View File
@@ -0,0 +1,10 @@
"""Re-export of :mod:`custom_components.nestquest.core.recurrence` for the integration package.
The recurrence model lives in :mod:`custom_components.nestquest.core.recurrence`
(extracted in task d0b691d8); this module re-exports it so existing
``from .recurrence import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.recurrence import * # noqa: F401,F403
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.schema` for the integration package.
The declarative schema DDL lives in
:mod:`custom_components.nestquest.core.schema` (extracted in
task d0b691d8); this module re-exports it so existing
``from .schema import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.schema import * # noqa: F401,F403
+3 -63
View File
@@ -32,7 +32,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import DOMAIN
from .coordinator import (
@@ -42,21 +41,13 @@ from .coordinator import (
)
from .core.snapshot import instance_payload
#: HA's state machine refuses states longer than 255
#: Home Assistant's state machine refuses states longer than 255
#: characters (its hard recorder/state limit). These sensors' states
#: are numeric, but the guard is applied uniformly: a payload-bearing
#: sensor can never crash the state write, and later string-state
#: sensors reuse the same helper.
MAX_STATE_LENGTH = 255
#: Entity-id length cap the roster applies to HA's slugify: HA's
#: slugifier itself does NOT truncate, but the entity registry derives
#: entity ids as ``<domain>.<slug>`` and truncates the full entity_id at
#: MAX_LENGTH_STATE_ENTITY_ID (64), so for this module's ``sensor.``
#: entities the slugged object id alone may not exceed
#: 64 - len("sensor.").
_MAX_SLUG_LENGTH = 64 - len("sensor.")
def _state_within_limit(state: Any) -> Any:
"""Return ``state`` unchanged, refusing strings over HA's limit.
@@ -107,51 +98,6 @@ def _next_open_quest(
)
def _slugify(text: str) -> str:
"""Slugify a display name the way Home Assistant derives entity ids.
Runs ``homeassistant.util.slugify`` — the real slugifier HA runs
entity names through, which transliterates every script to ASCII
through unidecode (so non-Latin names survive: ``京子`` slugs to
``jing_zi``) before folding to lowercase and turning every run of
non-alphanumerics into one underscore, answering HA's ``unknown``
sentinel when a name slugs to nothing — then caps the result at
the object-id length the entity registry enforces (the registry
truncates the full ``sensor.`` + slug entity_id at 64 characters,
so the slug alone is capped like this; HA's slugify does not
truncate by itself).
"""
return slugify(text)[:_MAX_SLUG_LENGTH]
def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]:
"""The ordered child roster the panel cards discover children from.
One entry per ACTIVE child in the snapshot's order (the API's
sort_order, then id): ``child_id``, ``name``, and the ``slug`` the
entity-id derivation produces for the child's name — the piece the
cards need to build ``sensor.nestquest_<slug>_...`` ids. Duplicate
display names get the registry's ``_2``/``_3`` suffix in snapshot
order, matching how HA resolves the collision at registration. The
rollups' ``children`` attribute (per-child counts) is unchanged and
carries a different shape on purpose.
"""
slugs: dict[str, int] = {}
roster: list[dict[str, Any]] = []
for child in children:
base = _slugify(child.child_name)
taken = slugs.get(base, 0) + 1
slugs[base] = taken
roster.append(
{
"child_id": child.child_id,
"name": child.child_name,
"slug": base if taken == 1 else f"{base}_{taken}",
}
)
return roster
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
"""Base for the per-child day sensors.
@@ -410,12 +356,7 @@ class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity):
class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
"""Total quests due today across active children.
Also publishes ``child_roster`` (see :func:`_child_roster`): the
ordered child identity list the panel cards read to discover the
board's children when the card config carries no ``child_order``.
"""
"""Total quests due today across active children."""
_attr_name = "NestQuest household quests due today"
_attr_state_class = SensorStateClass.MEASUREMENT
@@ -439,8 +380,7 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
"children": {
str(child.child_id): child.due_today
for child in data.children
},
"child_roster": _child_roster(data.children),
}
}
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.service_policy` for the integration package.
The service policy registry lives in
:mod:`custom_components.nestquest.core.service_policy` (extracted in
task 510c1f78); this module re-exports it so existing
``from .service_policy import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
from .core.service_policy import * # noqa: F401,F403
+486 -67
View File
@@ -1,36 +1,61 @@
"""The domain-global ``nestquest.complete_quest`` service (Feature 18).
"""Domain-global NestQuest service schemas and handlers (Feature 09).
Registration is domain-scoped and happens once (see
:func:`async_register_services`). Since Feature 18 the integration is
a client of the NestQuest API service, so ``complete_quest`` proxies
to the API's panel complete route through the entry's shared API
client instead of writing any database. The former HA admin services
(uncomplete, quest-definition and presence management, CSV export,
child management, regenerate) are removed: the admin surface is the
PWA and the API service's admin routes, so the only HA service left is
the panel completion path.
:func:`async_register_services`); handlers resolve a live database at
call time through the injected ``find_runtime`` callable. Permission
checks are Feature 09 later tasks — this module only validates payload
shape and forwards to the Feature 03/06/08 business layers (the
presence writes included, through :mod:`.presence_management` — the
ONE implementation the API admin plane shares).
``export_history_csv`` is registered with a schema but raises until
Feature 13. ``regenerate`` is supplied by the caller so
the Feature 07 handler stays the one in ``__init__``.
"""
from __future__ import annotations
import datetime
from collections.abc import Awaitable, Callable
from typing import Any
from zoneinfo import ZoneInfo
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .api_client import NestQuestApiError
from . import children as children_layer
from . import completion as completion_layer
from . import presence_management as presence_management_layer
from . import quest_definitions as quest_definitions_layer
from .const import (
DOMAIN,
DOMAIN_SERVICES,
QUEST_WINDOWS,
SERVICE_COMPLETE_QUEST,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_UPDATE_QUEST_DEFINITION,
)
from .events import fire_quest_completed, fire_quest_uncompleted
from .permissions import permission_gate
from .recurrence import ScheduleRule
from .service_policy import SERVICE_POLICY
FindRuntime = Callable[[HomeAssistant], Any]
ServiceHandler = Callable[[Any], Awaitable[None]]
ACTOR_PANEL = "panel"
ACTOR_USER = "user"
MANAGE_CHILD_CREATE = "create"
MANAGE_CHILD_EDIT = "edit"
MANAGE_CHILD_SET_ACTIVE = "set_active"
MANAGE_CHILD_REORDER = "reorder"
def _strict_int(value: object) -> int:
@@ -40,6 +65,82 @@ def _strict_int(value: object) -> int:
return value
def _strict_bool(value: object) -> bool:
"""Reject ints and other truthy values; require a real bool."""
if type(value) is not bool:
raise vol.Invalid(f"expected bool, got {value!r}")
return value
def _weekday_list(value: object) -> list[int]:
if type(value) is not list:
raise vol.Invalid(f"expected a list of weekday ints, got {value!r}")
return [_strict_int(item) for item in value]
def _presence_pattern(value: object) -> dict[int, list[int]]:
if not isinstance(value, dict):
raise vol.Invalid(f"pattern must be a mapping, got {value!r}")
pattern: dict[int, list[int]] = {}
for week, days in value.items():
if isinstance(week, str):
try:
week_index = int(week)
except ValueError as err:
raise vol.Invalid(
f"pattern week index must be an integer, got {week!r}"
) from err
if str(week_index) != week:
raise vol.Invalid(
f"pattern week index must be an integer, got {week!r}"
)
else:
week_index = _strict_int(week)
pattern[week_index] = _weekday_list(days)
return pattern
def _window_entry(value: object) -> str | tuple[str, str | None]:
if isinstance(value, str):
if value not in QUEST_WINDOWS:
raise vol.Invalid(f"unknown window {value!r}")
return value
if isinstance(value, dict):
parsed = vol.Schema(
{
vol.Required("window"): vol.In(QUEST_WINDOWS),
vol.Optional("due_time"): str,
}
)(value)
due_time = parsed.get("due_time")
if due_time is None:
return parsed["window"]
return (parsed["window"], due_time)
raise vol.Invalid(
f"windows entry must be a window name or object, got {value!r}"
)
def _windows(value: object) -> list[str | tuple[str, str | None]]:
if type(value) is not list:
raise vol.Invalid(f"windows must be a list, got {value!r}")
if not value:
raise vol.Invalid("windows must not be empty")
return [_window_entry(entry) for entry in value]
def _int_list(value: object) -> list[int]:
if type(value) is not list:
raise vol.Invalid(f"expected a list of ints, got {value!r}")
return [_strict_int(item) for item in value]
def _rule_dict(value: object) -> dict:
if not isinstance(value, dict):
raise vol.Invalid(f"rule must be a dict, got {value!r}")
return value
SCHEMA_COMPLETE_QUEST = vol.Schema(
{
vol.Required("instance_id"): _strict_int,
@@ -49,22 +150,102 @@ SCHEMA_COMPLETE_QUEST = vol.Schema(
}
)
SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST
SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema(
{
vol.Required("title"): str,
vol.Required("rule"): _rule_dict,
vol.Required("assignee_child_ids"): vol.All(
_int_list, vol.Length(min=1)
),
vol.Required("windows"): _windows,
vol.Optional("description"): str,
vol.Optional("icon"): str,
}
)
SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema(
{
vol.Required("definition_id"): _strict_int,
vol.Optional("title"): str,
vol.Optional("description"): vol.Any(str, None),
vol.Optional("icon"): vol.Any(str, None),
vol.Optional("rule"): _rule_dict,
vol.Optional("windows"): _windows,
}
)
SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema(
{
vol.Required("definition_id"): _strict_int,
vol.Required("is_active"): _strict_bool,
}
)
SCHEMA_SET_PRESENCE_PATTERN = vol.Schema(
{
vol.Required("child_id"): _strict_int,
vol.Required("cycle_length_weeks"): _strict_int,
vol.Required("anchor_date"): str,
vol.Required("pattern"): _presence_pattern,
}
)
SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema(
{
vol.Required("child_id"): _strict_int,
vol.Required("start_date"): str,
vol.Required("end_date"): str,
vol.Required("is_present"): _strict_bool,
vol.Optional("note"): str,
}
)
SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema(
{
vol.Required("override_id"): _strict_int,
}
)
SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({})
SCHEMA_MANAGE_CHILD = vol.Schema(
{
vol.Required("action"): vol.In(
[
MANAGE_CHILD_CREATE,
MANAGE_CHILD_EDIT,
MANAGE_CHILD_SET_ACTIVE,
MANAGE_CHILD_REORDER,
]
),
vol.Optional("child_id"): _strict_int,
vol.Optional("display_name"): str,
vol.Optional("colour"): str,
vol.Optional("avatar_ref"): str,
vol.Optional("sort_order"): _strict_int,
vol.Optional("is_active"): _strict_bool,
vol.Optional("ordered_ids"): _int_list,
}
)
def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
runtime = find_runtime(hass)
if runtime is None:
raise HomeAssistantError(
"NestQuest is not loaded; cannot run this service"
"NestQuest has no live database; cannot run this service"
)
return runtime
async def _refresh_entities(runtime: Any) -> None:
"""Force an immediate coordinator refresh after the service call.
"""Force an immediate coordinator refresh after a service mutation.
Only the SERVICE path calls this — the scheduled materialize and
Only the SERVICE paths call this — the scheduled materialize and
rollover internals are direct function calls and never widen this
gate — so the entities reflect a completion the moment the service
gate — so entities reflect a completion the moment the service
call returns instead of waiting for the poll interval.
"""
coordinator = getattr(runtime, "coordinator", None)
@@ -72,6 +253,12 @@ async def _refresh_entities(runtime: Any) -> None:
await coordinator.async_refresh()
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
return {"today": today, "horizon_days": runtime.settings.horizon_days}
def _actor_kwargs(call: Any) -> dict[str, Any]:
actor = call.data["actor"]
if actor == ACTOR_PANEL:
@@ -114,50 +301,223 @@ def _complete_quest(
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
instance_id = call.data["instance_id"]
# The panel contract (decision 9): a tap completes as the
# tapped child profile, the only actor shape the API's panel
# route accepts. The payload schema is unchanged — the panel
# cards call it with instance_id, actor=panel, actor_child_id.
actor = _actor_kwargs(call)
if actor["actor_source"] != ACTOR_PANEL:
raise HomeAssistantError(
"nestquest.complete_quest completes through the API's "
"panel route as the tapped child profile; actor must "
"be 'panel' with actor_child_id"
)
# The entry's ONE shared API client (built at setup): the
# coordinator's snapshot poll, the SSE subscription, and this
# proxy all ride the same instance. An entry with no panel
# token runs the unconfigured stub, which has no completion
# surface — fail fast with a clear, token-free message.
complete_instance = getattr(
getattr(runtime.coordinator, "api_client", None),
"complete_instance",
None,
# ``appended`` is decided under the same lock as the write, so
# a concurrent duplicate call (a double tap) can not produce a
# second transition event: the loser appends nothing.
result = await completion_layer.complete_instance(
runtime.database,
instance_id,
**_actor_kwargs(call),
)
if complete_instance is None:
raise HomeAssistantError(
"NestQuest API is not configured; cannot complete a "
"quest"
if result.appended:
await fire_quest_completed(
hass,
runtime.database,
instance_id,
was_on_time=result.was_on_time,
)
try:
await complete_instance(instance_id, actor["actor_child_id"])
except NestQuestApiError as err:
# The typed API failure surfaces as a service error the
# caller sees — never swallowed, never retried here. The
# typed message names method, path, and status only; the
# panel token never appears in it.
await _refresh_entities(runtime)
return _with_errors(_handler)
def _uncomplete_quest(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
instance_id = call.data["instance_id"]
result = await completion_layer.uncomplete_instance(
runtime.database,
instance_id,
**_actor_kwargs(call),
)
if result.appended:
await fire_quest_uncompleted(
hass, runtime.database, instance_id
)
await _refresh_entities(runtime)
return _with_errors(_handler)
def _create_quest_definition(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
database = _require_runtime(hass, find_runtime).database
data = call.data
await quest_definitions_layer.create_quest_definition(
database,
data["title"],
ScheduleRule.from_dict(data["rule"]),
data["assignee_child_ids"],
data["windows"],
description=data.get("description"),
icon=data.get("icon"),
)
return _with_errors(_handler)
def _update_quest_definition(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
kwargs: dict[str, Any] = _generation_kwargs(hass, runtime)
if "title" in data:
kwargs["title"] = data["title"]
if "description" in data:
kwargs["description"] = data["description"]
if "icon" in data:
kwargs["icon"] = data["icon"]
if "rule" in data:
kwargs["rule"] = ScheduleRule.from_dict(data["rule"])
if "windows" in data:
kwargs["windows"] = data["windows"]
await quest_definitions_layer.edit_quest_definition(
runtime.database, data["definition_id"], **kwargs
)
return _with_errors(_handler)
def _set_quest_definition_active(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
await quest_definitions_layer.set_quest_definition_active(
runtime.database,
call.data["definition_id"],
call.data["is_active"],
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
def _set_presence_pattern(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
await presence_management_layer.set_presence_schedule(
runtime.database,
data["child_id"],
data["cycle_length_weeks"],
data["anchor_date"],
data["pattern"],
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
def _create_presence_override(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
await presence_management_layer.create_presence_override(
runtime.database,
data["child_id"],
data["start_date"],
data["end_date"],
data["is_present"],
note=data.get("note"),
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
def _delete_presence_override(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
await presence_management_layer.delete_presence_override(
runtime.database,
call.data["override_id"],
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
async def _export_history_csv(_call: Any) -> None:
raise HomeAssistantError(
"CSV history export is not implemented until Feature 13"
)
def _manage_child(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
database = _require_runtime(hass, find_runtime).database
data = call.data
action = data["action"]
if action == MANAGE_CHILD_CREATE:
display_name = data.get("display_name")
if display_name is None:
raise HomeAssistantError(
"display_name is required when action is create"
)
kwargs: dict[str, Any] = {}
if "colour" in data:
kwargs["colour"] = data["colour"]
if "avatar_ref" in data:
kwargs["avatar_ref"] = data["avatar_ref"]
if "sort_order" in data:
kwargs["sort_order"] = data["sort_order"]
await children_layer.create_child(
database, display_name, **kwargs
)
return
if action == MANAGE_CHILD_EDIT:
child_id = data.get("child_id")
if child_id is None:
raise HomeAssistantError(
"child_id is required when action is edit"
)
kwargs = {}
if "display_name" in data:
kwargs["display_name"] = data["display_name"]
if "colour" in data:
kwargs["colour"] = data["colour"]
if "avatar_ref" in data:
kwargs["avatar_ref"] = data["avatar_ref"]
if "sort_order" in data:
kwargs["sort_order"] = data["sort_order"]
await children_layer.edit_child(database, child_id, **kwargs)
return
if action == MANAGE_CHILD_SET_ACTIVE:
child_id = data.get("child_id")
is_active = data.get("is_active")
if child_id is None:
raise HomeAssistantError(
"child_id is required when action is set_active"
)
if is_active is None:
raise HomeAssistantError(
"is_active is required when action is set_active"
)
await children_layer.set_child_active(
database, child_id, is_active
)
return
ordered_ids = data.get("ordered_ids")
if ordered_ids is None:
raise HomeAssistantError(
f"NestQuest quest completion failed: {err}"
) from err
# NO local completion write and NO local event: the API service
# emits the transition on its SSE stream and the integration's
# subscription re-fires it on the bus — firing it here too
# would double-fire. The immediate refresh keeps the old
# behaviour where the sensors reflect the completion the
# moment the service call returns instead of waiting for the
# poll interval.
await _refresh_entities(runtime)
"ordered_ids is required when action is reorder"
)
await children_layer.reorder_children(database, ordered_ids)
return _with_errors(_handler)
@@ -166,25 +526,84 @@ def async_register_services(
hass: HomeAssistant,
*,
find_runtime: FindRuntime,
regenerate_handler: ServiceHandler,
) -> None:
"""Register the panel completion service once at domain scope.
"""Register every canonical service once at domain scope.
The one remaining HA service is the panel completion path; every
admin operation moved to the API service's admin routes, so there
is no permission gate to apply here.
Every service — regenerate included — passes through the Feature
09 permission gate before its handler runs; the gate reads
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
registered exempt from the policy.
"""
if hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST):
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
return
hass.services.async_register(
DOMAIN,
SERVICE_COMPLETE_QUEST,
_complete_quest(hass, find_runtime),
schema=SCHEMA_COMPLETE_QUEST,
)
gate = permission_gate
specs: list[tuple[str, ServiceHandler, object]] = [
(SERVICE_REGENERATE, regenerate_handler, None),
(
SERVICE_COMPLETE_QUEST,
_complete_quest(hass, find_runtime),
SCHEMA_COMPLETE_QUEST,
),
(
SERVICE_UNCOMPLETE_QUEST,
_uncomplete_quest(hass, find_runtime),
SCHEMA_UNCOMPLETE_QUEST,
),
(
SERVICE_CREATE_QUEST_DEFINITION,
_create_quest_definition(hass, find_runtime),
SCHEMA_CREATE_QUEST_DEFINITION,
),
(
SERVICE_UPDATE_QUEST_DEFINITION,
_update_quest_definition(hass, find_runtime),
SCHEMA_UPDATE_QUEST_DEFINITION,
),
(
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
_set_quest_definition_active(hass, find_runtime),
SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
),
(
SERVICE_SET_PRESENCE_PATTERN,
_set_presence_pattern(hass, find_runtime),
SCHEMA_SET_PRESENCE_PATTERN,
),
(
SERVICE_CREATE_PRESENCE_OVERRIDE,
_create_presence_override(hass, find_runtime),
SCHEMA_CREATE_PRESENCE_OVERRIDE,
),
(
SERVICE_DELETE_PRESENCE_OVERRIDE,
_delete_presence_override(hass, find_runtime),
SCHEMA_DELETE_PRESENCE_OVERRIDE,
),
(
SERVICE_EXPORT_HISTORY_CSV,
_export_history_csv,
SCHEMA_EXPORT_HISTORY_CSV,
),
(
SERVICE_MANAGE_CHILD,
_manage_child(hass, find_runtime),
SCHEMA_MANAGE_CHILD,
),
]
def _database_of(_call: Any) -> Any:
return _require_runtime(hass, find_runtime).database
for service, handler, schema in specs:
gated = gate(
service, SERVICE_POLICY[service], _database_of
)(handler)
hass.services.async_register(DOMAIN, service, gated, schema=schema)
def async_deregister_services(hass: HomeAssistant) -> None:
"""Remove every canonical service when the last entry unloads."""
for service in DOMAIN_SERVICES:
if hass.services.has_service(DOMAIN, service):
hass.services.async_remove(DOMAIN, service)
hass.services.async_remove(DOMAIN, service)
+291 -4
View File
@@ -1,8 +1,15 @@
# Service declarations for the NestQuest integration (Home Assistant
# services.yaml metadata). Services are domain-scoped: nested under the
# "nestquest" domain, not repeated per config entry. Since Feature 18
# only the panel completion service remains — every admin operation
# moved to the PWA and the API service's admin routes.
# "nestquest" domain, not repeated per config entry.
regenerate:
name: Regenerate quest instances
description: >-
Regenerate every future quest instance for the household over the rolling
horizon, using the same idempotent materialization as the daily rollover
and the startup backfill. Safe to call at any time: a re-run refreshes the
same rows instead of duplicating them, and completed instances are never
touched.
complete_quest:
name: Complete quest
@@ -36,4 +43,284 @@ complete_quest:
description: Home Assistant user id when actor is user and no call context is present.
required: false
selector:
text:
text:
uncomplete_quest:
name: Uncomplete quest
description: Undo a quest instance completion.
fields:
instance_id:
name: Instance ID
description: The quest instance to uncomplete.
required: true
selector:
number:
mode: box
actor:
name: Actor
description: Who is undoing the completion (panel or user).
required: true
selector:
select:
options:
- panel
- user
actor_child_id:
name: Actor child ID
description: The tapped child profile. Required when actor is panel.
required: false
selector:
number:
mode: box
actor_user_id:
name: Actor user ID
description: Home Assistant user id when actor is user and no call context is present.
required: false
selector:
text:
create_quest_definition:
name: Create quest definition
description: Create a quest definition with its schedule rule, assignees and windows.
fields:
title:
name: Title
description: Display title for the quest.
required: true
selector:
text:
rule:
name: Rule
description: Recurrence rule as a mapping (rule_type plus shape fields).
required: true
selector:
object:
assignee_child_ids:
name: Assignee child IDs
description: Child ids this definition is assigned to.
required: true
selector:
object:
windows:
name: Windows
description: Day windows (morning, afternoon, evening), optionally with a due time.
required: true
selector:
object:
description:
name: Description
description: Optional longer description.
required: false
selector:
text:
icon:
name: Icon
description: Optional icon name.
required: false
selector:
text:
update_quest_definition:
name: Update quest definition
description: Edit a quest definition's title, description, icon, rule or windows.
fields:
definition_id:
name: Definition ID
description: The quest definition to update.
required: true
selector:
number:
mode: box
title:
name: Title
description: Replacement display title.
required: false
selector:
text:
description:
name: Description
description: Replacement description, or null to clear.
required: false
selector:
text:
icon:
name: Icon
description: Replacement icon, or null to clear.
required: false
selector:
text:
rule:
name: Rule
description: Replacement recurrence rule as a mapping.
required: false
selector:
object:
windows:
name: Windows
description: Replacement day windows.
required: false
selector:
object:
set_quest_definition_active:
name: Set quest definition active
description: Deactivate or reactivate a quest definition.
fields:
definition_id:
name: Definition ID
description: The quest definition to update.
required: true
selector:
number:
mode: box
is_active:
name: Active
description: Whether the definition should generate future instances.
required: true
selector:
boolean:
set_presence_pattern:
name: Set presence pattern
description: Upsert a child's repeating presence schedule.
fields:
child_id:
name: Child ID
description: The child whose schedule is being set.
required: true
selector:
number:
mode: box
cycle_length_weeks:
name: Cycle length weeks
description: Number of weeks in the repeating cycle (1 to 4).
required: true
selector:
number:
mode: box
min: 1
max: 4
anchor_date:
name: Anchor date
description: Cycle anchor as a YYYY-MM-DD date.
required: true
selector:
date:
pattern:
name: Pattern
description: Mapping of week index to present weekday ints (Monday is 0).
required: true
selector:
object:
create_presence_override:
name: Create presence override
description: Create a date-range presence override for a child.
fields:
child_id:
name: Child ID
description: The child the override applies to.
required: true
selector:
number:
mode: box
start_date:
name: Start date
description: Inclusive start date (YYYY-MM-DD).
required: true
selector:
date:
end_date:
name: End date
description: Inclusive end date (YYYY-MM-DD).
required: true
selector:
date:
is_present:
name: Present
description: Whether the child is present for this range.
required: true
selector:
boolean:
note:
name: Note
description: Optional note stored with the override.
required: false
selector:
text:
delete_presence_override:
name: Delete presence override
description: Delete a presence override by id.
fields:
override_id:
name: Override ID
description: The override to delete.
required: true
selector:
number:
mode: box
export_history_csv:
name: Export history CSV
description: Export completion history as CSV. Not implemented until Feature 13.
manage_child:
name: Manage child
description: Create, edit, activate or reorder child profiles.
fields:
action:
name: Action
description: create, edit, set_active, or reorder.
required: true
selector:
select:
options:
- create
- edit
- set_active
- reorder
child_id:
name: Child ID
description: Required for edit and set_active.
required: false
selector:
number:
mode: box
display_name:
name: Display name
description: Required for create; optional for edit.
required: false
selector:
text:
colour:
name: Colour
description: Optional colour string.
required: false
selector:
text:
avatar_ref:
name: Avatar
description: Optional avatar reference.
required: false
selector:
text:
sort_order:
name: Sort order
description: Optional panel sort order.
required: false
selector:
number:
mode: box
is_active:
name: Active
description: Required for set_active.
required: false
selector:
boolean:
ordered_ids:
name: Ordered IDs
description: Complete permutation of child ids. Required for reorder.
required: false
selector:
object:
-172
View File
@@ -1,172 +0,0 @@
"""HA-bus subscription over the API service's panel SSE stream.
The NestQuest API service (Feature 16) publishes every quest
transition on ``GET /api/v1/panel/events`` as Server-Sent Events
frames. This module is the integration-side subscriber: ONE
background task consumes :meth:`~.api_client.NestQuestApiClient
.stream_events` and re-fires each frame on ``hass.bus`` with the SAME
event type and payload fields the local transition path
(:mod:`~.events`) emits — ``nestquest_quest_completed``,
``nestquest_quest_uncompleted``, ``nestquest_quest_missed`` and
``nestquest_child_day_complete`` — so the four blueprints fire
identically whether a completion happens through the integration's own
service or through the API service's panel route.
Failure handling: a failed stream (API down, network blip, non-2xx, a
dropped connection) is retried FOREVER with a bounded exponential
backoff — :data:`RECONNECT_MIN_SECONDS` after the first failure,
doubling up to :data:`RECONNECT_MAX_SECONDS` — so a long API outage
neither pins the event loop nor spams the log, and a frame received on
a healthy stream resets the backoff. Nothing escapes the task: every
failure is logged with its reason and the loop reconnects; a
:class:`asyncio.CancelledError` (from :meth:`stop` or integration
unload) is the only thing that ends it, and it stops cleanly — no
frame fires after :meth:`stop` returns and the response is closed.
"""
from __future__ import annotations
import asyncio
from typing import Any
from .const import LOGGER
__all__ = [
"NestQuestEventStream",
"RECONNECT_MAX_SECONDS",
"RECONNECT_MIN_SECONDS",
]
#: Delay before the FIRST reconnect attempt after a stream failure.
#: One failed frame must not turn into a hot reconnect loop.
RECONNECT_MIN_SECONDS = 5.0
#: Ceiling for the exponential reconnect backoff: a long API outage
#: retries at a calm fixed cadence instead of growing without bound.
RECONNECT_MAX_SECONDS = 60.0
class NestQuestEventStream:
"""Consume the panel SSE stream in the background, fire the HA bus.
ONE instance per config entry, built in integration setup with the
SAME API client the coordinator polls (the client is stateless —
one shared session, one connection pool). ``start()`` schedules
the background task; ``stop()`` cancels and awaits it, so unload
never leaks the task and no frame fires after teardown. The task
itself is failure-proof: any exception from the stream (typed
:class:`~.api_client.NestQuestApiError` or anything else) is
logged and retried after the current backoff, never allowed to
escape the task.
"""
def __init__(
self,
hass: Any,
client: Any,
*,
reconnect_min_seconds: float = RECONNECT_MIN_SECONDS,
reconnect_max_seconds: float = RECONNECT_MAX_SECONDS,
) -> None:
"""Store the hass bus target, the API client, and the backoff.
The client is anything exposing ``stream_events()`` (the real
:class:`~.api_client.NestQuestApiClient` in production, a stub
in tests). The backoff bounds must be positive and the minimum
must not exceed the maximum — a misconfiguration should fail
loudly at construction, not quietly reconnect at delay 0.
"""
if (
not isinstance(reconnect_min_seconds, (int, float))
or isinstance(reconnect_min_seconds, bool)
or reconnect_min_seconds <= 0
or reconnect_min_seconds > reconnect_max_seconds
):
raise ValueError(
"reconnect_min_seconds must be a positive number no "
f"greater than reconnect_max_seconds, got "
f"{reconnect_min_seconds!r}"
)
if (
not isinstance(reconnect_max_seconds, (int, float))
or isinstance(reconnect_max_seconds, bool)
or reconnect_max_seconds <= 0
):
raise ValueError(
f"reconnect_max_seconds must be a positive number, got "
f"{reconnect_max_seconds!r}"
)
self._hass = hass
self._client = client
self._min_backoff = float(reconnect_min_seconds)
self._max_backoff = float(reconnect_max_seconds)
self._backoff = float(reconnect_min_seconds)
self._task: asyncio.Task | None = None
@property
def task(self) -> asyncio.Task | None:
"""The background task, or ``None`` when never started/stopped."""
return self._task
def start(self) -> None:
"""Schedule the subscription task (idempotent while it runs)."""
if self._task is not None and not self._task.done():
return
self._backoff = self._min_backoff
self._task = asyncio.create_task(
self._run(), name="nestquest-sse-subscription"
)
async def stop(self) -> None:
"""Cancel and await the subscription task.
Returns only after the task has actually finished, so unload
can rely on: no further frame is fired after ``stop()``, and
the underlying stream response has been closed. Safe to call
when the task was never started or already stopped.
"""
task, self._task = self._task, None
if task is None:
return
task.cancel()
try:
await task
except asyncio.CancelledError:
# The task's own cancellation surfaces here — expected and
# swallowed; a cancelling the CALLER would re-raise out of
# the surrounding await on its own.
pass
async def _run(self) -> None:
"""Consume stream frames forever, reconnecting on failure."""
while True:
try:
async for event_type, payload in self._client.stream_events():
# A frame arrived on a healthy stream: the next
# failure reconnects quickly again.
self._backoff = self._min_backoff
try:
self._hass.bus.async_fire(event_type, dict(payload))
except Exception as err:
# Firing one frame must never tear down a
# healthy connection: log and keep consuming.
LOGGER.error(
"NestQuest panel event %s could not be fired "
"on the Home Assistant event bus: %s",
event_type,
err,
)
except asyncio.CancelledError:
# Unload / shutdown: stop cleanly, never converted.
raise
except Exception as err:
delay = self._backoff
self._backoff = min(
self._backoff * 2, self._max_backoff
)
LOGGER.warning(
"NestQuest panel event stream failed (%s); "
"reconnecting in %.1f s",
err,
delay,
)
await asyncio.sleep(delay)
+32
View File
@@ -0,0 +1,32 @@
"""Re-export of :mod:`custom_components.nestquest.core.store` for the integration package.
The path resolver lives in
:mod:`custom_components.nestquest.core.store` (extracted in task
d0b691d8) and is Home-Assistant-free:
:func:`custom_components.nestquest.core.store.async_get_db_path`
takes the full database path. This module re-exports it and provides
the original ``async_get_db_path(hass)`` signature so the integration
resolves the path from ``hass.config.path`` exactly as before.
"""
from __future__ import annotations
from pathlib import Path
from homeassistant.core import HomeAssistant
from .core.const import SQLITE_DB_FILENAME
from .core.store import async_get_db_path as _core_async_get_db_path
from .core.store import _ensure_dir
__all__ = ["async_get_db_path", "_ensure_dir"]
async def async_get_db_path(hass: HomeAssistant) -> Path:
"""Return the absolute path to the NestQuest SQLite database file.
Resolves the database file from ``hass.config.path`` and delegates to
:func:`custom_components.nestquest.core.store.async_get_db_path`,
which ensures the parent directory exists off the event loop via
:func:`asyncio.to_thread`.
"""
return await _core_async_get_db_path(hass.config.path(SQLITE_DB_FILENAME))
+7 -6
View File
@@ -18,19 +18,20 @@
"step": {
"init": {
"title": "NestQuest Settings",
"description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.",
"description": "Adjust how NestQuest plans, rolls over days, and idles its panel.",
"data": {
"horizon_days": "Generation horizon (days)",
"day_rollover_time": "Day rollover time (HH:MM)",
"panel_idle_timeout": "Panel idle timeout (seconds)",
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
"api_base_url": "NestQuest API base URL (http://host:port)",
"panel_token": "Panel service token"
"admin_user_ids": "Admin users (parents)"
}
}
},
"errors": {
"invalid": "Invalid value.",
"invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.",
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval."
"invalid_time": "Time must use 24-hour HH:MM format.",
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
"invalid_admin": "Invalid admin selection."
}
}
}
+132
View File
@@ -0,0 +1,132 @@
"""The nightly missed-quest sweep (Feature 11).
At the configured day-rollover time (the same HA-local listener that
re-materializes the rolling horizon) this job finds every instance
whose ``due_date`` is before the HA-local date, that has NO completion
event, and that falls after the persisted sweep watermark — and fires
ONE ``nestquest_quest_missed`` event per instance with the documented
payload. The sweep is read-only over the domain tables: it never
mutates an instance and never writes a completion event (``missed``
is derived state, never an event_type — Feature 08's contract).
Idempotency is the watermark: after a run, ``nestquest_meta_state``
records the HA-local date the sweep ran, and the next run only
sweeps instances due in ``[watermark, today)``. A second run the
same night therefore fires nothing new, and a run after days of
downtime sweeps exactly the accumulated window once — no
per-instance journal, no re-announcing old misses. An instance swept
as missed that is 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.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
from zoneinfo import ZoneInfo
from .const import EVENT_QUEST_MISSED
from .dao_children import ChildrenDao
from .dao_instances import QuestInstancesDao
from .dao_meta import MetaStateDao
from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
#: meta_state key holding the HA-local date the sweep last ran.
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
#: the migration runner's pattern: the whole watermark read → query →
#: fire → 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.
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the sweep lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _SWEEP_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_SWEEP_LOCKS[key] = lock
return lock
#: UTC ISO-8601 timestamp policy shared with the DAO layers.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _now_stamp() -> str:
return datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
async def run_missed_sweep(
hass,
database: NestQuestDatabase,
*,
today: datetime.date | None = None,
) -> int:
"""Fire one missed event per unswept past-due open instance.
``today`` optionally pins the HA-local date (threaded like the
materialization and completion layers); when omitted it is read
from ``hass.config.time_zone``. Returns the number of events
fired. Fires nothing and returns 0 when the watermark already
covers ``today`` (a same-night rerun).
The whole watermark read → query → fire → 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.
"""
today_date = (
today
if today is not None
else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
)
today_iso = today_date.isoformat()
dao = MetaStateDao(database)
async with _sweep_lock(database):
watermark = await dao.get(SWEEP_WATERMARK_KEY)
if watermark == today_iso:
LOGGER.debug(
"NestQuest missed sweep already ran for %s", today_iso
)
return 0
instances_dao = QuestInstancesDao(database)
rows = await instances_dao.list_missed_unswept(today_iso, watermark)
children_dao = ChildrenDao(database)
definitions_dao = QuestDefinitionsDao(database)
now_stamp = _now_stamp()
fired = 0
for instance in rows:
child = await children_dao.get(instance.child_id)
definition = await definitions_dao.get(instance.definition_id)
hass.bus.async_fire(
EVENT_QUEST_MISSED,
{
"child_id": instance.child_id,
"child_name": child.display_name if child else None,
"instance_id": instance.id,
"quest_title": definition.title if definition else None,
"window": instance.window,
"due_date": instance.due_date,
"due_time": instance.due_time,
"occurred_at": now_stamp,
},
)
fired += 1
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
LOGGER.info(
"NestQuest missed sweep for %s fired %d event(s)", today_iso, fired
)
return fired
@@ -18,19 +18,20 @@
"step": {
"init": {
"title": "NestQuest Settings",
"description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.",
"description": "Adjust how NestQuest plans, rolls over days, and idles its panel.",
"data": {
"horizon_days": "Generation horizon (days)",
"day_rollover_time": "Day rollover time (HH:MM)",
"panel_idle_timeout": "Panel idle timeout (seconds)",
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
"api_base_url": "NestQuest API base URL (http://host:port)",
"panel_token": "Panel service token"
"admin_user_ids": "Admin users (parents)"
}
}
},
"errors": {
"invalid": "Invalid value.",
"invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.",
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval."
"invalid_time": "Time must use 24-hour HH:MM format.",
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
"invalid_admin": "Invalid admin selection."
}
}
}
File diff suppressed because it is too large Load Diff
-14
View File
@@ -1,14 +0,0 @@
[Unit]
Description=NestQuest OpenCode controller
[Service]
Type=simple
WorkingDirectory=/mnt/ai-storage/projects/NestQuest
ExecStartPre=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller --check
ExecStart=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller
Restart=on-failure
RestartSec=60s
RestartPreventExitStatus=77
TimeoutStartSec=0
KillMode=control-group
Environment=PYTHONUNBUFFERED=1
@@ -1,11 +0,0 @@
---
description: Run the NestQuest orchestrator now in this OpenCode terminal
agent: terminal-orchestrator
---
Run the NestQuest project queue now in this foreground OpenCode session.
Read the current CTXD prompt and Maestro state, but use the FOREGROUND
TERMINAL RUNTIME instead of the service runtime. Reconcile repository and
pull-request state, then execute CONTROLLER_TICK repeatedly until a global
terminal condition, explicit user stop, genuine global block, or host-forced
yield. Do not start or wait for the systemd controller.
-203
View File
@@ -1,203 +0,0 @@
# 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.
-522
View File
@@ -9,197 +9,10 @@
"lit": "^3.3.0"
},
"devDependencies": {
"jsdom": "^30.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz",
"integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.4.0",
"@csstools/css-color-parser": "^4.2.3",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz",
"integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.1.0",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
"integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz",
"integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.1",
"@csstools/css-calc": "^3.4.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
"integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -642,24 +455,6 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
@@ -1097,79 +892,6 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -1245,66 +967,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/html-encoding-sniffer": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz",
"integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^7.0.0",
"@asamuzakjp/dom-selector": "^9.2.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^7.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"tough-cookie": "^6.0.2",
"undici": "^8.10.2",
"w3c-xmlserializer": "^6.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lit": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
@@ -1336,23 +998,6 @@
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
@@ -1372,19 +1017,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1434,26 +1066,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
@@ -1500,19 +1112,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1540,52 +1139,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tldts": {
"version": "7.4.14",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.14.tgz",
"integrity": "sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.14"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.14",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.14.tgz",
"integrity": "sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1600,16 +1153,6 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz",
"integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/vite": {
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
@@ -1684,71 +1227,6 @@
"optional": true
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.1.2",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz",
"integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}
-1
View File
@@ -9,7 +9,6 @@
"lit": "^3.3.0"
},
"devDependencies": {
"jsdom": "^30.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
+7 -36
View File
@@ -143,16 +143,6 @@ function asNumber(value: unknown, fallback: number): number {
return Number.isFinite(parsed) ? parsed : fallback;
}
/** The slug a discovered roster entry carries, or "" when the entry is
* not an object with a non-empty slug string. */
function asRosterSlug(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const slug = (entry as Record<string, unknown>).slug;
return typeof slug === "string" ? slug.trim() : "";
}
function clampPercent(value: number): number {
if (!Number.isFinite(value)) {
return 0;
@@ -902,34 +892,15 @@ export class NestQuestPartyBoardCard extends LitElement {
return [];
}
const order = this._config.child_order;
if (Array.isArray(order)) {
return order
.filter(
(entry): entry is string =>
typeof entry === "string" && entry.trim().length > 0
)
.map((entry) => entry.trim());
}
return this._discoveredChildSlugs();
}
/** Zero-config discovery (Feature 20): the ordered child roster the
* integration publishes on the household rollup
* (custom_components/nestquest/sensor.py — ``child_roster``, the
* snapshot's sort_order, then id), each entry carrying the slug the
* per-child entity ids are built from. Consulted only when the
* config has no child_order — explicit configuration wins. */
private _discoveredChildSlugs(): string[] {
const household = this._state(
"sensor.nestquest_household_quests_due_today"
);
const roster = household?.attributes?.child_roster;
if (!Array.isArray(roster)) {
if (!Array.isArray(order)) {
return [];
}
return roster
.map((entry) => asRosterSlug(entry))
.filter((slug) => slug.length > 0);
return order
.filter(
(entry): entry is string =>
typeof entry === "string" && entry.trim().length > 0
)
.map((entry) => entry.trim());
}
private _plates(): ChildPlate[] {
-1
View File
@@ -1,7 +1,6 @@
import "./cards/party-board";
import "./cards/quest-log";
import "./cards/admin";
import "./strategy";
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
if (!document.querySelector('link[data-nq-fonts=""]')) {
-161
View File
@@ -1,161 +0,0 @@
import type { HassLike } from "./types";
type StateObject = {
state: unknown;
attributes?: Record<string, unknown> | null;
};
type RosterEntry = {
name: string;
slug: string;
};
type View = {
path?: string;
title?: string;
type: string;
cards: Record<string, unknown>[];
};
type LovelaceConfig = {
views: View[];
};
type StrategyConfig = Record<string, unknown>;
type StrategyClass = {
generate: (
config: StrategyConfig,
hass: HassLike | undefined
) => Promise<LovelaceConfig>;
};
declare global {
interface Window {
customStrategies?: Record<string, StrategyClass>;
}
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/** The slug a discovered roster entry carries, or "" when the entry is
* not an object with a non-empty slug string — the same shape check
* the party board applies to the same attribute. */
function asRosterSlug(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const slug = (entry as Record<string, unknown>).slug;
return typeof slug === "string" ? slug.trim() : "";
}
/** The dashboard's URL path segment, used to COMPUTE the card paths the
* navigation needs. A dashboard lives at a single top-level segment
* (/<url_path>), so the first pathname segment is the dashboard root —
* this is where the strategy renders, and navigating back to it shows
* the first view (the party board). An explicit ``url_path`` in the
* strategy configuration wins for setups that need to pin it. */
function dashboardUrlPath(config: StrategyConfig): string {
const configured = asString(config.url_path).replace(/^\/+|\/+$/g, "");
if (configured) {
return configured;
}
const segment = window.location.pathname.split("/").filter(Boolean)[0];
return segment ?? "";
}
/** The display name a roster entry carries, or "" when it is not a
* non-empty string — the roster's ``name`` field (sensor.py
* ``_child_roster``), used for the quest-log view titles. */
function asRosterName(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const name = (entry as Record<string, unknown>).name;
return typeof name === "string" ? name.trim() : "";
}
/** The ordered child roster the integration publishes on the household
* rollup (custom_components/nestquest/sensor.py — ``child_roster``,
* the snapshot's sort_order, then id). The same source the party
* board's plate discovery reads, consumed here at render time so the
* quest-log views exist for exactly the active children. */
function rosterEntries(hass: HassLike | undefined): RosterEntry[] {
const states = hass?.states as Record<string, StateObject> | undefined;
const household = states?.["sensor.nestquest_household_quests_due_today"];
const roster = household?.attributes?.child_roster;
if (!Array.isArray(roster)) {
return [];
}
return roster
.map((entry) => ({ slug: asRosterSlug(entry), name: asRosterName(entry) }))
.filter((entry) => entry.slug.length > 0);
}
/**
* Zero-config NestQuest dashboard strategy (Feature 20).
*
* A dashboard configured with ``strategy: { type: custom:nestquest-party }``
* gets its views GENERATED at render time from the household roster: one
* party-board view (the dashboard root) and one quest-log view per child,
* ordered by the roster's sort_order. The paths the cards navigate by are
* COMPUTED, not configured:
*
* - the board's ``quest_log_path`` is the dashboard root; tapping a crest
* pushes ``<root>/<slug>`` (party-board.ts ``_openQuestLog``);
* - each log view's ``path`` IS the child's slug, so the log resolves its
* child from the last pathname segment (quest-log.ts
* ``childSlugFromPath``) and its ``board_path`` back to the dashboard
* root (quest-log.ts ``_returnToBoard``).
*
* With zero children the board view still renders — the board card shows
* its own "not set up yet" notice for an empty roster, so no log views are
* needed. ``url_path`` and ``weather_entity`` may still be set explicitly
* in the strategy configuration; everything else is derived.
*/
export class NestQuestPartyStrategy {
static async generate(
config: StrategyConfig,
hass: HassLike | undefined
): Promise<LovelaceConfig> {
const root = `/${dashboardUrlPath(config)}`;
const weatherEntity = asString(config.weather_entity);
const boardCard: Record<string, unknown> = {
type: "custom:nestquest-party-board-card",
quest_log_path: root,
};
if (weatherEntity) {
boardCard.weather_entity = weatherEntity;
}
const logCard: Record<string, unknown> = {
type: "custom:nestquest-quest-log-card",
board_path: root,
};
if (weatherEntity) {
logCard.weather_entity = weatherEntity;
}
return {
views: [
{
path: "party",
title: "The Party",
type: "panel",
cards: [boardCard],
},
...rosterEntries(hass).map<View>((entry) => ({
path: entry.slug,
title: entry.name
? `${entry.name}'s Quest Log`
: `${entry.slug}'s Quest Log`,
type: "panel",
cards: [{ ...logCard }],
})),
],
};
}
}
window.customStrategies = window.customStrategies ?? {};
window.customStrategies["nestquest-party"] = NestQuestPartyStrategy;
-80
View File
@@ -1,80 +0,0 @@
/**
* Render the built party-board card under jsdom and print its plates.
*
* Usage: node render-party-board.mjs < spec.json
*
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?};
* stdout receives one JSON object {plates, tappable, location, html} where
* plates is the ordered plate list the shadow DOM renders (name +
* present/away/unknown kind), tappable the plate names rendered as tappable
* <button> plates, and location the window pathname after the optional plate
* tap. The Python panel tests drive this harness so the assertions run
* against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { JSDOM } from "jsdom";
const spec = JSON.parse(readFileSync(0, "utf8"));
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: spec.url ?? "http://homeassistant.local/nestquest/board",
pretendToBeVisual: true,
});
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (!(key in globalThis)) {
try {
globalThis[key] = dom.window[key];
} catch {
// jsdom getters may throw on access; the card does not need them.
}
}
}
globalThis.window = dom.window;
// The card navigates by dispatching new Event("location-changed") on this
// window; jsdom's EventTarget rejects Node's own global Event class, so the
// jsdom Event must shadow it for the dispatched event to be delivered.
globalThis.Event = dom.window.Event;
await import(pathToFileURL(spec.bundle).href);
const element = document.createElement(spec.tag);
element.setConfig(spec.config);
element.hass = spec.hass;
document.body.appendChild(element);
await element.updateComplete;
const plateNodes = [
...element.shadowRoot.querySelectorAll(".plates .plate"),
];
const plates = plateNodes.map((plate) => ({
name: plate.querySelector(".name")?.textContent?.trim() ?? "",
kind: plate.classList.contains("unknown")
? "unknown"
: plate.classList.contains("away")
? "away"
: "present",
}));
const tappable = plateNodes
.filter((plate) => plate.tagName === "BUTTON")
.map((plate) => plate.querySelector(".name")?.textContent?.trim() ?? "");
if (spec.click_plate !== undefined) {
const plate = plateNodes.find(
(candidate) =>
candidate.querySelector(".name")?.textContent?.trim() === spec.click_plate
);
plate?.click();
}
const payload = JSON.stringify({
plates,
tappable,
location: window.location.pathname,
html: element.shadowRoot.innerHTML,
});
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
-73
View File
@@ -1,73 +0,0 @@
/**
* Render the built quest-log card under jsdom and report its location.
*
* Usage: node render-quest-log.mjs < spec.json
*
* The spec JSON carries {bundle, url, config, hass, idle_ms?, probe_ms?};
* the card is mounted at the given URL (it resolves its child from the
* pathname's last segment), after the optional probe wait one snapshot of
* the render is taken mid-wait, then after the optional idle wait stdout
* receives one JSON object {location, headline, countdown, html, probe} —
* the final window pathname, the rendered title, the complete-screen
* countdown text, the rendered shadow DOM, and the probe snapshot when
* probe_ms was given. The Python panel tests drive this harness so the
* assertions run against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { JSDOM } from "jsdom";
const spec = JSON.parse(readFileSync(0, "utf8"));
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: spec.url ?? "http://homeassistant.local/nestquest/ada",
pretendToBeVisual: true,
});
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (!(key in globalThis)) {
try {
globalThis[key] = dom.window[key];
} catch {
// jsdom getters may throw on access; the card does not need them.
}
}
}
globalThis.window = dom.window;
// The card navigates by dispatching new Event("location-changed") on this
// window; jsdom's EventTarget rejects Node's own global Event class, so the
// jsdom Event must shadow it for the return navigation to be delivered.
globalThis.Event = dom.window.Event;
await import(pathToFileURL(spec.bundle).href);
const element = document.createElement("nestquest-quest-log-card");
element.setConfig(spec.config);
element.hass = spec.hass;
document.body.appendChild(element);
await element.updateComplete;
const snapshot = () => ({
location: window.location.pathname,
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
countdown:
element.shadowRoot.querySelector(".complete-countdown")?.textContent?.trim() ??
"",
html: element.shadowRoot.innerHTML,
});
let probe = null;
if (spec.probe_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.probe_ms));
probe = snapshot();
}
if (spec.idle_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
}
const payload = JSON.stringify({ ...snapshot(), probe });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
-48
View File
@@ -1,48 +0,0 @@
/**
* Import the built bundle into jsdom and generate the dashboard strategy's
* views.
*
* Usage: node render-strategy.mjs < spec.json
*
* The spec JSON carries {bundle, url, config, hass}; stdout receives one
* JSON object {views} — the strategy output's views array with the paths
* and card configs the generated dashboard renders. The Python panel
* tests drive this harness so the assertions run against the exact bundle
* HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { JSDOM } from "jsdom";
const spec = JSON.parse(readFileSync(0, "utf8"));
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: spec.url ?? "http://homeassistant.local/nestquest/party",
pretendToBeVisual: true,
});
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (!(key in globalThis)) {
try {
globalThis[key] = dom.window[key];
} catch {
// jsdom getters may throw on access; the strategy does not need them.
}
}
}
globalThis.window = dom.window;
await import(pathToFileURL(spec.bundle).href);
const strategy = window.customStrategies?.["nestquest-party"];
if (!strategy) {
console.error("nestquest-party strategy is not registered on the bundle");
process.exit(1);
}
const lovelace = await strategy.generate(spec.config, spec.hass);
const payload = JSON.stringify({ views: lovelace.views });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
-86
View File
@@ -1,86 +0,0 @@
import json
from pathlib import Path
import runpy
import unittest
ROOT = Path(__file__).resolve().parents[1]
RENDER = runpy.run_path(str(ROOT / "scripts/render-cycle-prompt"))["render"]
TEMPLATE = (ROOT / "templates/ORCHESTRATOR-PROMPT.template.md").read_text()
PROFILE = json.loads((ROOT / "templates/nestquest.profile.json").read_text())
class CyclePromptTests(unittest.TestCase):
def test_project_profile_renders_without_specific_source_names(self):
other = PROFILE.copy()
other.update({
"PROJECT_NAME": "ExampleQuest",
"PROJECT_CONTEXT_LABEL": "EXAMPLEQUEST COORDINATION",
"WORK_TRACKER": "TaskGrid",
"CONTEXT_STORE": "ContextHub",
"INTEGRATION_BRANCH": "integration",
"RELEASE_BRANCH": "production",
"CONTROL_HOME": "/opt/examplequest/controller",
"SERVICE_NAME": "examplequest-controller.service",
"PROJECT_ROOT": "/srv/examplequest",
"TERMINAL_LAUNCHER": "eq-orchestrate",
"FEATURE_BRANCH_EXAMPLE": "feature/EQ-17",
})
output = RENDER(TEMPLATE, other)
self.assertIn("ExampleQuest Persistent Autonomous", output)
self.assertIn("TaskGrid", output)
self.assertIn("ContextHub", output)
self.assertIn("feature/EQ-17", output)
self.assertNotIn("NestQuest", output)
self.assertNotIn("nestquest", output)
self.assertNotIn("Maestro", output)
self.assertNotIn("CTXD", output)
self.assertNotIn("{{", output)
def test_missing_value_fails_before_publication(self):
other = PROFILE.copy()
del other["PROJECT_NAME"]
with self.assertRaisesRegex(ValueError, "PROJECT_NAME"):
RENDER(TEMPLATE, other)
def test_template_contains_only_terminal_runtime(self):
output = RENDER(TEMPLATE, PROFILE)
self.assertIn("# 27. FOREGROUND TERMINAL RUNTIME", output)
self.assertIn("nq-orchestrate", output)
self.assertNotIn("# 27. HOST CONTROLLER RUNTIME", output)
self.assertNotIn("The persistent user service runs", output)
self.assertNotIn("---\n\n---\n\n# 27", output)
def test_task_complete_feature_triggers_one_guarded_promotion(self):
output = RENDER(TEMPLATE, PROFILE)
self.assertIn(
"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)
self.assertIn("if it was closed without merge, recalculate", output)
self.assertIn("sole active orchestrator for the project", output)
self.assertIn("including controller exclusivity, reconciliation", output)
self.assertIn("RELEASE_PREPARATION_POLICY = EXCLUDED", output)
self.assertIn("Active promotion branch and pull request", output)
self.assertIn("An active promotion that has not reached `RELEASE_READY`", output)
self.assertIn("RELEASE_PREPARATION_POLICY:\n", output)
self.assertIn("A ready promotion is a local checkpoint", output)
self.assertIn("continue that work while the ready promotion waits", output)
self.assertLess(
output.index("Begin an automatically triggered, requested, or scheduled release"),
output.index("Advance to the next eligible feature"),
)
self.assertIn("Only Joshua may merge into `main`", output)
if __name__ == "__main__":
unittest.main()
@@ -1,88 +0,0 @@
from pathlib import Path
import contextlib
import io
import runpy
import sys
import tempfile
import unittest
from unittest.mock import patch
APPROVE = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-approve-merge"))
HEAD = "a" * 40
class MergeGateTests(unittest.TestCase):
def test_main_rejects_moved_head_unmergeable_and_pending_checks(self):
root = Path("/tmp/opencode")
root.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(dir=root) as directory:
review_file = Path(directory) / "review.txt"
review_file.write_text(f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n")
for problem in ("moved", "unmergeable", "pending", "none"):
with self.subTest(problem=problem):
merged = False
calls = []
def fake_request(path, token, payload=None):
nonlocal merged
calls.append((path, payload))
if path.endswith("/reviews"):
return [{"id": 1, "state": "APPROVED", "commit_id": HEAD,
"user": {"login": "review_agent"}}]
if path.endswith("/status"):
return {"total_count": 1, "state": "pending" if problem == "pending" else "success"}
if path.endswith("/merge"):
merged = True
return {}
if path.endswith("/pulls/149"):
return {"number": 149, "base": {"ref": "dev"},
"head": {"sha": "b" * 40 if problem == "moved" else HEAD},
"state": "open", "merged": merged,
"mergeable": problem != "unmergeable"}
raise AssertionError(path)
globals_ = APPROVE["main"].__globals__
with patch.dict(globals_, {"request": fake_request,
"token_from_file": lambda: "test"}), \
patch.object(sys, "argv", ["nq-approve-merge", "merge", "149", HEAD,
str(review_file)]), \
contextlib.redirect_stdout(io.StringIO()):
if problem == "none":
self.assertEqual(APPROVE["main"](), 0)
else:
with self.assertRaises(RuntimeError):
APPROVE["main"]()
self.assertEqual(merged, problem == "none")
if problem == "none":
merge_calls = [payload for path, payload in calls if path.endswith("/merge")]
self.assertEqual(merge_calls,
[{"do": "squash", "head_commit_id": HEAD}])
def test_review_evidence_must_match_exact_head_and_approval(self):
root = Path("/tmp/opencode")
root.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(dir=root) as directory:
review = Path(directory) / "review.txt"
review.write_text(f"Findings: none\nREVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n")
APPROVE["validate_review"](review, HEAD)
with self.assertRaisesRegex(RuntimeError, "exact head"):
APPROVE["validate_review"](review, "b" * 40)
review.write_text(f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: CHANGES_REQUIRED\n")
with self.assertRaisesRegex(RuntimeError, "exact head"):
APPROVE["validate_review"](review, HEAD)
def test_requires_distinct_gitea_reviewer_on_exact_commit(self):
records = [{"state": "APPROVED", "commit_id": HEAD, "user": {"login": "review_agent"}}]
with patch.dict(APPROVE["has_platform_review"].__globals__, {"request": lambda *_: records}):
self.assertTrue(APPROVE["has_platform_review"](149, HEAD, "unused"))
self.assertFalse(APPROVE["has_platform_review"](149, "b" * 40, "unused"))
records[0]["user"]["login"] = "orchestrator-agent"
self.assertFalse(APPROVE["has_platform_review"](149, HEAD, "unused"))
records[:] = [
{"id": 1, "state": "APPROVED", "commit_id": HEAD, "user": {"login": "review_agent"}},
{"id": 2, "state": "REQUEST_CHANGES", "commit_id": HEAD, "user": {"login": "review_agent"}},
]
self.assertFalse(APPROVE["has_platform_review"](149, HEAD, "unused"))
if __name__ == "__main__":
unittest.main()
-108
View File
@@ -1,108 +0,0 @@
import json
import io
from pathlib import Path
import runpy
import sqlite3
import sys
import tempfile
import unittest
from unittest.mock import patch
CONTROL = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-controller"))
class ControllerProtocolTests(unittest.TestCase):
def test_ctxd_preflight_reads_configured_endpoint_and_checks_identity(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
config = root / ".config/opencode/opencode.json"
config.parent.mkdir(parents=True)
config.write_text(json.dumps({"mcp": {"ctxd": {
"url": "http://localhost:9091/mcp",
"headers": {"Authorization": "Bearer test"},
}}}))
record = {"project_id": CONTROL["CTXD_PROJECT"],
"file_path": CONTROL["CTXD_FILE"],
"content": "# CONTROLLER_TICK", "version": 6}
with patch.object(Path, "home", return_value=root), \
patch.dict(CONTROL["check_ctxd"].__globals__["urllib"].request.__dict__,
{"urlopen": lambda request, timeout: io.BytesIO(json.dumps(record).encode())}):
self.assertEqual(CONTROL["check_ctxd"](), 6)
record["project_id"] = "wrong"
with self.assertRaisesRegex(RuntimeError, "wrong NestQuest prompt"):
CONTROL["check_ctxd"]()
record["project_id"] = CONTROL["CTXD_PROJECT"]
config.write_text(json.dumps({"mcp": {"ctxd": {
"url": "http://localhost:9091/mcp/",
"headers": {"Authorization": "Bearer test"},
}}}))
self.assertEqual(CONTROL["check_ctxd"](), 6)
config.write_text(json.dumps({"mcp": {"ctxd": {
"url": "http://localhost:9091/invalid",
"headers": {"Authorization": "Bearer test"},
}}}))
with self.assertRaisesRegex(RuntimeError, "must end in /mcp"):
CONTROL["check_ctxd"]()
def test_installed_opencode_text_event(self):
event = {"type": "text", "sessionID": "ses_test", "part": {"type": "text", "text": "Next task queued.\nORCHESTRATOR_STATE: CONTINUE\n"}}
self.assertEqual(CONTROL["parse_event"](json.dumps(event)), (event["part"]["text"], None))
self.assertEqual(CONTROL["STATE_RE"].findall(event["part"]["text"]), ["CONTINUE"])
def test_error_event_does_not_publish_provider_message(self):
event = {"type": "error", "error": {"name": "APIError", "data": {"message": "secret provider response"}}}
self.assertEqual(CONTROL["parse_event"](json.dumps(event)), (None, "APIError"))
self.assertEqual(CONTROL["parse_event"]("null"), (None, None))
self.assertEqual(CONTROL["parse_event"]("[]"), (None, None))
def test_tick_uses_final_text(self):
event = json.dumps({"type": "text", "part": {"type": "text", "text": "ORCHESTRATOR_STATE: QUIESCENT\n"}})
command = [sys.executable, "-c", f"import json,sys; sys.stdin.read(); print({event!r})"]
status, answer, error = CONTROL["run_tick"](command, {}, "tick", 10)
self.assertEqual((status, error), (0, None))
self.assertEqual(CONTROL["STATE_RE"].findall(answer), ["QUIESCENT"])
def test_timeout_kills_child_group_and_marks_tick_failed(self):
command = [sys.executable, "-c", "import sys,time; sys.stdin.read(); time.sleep(10)"]
status, answer, error = CONTROL["run_tick"](command, {}, "tick", 1)
self.assertEqual(status, 124)
self.assertEqual(answer, "")
self.assertIn("timeout", error)
def test_interactive_session_detects_dir_and_resumed_session(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
project = root / "project"
project.mkdir()
proc = root / "proc"
entry = proc / "123"
entry.mkdir(parents=True)
(entry / "cwd").symlink_to(root)
database = root / "opencode.db"
with sqlite3.connect(database) as db:
db.execute("CREATE TABLE session (id TEXT, directory TEXT)")
db.execute("INSERT INTO session VALUES (?, ?)", ("ses_test", str(project)))
(entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(project).encode() + b"\0")
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
(entry / "cmdline").write_bytes(b"opencode\0web\0--dir\0" + str(project).encode() + b"\0")
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
(entry / "cmdline").write_bytes(b"opencode\0serve\0")
(entry / "cwd").unlink()
(entry / "cwd").symlink_to(project)
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
(entry / "cwd").unlink()
(entry / "cwd").symlink_to(root)
(entry / "cmdline").write_bytes(b"opencode\0-s\0ses_test\0")
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
(entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(root).encode() + b"\0")
self.assertFalse(CONTROL["interactive_nestquest_session"](project, proc, database))
with sqlite3.connect(database) as db:
db.execute("DROP TABLE session")
(entry / "cmdline").write_bytes(b"opencode\0-s\0ses_test\0")
with self.assertRaisesRegex(RuntimeError, "lookup failed"):
CONTROL["interactive_nestquest_session"](project, proc, database)
if __name__ == "__main__":
unittest.main()
@@ -1,91 +0,0 @@
from pathlib import Path
import fcntl
import os
import subprocess
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / "scripts/nq-orchestrate"
class ForegroundLauncherTests(unittest.TestCase):
def test_launches_terminal_agent_with_rendered_and_user_prompt(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
control = root / "control"
scripts = control / "scripts"
agents = control / ".opencode/agents"
templates = control / "templates"
project = root / "project"
for path in (scripts, agents, templates, project):
path.mkdir(parents=True, exist_ok=True)
(templates / "nestquest.profile.json").write_text("{}")
(agents / "terminal-orchestrator.md").write_text("agent")
renderer = scripts / "render-cycle-prompt"
renderer.write_text("#!/bin/sh\nprintf 'BASE TERMINAL PROMPT'")
controller = scripts / "nq-controller"
controller_args = root / "controller-args"
open_marker = root / "open-session"
controller.write_text(
f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {controller_args}\n"
f"if test -f {open_marker}; then printf 'interactive NestQuest session open\\n'; "
"else printf 'no interactive NestQuest session\\n'; fi\n"
)
opencode = root / "opencode"
output = root / "args"
opencode.write_text(f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {output}\n")
systemctl = root / "systemctl"
stopped = root / "stopped"
systemctl.write_text(f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {stopped}\n")
for executable in (renderer, controller, opencode, systemctl):
executable.chmod(0o700)
mission = root / "mission.md"
mission.write_text("Complete feature EQ-17.")
env = os.environ.copy()
env.update({
"NQ_CONTROL_HOME": str(control),
"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)
self.assertEqual(printed.returncode, 0, printed.stderr)
self.assertEqual(printed.stdout, "BASE TERMINAL PROMPT\n")
self.assertFalse(stopped.exists())
result = subprocess.run([str(SCRIPT), str(mission)], env=env, text=True,
capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
args = output.read_text()
self.assertIn("--agent\nterminal-orchestrator", args)
self.assertIn("BASE TERMINAL PROMPT", args)
self.assertIn("# USER MISSION", args)
self.assertIn("Complete feature EQ-17.", args)
self.assertEqual(stopped.read_text().splitlines(),
["--user", "stop", "nestquest-controller.service"])
self.assertEqual(controller_args.read_text().splitlines(),
["--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)
self.assertEqual(refused.returncode, 75)
self.assertIn("Another interactive NestQuest", refused.stderr)
self.assertFalse(output.exists())
if __name__ == "__main__":
unittest.main()
-17
View File
@@ -1,17 +0,0 @@
from pathlib import Path
import runpy
import unittest
REVIEW = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-review"))
HEAD = "a" * 40
class ReviewLauncherTests(unittest.TestCase):
def test_exact_head_verdict(self):
self.assertEqual(REVIEW["verdict"](f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n", HEAD), "APPROVED")
with self.assertRaisesRegex(RuntimeError, "one verdict"):
REVIEW["verdict"](f"REVIEWED_HEAD: {'b' * 40}\nREVIEW_VERDICT: APPROVED\n", HEAD)
with self.assertRaisesRegex(RuntimeError, "one verdict"):
REVIEW["verdict"](f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\nREVIEW_VERDICT: CHANGES_REQUIRED\n", HEAD)
if __name__ == "__main__":
unittest.main()
+1 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.6.0"
version = "0.5.0"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
@@ -21,7 +21,6 @@ dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24",
"pyyaml>=6",
"unidecode>=1.4.0",
"uvicorn>=0.30",
"voluptuous>=0.14",
]
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 4 ]]; then
echo "usage: nq-agent developer|approver WORKTREE TITLE PROMPT_FILE" >&2
exit 64
fi
role=$1
worktree=$2
title=$3
prompt_file=$4
case "$role" in developer|approver) ;; *) echo "invalid role: $role" >&2; exit 64 ;; esac
[[ -d "$worktree" && -f "$prompt_file" ]] || { echo "missing worktree or prompt" >&2; exit 66; }
repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
export OPENCODE_CONFIG_DIR="$repo_dir/.opencode"
opencode_bin=${OPENCODE_BIN:-/home/overseer/.opencode/bin/opencode}
exec "$opencode_bin" run --pure --dir "$worktree" --agent "$role" --title "$title" --format json < "$prompt_file"
-131
View File
@@ -1,131 +0,0 @@
#!/usr/bin/env python3
"""Inspect or merge a reviewed NestQuest feature PR into dev only."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import re
import sys
import urllib.error
import urllib.request
API = "https://code.cubecraftlabs.com/api/v1/repos/Maniacs_Mansion/NestQuest"
CREDENTIALS = Path("/home/overseer/.llms/credentials.env")
FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
def token_from_file() -> str:
for line in CREDENTIALS.read_text().splitlines():
if line.startswith("GITEA_APPROVER_AGENT_TOKEN="):
return line.split("=", 1)[1].strip().strip("\"'")
raise RuntimeError("GITEA_APPROVER_AGENT_TOKEN is unavailable")
def request(path: str, token: str, payload: dict | None = None) -> dict | list:
body = None if payload is None else json.dumps(payload).encode()
headers = {"Authorization": "token " + token, "Accept": "application/json"}
if body is not None:
headers["Content-Type"] = "application/json"
call = urllib.request.Request(API + path, data=body, headers=headers)
try:
with urllib.request.urlopen(call, timeout=20) as response:
raw = response.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as error:
raise RuntimeError(f"Gitea returned HTTP {error.code}") from None
def inspect(pr_number: int, token: str) -> dict:
pr = request(f"/pulls/{pr_number}", token)
head = (pr.get("head") or {}).get("sha")
base = (pr.get("base") or {}).get("ref")
if not isinstance(head, str) or not FULL_SHA.fullmatch(head):
raise RuntimeError("Gitea returned an invalid PR head")
status = request(f"/commits/{head}/status", token)
return {
"number": pr.get("number"),
"base": base,
"head": head,
"state": pr.get("state"),
"merged": pr.get("merged"),
"mergeable": pr.get("mergeable"),
"check_count": status.get("total_count") or 0,
"check_state": status.get("state"),
}
def validate_review(path: Path, head: str) -> None:
if not path.is_file() or not path.resolve().is_relative_to(Path("/tmp/opencode").resolve()):
raise RuntimeError("review evidence must be a file under /tmp/opencode")
text = path.read_text(errors="replace")
reviewed = re.findall(r"(?m)^REVIEWED_HEAD:\s*([0-9a-f]{40})\s*$", text)
verdicts = re.findall(r"(?m)^REVIEW_VERDICT:\s*(APPROVED|CHANGES_REQUIRED)\s*$", text)
if len(reviewed) != 1 or len(verdicts) != 1 or reviewed[0] != head or verdicts[0] != "APPROVED":
raise RuntimeError("independent review does not approve this exact head")
def has_platform_review(pr_number: int, head: str, token: str) -> bool:
reviews = request(f"/pulls/{pr_number}/reviews", token)
if not isinstance(reviews, list):
raise RuntimeError("Gitea returned invalid review records")
by_reviewer = [
review for review in reviews
if isinstance(review, dict)
and (review.get("user") or {}).get("login") == "review_agent"
and review.get("commit_id") == head
]
if not by_reviewer:
return False
latest = max(by_reviewer, key=lambda review: int(review.get("id") or 0))
return latest.get("state") == "APPROVED"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("operation", choices=("inspect", "merge"))
parser.add_argument("pr", type=int)
parser.add_argument("reviewed_head", nargs="?")
parser.add_argument("review_file", nargs="?", type=Path)
args = parser.parse_args()
if args.pr < 1:
parser.error("PR number must be positive")
if args.operation == "merge" and (not args.reviewed_head or not args.review_file):
parser.error("merge requires reviewed_head and review_file")
token = token_from_file()
before = inspect(args.pr, token)
if args.operation == "inspect":
print(json.dumps(before, sort_keys=True))
return 0
if not FULL_SHA.fullmatch(args.reviewed_head):
parser.error("reviewed head must be a full SHA")
validate_review(args.review_file, args.reviewed_head)
if not has_platform_review(args.pr, args.reviewed_head, token):
raise RuntimeError("Gitea lacks an exact-head approval by review_agent")
if before["base"] != "dev" or before["head"] != args.reviewed_head:
raise RuntimeError("PR target or current head differs from the approved review")
if before["state"] != "open" or before["merged"] or before["mergeable"] is not True:
raise RuntimeError("PR is closed, merged, or not mergeable")
if before["check_count"] and before["check_state"] != "success":
raise RuntimeError("a configured commit check has not passed")
# Gitea rechecks branch protection at the merge endpoint. The caller must
# independently verify Maestro and task-level evidence before invoking us.
# Gitea checks head_commit_id atomically while processing the merge.
request(f"/pulls/{args.pr}/merge", token,
{"do": "squash", "head_commit_id": args.reviewed_head})
after = inspect(args.pr, token)
if after["merged"] is not True or after["head"] != args.reviewed_head:
raise RuntimeError("merge result is missing or differs from the reviewed head")
print(json.dumps(after, sort_keys=True))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, RuntimeError) as error:
print(f"approval gate failed: {error}", file=sys.stderr)
raise SystemExit(1)
-277
View File
@@ -1,277 +0,0 @@
#!/usr/bin/env python3
"""Durable, single-owner controller for one NestQuest OpenCode tick at a time."""
from __future__ import annotations
import argparse
import fcntl
import json
import os
from pathlib import Path
import re
import selectors
import signal
import sqlite3
import subprocess
import sys
import time
import urllib.request
DEFAULT_PROJECT = Path("/mnt/ai-storage/projects/NestQuest")
DEFAULT_STATE = Path.home() / ".local/state/nestquest-controller"
STATE_RE = re.compile(r"(?m)^ORCHESTRATOR_STATE: (CONTINUE|QUIESCENT|BLOCKED)\s*$")
MAX_TEXT = 64_000
MAX_LINE = 4_000_000
CTXD_PROJECT = "nestquest-house-chaos-coordination"
CTXD_FILE = "OPENCODE-ORCHESTRATOR-PROMPT.MD"
def check_ctxd() -> int:
"""Use OpenCode's configured endpoint, never an unrelated host DB."""
config = json.loads((Path.home() / ".config/opencode/opencode.json").read_text())
mcp = config["mcp"]["ctxd"]
configured_url = mcp["url"].rstrip("/")
if not configured_url.endswith("/mcp"):
raise RuntimeError("CTXD MCP URL must end in /mcp")
endpoint = configured_url.removesuffix("/mcp")
url = f"{endpoint}/projects/{CTXD_PROJECT}/files/{CTXD_FILE}"
with urllib.request.urlopen(
urllib.request.Request(url, headers=mcp["headers"]), timeout=8
) as response:
record = json.load(response)
if (
record.get("project_id") != CTXD_PROJECT
or record.get("file_path") != CTXD_FILE
or "CONTROLLER_TICK" not in record.get("content", "")
):
raise RuntimeError("CTXD returned the wrong NestQuest prompt")
return int(record["version"])
def interactive_nestquest_session(
project: Path,
proc_root: Path = Path("/proc"),
database: Path = Path.home() / ".local/share/opencode/opencode.db",
) -> bool:
"""Avoid racing a user-owned OpenCode TUI for the same repository."""
try:
db = sqlite3.connect(f"file:{database}?mode=ro", uri=True)
except sqlite3.Error as error:
raise RuntimeError("OpenCode session database is unavailable") from error
try:
for entry in proc_root.iterdir():
if not entry.name.isdecimal() or int(entry.name) == os.getpid():
continue
try:
args = (entry / "cmdline").read_bytes().split(b"\0")
args = [a.decode(errors="replace") for a in args if a]
if not args or Path(args[0]).name != "opencode":
continue
if len(args) > 1 and args[1] in ("run", "debug"):
continue
session = None
for flag in ("-s", "--session"):
if flag in args and args.index(flag) + 1 < len(args):
session = args[args.index(flag) + 1]
break
if session:
row = db.execute("SELECT directory FROM session WHERE id=?", (session,)).fetchone()
if row and Path(row[0]).resolve() == project.resolve():
return True
elif "--dir" in args and args.index("--dir") + 1 < len(args):
if Path(args[args.index("--dir") + 1]).resolve() == project.resolve():
return True
elif (entry / "cwd").resolve() == project.resolve():
return True
except sqlite3.Error as error:
raise RuntimeError("OpenCode session lookup failed") from error
except TypeError as error:
raise RuntimeError("OpenCode session directory is invalid") from error
except (OSError, PermissionError):
continue
finally:
db.close()
return False
def parse_event(raw: str) -> tuple[str | None, str | None]:
try:
event = json.loads(raw)
except json.JSONDecodeError:
return None, None
if not isinstance(event, dict):
return None, None
kind = event.get("type")
part = event.get("part") or {}
if kind == "text" and isinstance(part, dict):
return part.get("text"), None
if kind == "error":
error = event.get("error") or {}
known = {"ProviderAuthError", "UnknownError", "MessageOutputLengthError", "MessageAbortedError", "StructuredOutputError", "ContextOverflowError", "APIError"}
name = error.get("name") if isinstance(error, dict) else None
return None, name if name in known else "OpenCode error"
return None, None
def run_tick(command: list[str], env: dict[str, str], prompt: str, timeout: int) -> tuple[int, str, str | None]:
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
start_new_session=True,
)
assert process.stdin is not None and process.stdout is not None
try:
process.stdin.write(prompt.encode())
except BrokenPipeError:
pass
finally:
process.stdin.close()
selector = selectors.DefaultSelector()
selector.register(process.stdout, selectors.EVENT_READ)
deadline = time.monotonic() + timeout
buffer = b""
texts: list[str] = []
last_error: str | None = None
diagnostic_lines = 0
try:
while selector.get_map():
if time.monotonic() >= deadline:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
return 124, "".join(texts)[-MAX_TEXT:], "controller tick exceeded documented timeout"
for key, _ in selector.select(timeout=1):
chunk = os.read(key.fd, 65536)
if not chunk:
selector.unregister(key.fileobj)
continue
buffer += chunk
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
text, error = parse_event(line.decode(errors="replace"))
if not line.lstrip().startswith(b"{"):
diagnostic_lines += 1
if text:
texts.append(text)
if sum(map(len, texts)) > MAX_TEXT:
texts = ["".join(texts)[-MAX_TEXT:]]
if error:
last_error = error
if len(buffer) > MAX_LINE:
buffer = b"" # A tool-output event is not a controller verdict.
if buffer:
text, error = parse_event(buffer.decode(errors="replace"))
if text:
texts.append(text)
if error:
last_error = error
code = process.wait()
if code and last_error is None and diagnostic_lines:
last_error = f"OpenCode emitted {diagnostic_lines} non-JSON diagnostic lines"
return code, "".join(texts)[-MAX_TEXT:], last_error
finally:
selector.close()
process.stdout.close()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", type=Path, default=DEFAULT_PROJECT)
parser.add_argument("--state-dir", type=Path, default=DEFAULT_STATE)
parser.add_argument("--once", action="store_true", help="run only one model turn")
parser.add_argument("--check", action="store_true", help="validate paths without calling a model")
parser.add_argument("--check-interactive", action="store_true", help="report an open NestQuest TUI without calling a model")
parser.add_argument("--tick-timeout", type=int, default=6 * 3600)
args = parser.parse_args()
project = args.project.resolve()
control_home = Path(__file__).resolve().parent.parent
config_dir = control_home / ".opencode"
binary = Path(os.environ.get("OPENCODE_BIN", "/home/overseer/.opencode/bin/opencode"))
if not (project.is_dir() and config_dir.is_dir() and binary.is_file()):
parser.error("project, agent configuration, or OpenCode binary is missing")
if args.check:
try:
ctxd_version = check_ctxd()
except (OSError, KeyError, ValueError, RuntimeError) as error:
print(f"CTXD preflight failed: {type(error).__name__}", file=sys.stderr)
return 1
print(f"ready: project={project} configuration={config_dir} binary={binary} ctxd_prompt_version={ctxd_version}")
return 0
if args.check_interactive:
try:
active = interactive_nestquest_session(project)
except RuntimeError:
print("OpenCode session state unavailable", file=sys.stderr)
return 77
print("interactive NestQuest session open" if active else "no interactive NestQuest session")
return 0
args.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(args.state_dir, 0o700)
with (args.state_dir / "controller.lock").open("w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("another NestQuest controller is running", file=sys.stderr)
return 75
command = [str(binary), "run", "--pure", "--dir", str(project), "--agent", "orchestrator", "--format", "json", "--title", "NestQuest controller tick"]
env = os.environ.copy()
env["OPENCODE_CONFIG_DIR"] = str(config_dir)
env["NESTQUEST_CONTROL_HOME"] = str(control_home)
failures = 0
while True:
try:
interactive = interactive_nestquest_session(project)
except RuntimeError:
print("OpenCode session state unavailable; waiting", file=sys.stderr, flush=True)
if args.once:
return 77
time.sleep(60)
continue
if interactive:
print("interactive NestQuest session still open; waiting", flush=True)
if args.once:
return 75
time.sleep(60)
continue
prompt = (
"Run one NestQuest controller tick. Read CTXD and Maestro afresh, "
"reconcile Git and PR state, then execute the next eligible action. "
"Use NESTQUEST_CONTROL_HOME for role launch scripts. "
"Return a final ORCHESTRATOR_STATE line as your agent instructions require."
)
status, answer, error = run_tick(command, env, prompt, args.tick_timeout)
states = STATE_RE.findall(answer)
state = states[-1] if status == 0 and error is None and states else "ERROR"
if state == "CONTINUE":
failures = 0
elif state == "QUIESCENT":
failures = 0
elif state == "BLOCKED":
failures = 0
else:
failures += 1
summary = {"time": int(time.time()), "state": state, "exit_code": status, "error": error, "failure_count": failures, "answer_tail": answer[-4000:]}
target = args.state_dir / "last-run.json"
temporary = args.state_dir / "last-run.json.tmp"
temporary.write_text(json.dumps(summary, indent=2) + "\n")
os.chmod(temporary, 0o600)
temporary.replace(target)
print(f"tick state={state} exit={status} failures={failures}", flush=True)
if args.once:
return 0 if state in ("CONTINUE", "QUIESCENT") else 77
if state == "BLOCKED" or failures >= 3:
print("controller blocked; inspect last-run.json", file=sys.stderr)
return 77
time.sleep(300 if state == "QUIESCENT" else min(120, 10 * 2**failures))
if __name__ == "__main__":
raise SystemExit(main())
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env bash
# Launch the NestQuest orchestrator immediately in the foreground terminal.
set -euo pipefail
if [[ $# -gt 1 ]]; then
echo "usage: nq-orchestrate [MISSION_PROMPT_FILE|-|--print-prompt]" >&2
exit 64
fi
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"
for required in "$renderer" "$profile"; do
[[ -e "$required" ]] || { echo "missing required path: $required" >&2; exit 66; }
done
prompt=$("$renderer" "$profile")
if [[ ${1:-} == "--print-prompt" ]]; then
printf '%s\n' "$prompt"
exit 0
fi
for required in "$project" "$opencode_bin" "$control_home/.opencode/agents/terminal-orchestrator.md"; do
[[ -e "$required" ]] || { echo "missing required path: $required" >&2; exit 66; }
done
if [[ $# -eq 1 ]]; then
if [[ "$1" == "-" ]]; then
mission=$(cat)
else
[[ -f "$1" ]] || { echo "mission prompt not found: $1" >&2; exit 66; }
mission=$(cat "$1")
fi
[[ -n "${mission//[[:space:]]/}" ]] || { echo "mission prompt is empty" >&2; exit 65; }
prompt+=$'\n\n---\n\n# USER MISSION\n\n'
prompt+="$mission"
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
echo "Close it, then run nq-orchestrate again." >&2
exit 75
fi
export OPENCODE_CONFIG_DIR="$control_home/.opencode"
exec "$opencode_bin" "$project" --pure --agent terminal-orchestrator --prompt "$prompt"
-111
View File
@@ -1,111 +0,0 @@
#!/usr/bin/env python3
"""Run Claude on an exact PR head and publish an independent Gitea approval."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import urllib.error
import urllib.request
API = "https://code.cubecraftlabs.com/api/v1"
REPO = "/repos/Maniacs_Mansion/NestQuest"
CREDENTIALS = Path("/home/overseer/.llms/credentials.env")
CLAUDE = "/home/overseer/.local/bin/claude"
SHA = re.compile(r"^[0-9a-f]{40}$")
def git(worktree: Path, *args: str) -> str:
return subprocess.check_output(["git", "-C", str(worktree), *args], text=True).strip()
def token() -> str:
for line in CREDENTIALS.read_text().splitlines():
if line.startswith("GITEA_REVIEW_AGENT_TOKEN="):
return line.split("=", 1)[1].strip().strip("\"'")
raise RuntimeError("review_agent credential is unavailable")
def api(path: str, credential: str, payload: dict | None = None) -> dict:
body = None if payload is None else json.dumps(payload).encode()
headers = {"Authorization": "token " + credential, "Accept": "application/json"}
if body is not None:
headers["Content-Type"] = "application/json"
call = urllib.request.Request(API + path, data=body, headers=headers)
try:
with urllib.request.urlopen(call, timeout=20) as response:
return json.loads(response.read())
except urllib.error.HTTPError as error:
raise RuntimeError(f"Gitea returned HTTP {error.code}") from None
def verdict(output: str, head: str) -> str:
reviewed = re.findall(r"(?m)^REVIEWED_HEAD:\s*([0-9a-f]{40})\s*$", output)
decisions = re.findall(r"(?m)^REVIEW_VERDICT:\s*(APPROVED|CHANGES_REQUIRED)\s*$", output)
if reviewed != [head] or len(decisions) != 1:
raise RuntimeError("Claude did not return one verdict for the exact reviewed head")
return decisions[0]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("worktree", type=Path)
parser.add_argument("base_ref")
parser.add_argument("head_ref")
parser.add_argument("prompt_file", type=Path)
parser.add_argument("pr", type=int)
parser.add_argument("output_file", type=Path)
args = parser.parse_args()
if not args.worktree.is_dir() or not args.prompt_file.is_file() or args.pr < 1:
parser.error("valid worktree, prompt file, and PR number required")
if not args.output_file.resolve().is_relative_to(Path("/tmp/opencode").resolve()):
parser.error("review output must be under /tmp/opencode")
base = git(args.worktree, "rev-parse", "--verify", args.base_ref + "^{commit}")
head = git(args.worktree, "rev-parse", "--verify", args.head_ref + "^{commit}")
if not SHA.fullmatch(head) or git(args.worktree, "rev-parse", "HEAD") != head:
raise RuntimeError("worktree HEAD differs from requested head")
subprocess.run(["git", "-C", str(args.worktree), "merge-base", "--is-ancestor", base, head], check=True)
prompt = args.prompt_file.read_text() + (
f"\n\nReview only the delta from base {base} to exact head {head}. "
"Verify current HEAD before issuing a verdict. Read only; do not modify files. "
"End with two plain lines: REVIEWED_HEAD: <full 40-character SHA> "
"and REVIEW_VERDICT: APPROVED or REVIEW_VERDICT: CHANGES_REQUIRED."
)
result = subprocess.run(
[CLAUDE, "-p", prompt, "--model", "claude-sonnet-5", "--effort", "high",
"--permission-mode", "dontAsk", "--disallowedTools", "Edit", "Write",
"NotebookEdit", "--allowedTools", "Read", "Grep", "Glob",
"Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)", "Bash(git status:*)"],
cwd=args.worktree, text=True, capture_output=True, timeout=1200,
)
args.output_file.parent.mkdir(parents=True, exist_ok=True)
args.output_file.write_text(result.stdout)
os.chmod(args.output_file, 0o600)
if result.returncode:
raise RuntimeError(f"Claude exited {result.returncode}: {result.stderr[-300:]}")
decision = verdict(result.stdout, head)
if git(args.worktree, "rev-parse", "HEAD") != head:
raise RuntimeError("worktree HEAD changed during review")
print(result.stdout, end="")
if decision != "APPROVED":
return 2
credential = token()
if api("/user", credential).get("login") != "review_agent":
raise RuntimeError("review credential belongs to wrong account")
pr = api(f"{REPO}/pulls/{args.pr}", credential)
if pr.get("state") != "open" or (pr.get("head") or {}).get("sha") != head:
raise RuntimeError("PR head changed before review publication")
published = api(
f"{REPO}/pulls/{args.pr}/reviews", credential,
{"body": "Independent Claude Sonnet 5 review completed for this exact commit.",
"commit_id": head, "event": "APPROVED"},
)
if published.get("state") != "APPROVED" or published.get("commit_id") != head:
raise RuntimeError("Gitea did not confirm exact-head approval")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError,
subprocess.TimeoutExpired) as error:
print(f"review gate failed: {error}", file=sys.stderr)
raise SystemExit(1)
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""Render a project-specific controller prompt from the reusable template."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
import sys
TEMPLATE = Path(__file__).resolve().parent.parent / "templates/ORCHESTRATOR-PROMPT.template.md"
PLACEHOLDER = re.compile(r"\{\{([A-Z][A-Z0-9_]*)\}\}")
def render(template: str, profile: dict[str, str]) -> str:
keys = set(PLACEHOLDER.findall(template))
missing = sorted(keys - profile.keys())
if missing:
raise ValueError("missing profile values: " + ", ".join(missing))
for key in keys:
if not isinstance(profile[key], str) or not profile[key].strip():
raise ValueError(f"{key} must be a nonempty string")
result = PLACEHOLDER.sub(lambda match: profile[match.group(1)], template)
if PLACEHOLDER.search(result):
raise ValueError("profile values must not contain template placeholders")
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("profile", type=Path)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
profile = json.loads(args.profile.read_text())
if not isinstance(profile, dict):
parser.error("profile must be a JSON object")
content = render(TEMPLATE.read_text(), profile)
if args.output:
args.output.write_text(content)
print(args.output)
else:
sys.stdout.write(content)
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
-32
View File
@@ -1,32 +0,0 @@
# Reusing the development cycle prompt
`ORCHESTRATOR-PROMPT.template.md` is the current NestQuest controller
prompt with project-specific names, models, branches, identities, and launcher
paths replaced by placeholders. `nestquest.profile.json` is the working
example. Render a concrete prompt with:
```bash
scripts/render-cycle-prompt templates/nestquest.profile.json --output /tmp/opencode/nestquest-prompt.md
```
For another project, copy the profile and set its project name, tracker,
context store, models, branch names, release owner, example IDs, review
account, and installed launcher paths. Review the rendered result section by
section, especially task/feature semantics, release rules, permissions,
review gates, and the host runtime section. The template assumes an OpenCode
controller, a feature/task tracker, a versioned context store, a code host,
and a separate reviewer and approver. Adapt those sections when the project's
systems differ. Section 27 is a single-session foreground loop suitable for
`opencode --agent ... --prompt ...`; the reusable prompt has no service
runtime.
For NestQuest, `nq-orchestrate --print-prompt` emits the fully rendered
foreground prompt without starting OpenCode. `nq-orchestrate [mission.md]`
renders that same prompt, appends the optional mission, and supplies it to the
foreground orchestrator agent.
Publish the reviewed rendered prompt into that project's context store with
its version check.
Give the project its own agent definitions, broker, profile, and terminal
launcher. The template itself does not start an orchestrator or register
another project.

Some files were not shown because too many files have changed in this diff Show More