Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b037c7d675 | ||
|
|
4ac4d3f1ec |
@@ -0,0 +1,32 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
description: Start, stop, or inspect the NestQuest development cycle on explicit request
|
||||
mode: primary
|
||||
model: openrouter/deepseek/deepseek-v4.1-flash
|
||||
permission:
|
||||
edit: deny
|
||||
write: deny
|
||||
task: deny
|
||||
doom_loop: deny
|
||||
bash:
|
||||
"*": deny
|
||||
"/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start": allow
|
||||
"/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop": allow
|
||||
"/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status": allow
|
||||
external_directory:
|
||||
"*": ask
|
||||
"/home/overseer/.local/share/nestquest-controller/scripts/*": allow
|
||||
---
|
||||
|
||||
You control the NestQuest development service only when a user invokes a
|
||||
cycle-control slash command. Run the one exact `nq-cycle` command in that
|
||||
command's prompt, with no extra shell text. Report its result. If a start
|
||||
reports an open interactive NestQuest session, tell the user to close that
|
||||
session so the background controller can begin. Do not start development
|
||||
merely because you read a project instruction or status report.
|
||||
Do not recommend enabling the systemd unit. Manual `start` works without
|
||||
enablement; this cycle is intentionally started only on request.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
description: Show the NestQuest development cycle status
|
||||
agent: cycle-control
|
||||
---
|
||||
|
||||
Run exactly this one shell command:
|
||||
|
||||
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status`
|
||||
|
||||
Report whether the controller is active and its last tick state.
|
||||
Do not suggest enabling automatic startup.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
description: Start the NestQuest development cycle on this host
|
||||
agent: cycle-control
|
||||
---
|
||||
|
||||
Run exactly this one shell command:
|
||||
|
||||
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start`
|
||||
|
||||
Report its status. If it says an interactive NestQuest OpenCode session is
|
||||
open, tell me to close this session; the background controller will begin
|
||||
afterward. Do not launch a developer agent or a controller tick in this chat.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Stop the NestQuest development cycle on this host
|
||||
agent: cycle-control
|
||||
---
|
||||
|
||||
Run exactly this one shell command:
|
||||
|
||||
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop`
|
||||
|
||||
Report the service status. Do not stop any other OpenCode session or task.
|
||||
@@ -0,0 +1,74 @@
|
||||
# NestQuest controller
|
||||
|
||||
OpenCode 1.18.31 loads the three named agents from `.opencode/agents/`. Their
|
||||
models match the current NestQuest CTXD prompt: DeepSeek V4.1
|
||||
Flash for controller and approver, GLM 5.3 Flash for development. The
|
||||
controller runs one OpenCode turn at a time, then starts a fresh turn based on
|
||||
Maestro, CTXD, Git, and pull-request state. Its state file is
|
||||
`~/.local/state/nestquest-controller/last-run.json` (mode 0600). A lock allows
|
||||
only one controller process. It waits while an interactive OpenCode session is
|
||||
open for the NestQuest checkout.
|
||||
|
||||
`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 production service 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 all four OpenCode
|
||||
agents (orchestrator, developer, approver, cycle-control)
|
||||
with `opencode debug agent <name>` while `OPENCODE_CONFIG_DIR` points at the
|
||||
installed `.opencode` directory. Install `deploy/nestquest-controller.service`
|
||||
under `~/.config/systemd/user/` and reload the user manager. Its lack of an
|
||||
`[Install]` section makes it a manual-only unit; development starts only
|
||||
when requested. Copy `deploy/opencode-global-commands/*.md` into
|
||||
`~/.config/opencode/commands/` and `.opencode/agents/cycle-control.md` into
|
||||
`~/.config/opencode/agents/` for the commands to work from older branches.
|
||||
The host has lingering
|
||||
enabled for the `overseer` user. The service waits for an open interactive
|
||||
NestQuest session to close before starting work.
|
||||
|
||||
In NestQuest's OpenCode session, use `/start-development`,
|
||||
`/stop-development`, or `/development-status`. The matching global
|
||||
`/start-nestquest-development`, `/stop-nestquest-development`, and
|
||||
`/nestquest-development-status` commands work even while an older branch is
|
||||
checked out. Starting the unit in an interactive NestQuest session puts it
|
||||
in a waiting state; close that session to let the first controller tick run.
|
||||
The commands call `scripts/nq-cycle`; they do not run a developer inside
|
||||
the interactive chat.
|
||||
|
||||
`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.
|
||||
|
||||
A tick has an explicit six-hour timeout. On timeout or an OpenCode error, the
|
||||
next tick must reconcile external state before acting. Three consecutive
|
||||
failed ticks stop the service. A BLOCKED verdict also stops it. QUIESCENT
|
||||
causes a five-minute poll. Neither a feature merge nor a progress report
|
||||
stops the controller while eligible work remains.
|
||||
|
||||
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.
|
||||
@@ -1,19 +0,0 @@
|
||||
"""NestQuest API service.
|
||||
|
||||
A standalone FastAPI deployable that owns the NestQuest database and
|
||||
exposes it over HTTP to the Home Assistant integration (panel plane) and
|
||||
the admin PWA (admin plane). See Feature 16 (task cd03735b) in the
|
||||
project tracker for the full contract.
|
||||
|
||||
This package imports the NestQuest domain ``core`` WITHOUT executing
|
||||
``custom_components/nestquest/__init__.py`` — that parent package's
|
||||
``__init__.py`` imports ``homeassistant``, which is absent from the
|
||||
API's runtime. The coupling is encapsulated in
|
||||
:mod:`api.nestquest_core`, which loads ``core`` by file location with
|
||||
:mod:`importlib.util` and registers it under the distinct top-level name
|
||||
``nestquest_core`` (no ``sys.path`` mutation, no generic top-level
|
||||
names published). It is a TRANSITIONAL coupling that Feature 18 will
|
||||
revisit: once the integration becomes a thin API client and ``core``
|
||||
moves out of the integration bundle, this helper goes away.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
"""The NestQuest FastAPI application.
|
||||
|
||||
This module builds the ASGI app for the NestQuest API service (Feature
|
||||
16). The skeleton here wires the bundled ``core`` (Feature 15) and
|
||||
exposes the two endpoints every later task depends on:
|
||||
|
||||
- ``GET /health`` — returns ``200`` with a JSON status body once the
|
||||
app has imported core, opened the database, and applied migrations;
|
||||
a cheap ``SELECT 1`` confirms the connection is live without leaking
|
||||
the filesystem path on an unauthenticated route.
|
||||
- ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI);
|
||||
``/openapi.json`` is the raw schema. No extra wiring is needed;
|
||||
FastAPI serves both by default.
|
||||
|
||||
The database is opened and migrated in a FastAPI lifespan context so a
|
||||
startup failure (e.g. an unwritable path) surfaces as a startup error
|
||||
rather than the first request. The connection is closed on shutdown.
|
||||
|
||||
This module exports ONLY the :func:`create_app` factory — there is no
|
||||
module-level app instance, because building one at import time would
|
||||
read ``NESTQUEST_DB_PATH`` at import (and fail-fast on a missing value)
|
||||
before any worker is ready to report it. Serve with::
|
||||
|
||||
uvicorn --factory api.app:create_app
|
||||
|
||||
so the factory runs on each worker startup and reads the environment
|
||||
then.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from api.config import ApiConfig
|
||||
from api.database import DatabaseState, make_database
|
||||
|
||||
|
||||
def _build_lifespan(db_path: str):
|
||||
"""Return a lifespan that opens+migrates then closes one database.
|
||||
|
||||
Captured as a closure so the app factory can hand the configured
|
||||
path in without threading it through FastAPI's lifespan signature.
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
state = make_database(db_path)
|
||||
app.state.db = state
|
||||
try:
|
||||
await state.open_and_migrate()
|
||||
yield
|
||||
finally:
|
||||
await state.close()
|
||||
|
||||
return lifespan
|
||||
|
||||
|
||||
def create_app(config: ApiConfig | None = None) -> FastAPI:
|
||||
"""Build the NestQuest API FastAPI app.
|
||||
|
||||
``config`` defaults to one built from the process environment, so
|
||||
``uvicorn --factory api.app:create_app`` picks up
|
||||
``NESTQUEST_DB_PATH`` (and fails fast on startup if it is missing).
|
||||
Tests pass an explicit config so each test gets a hermetic temp
|
||||
SQLite file.
|
||||
"""
|
||||
cfg = config if config is not None else ApiConfig.from_env()
|
||||
|
||||
app = FastAPI(
|
||||
title="NestQuest API",
|
||||
description=(
|
||||
"Standalone HTTP service that owns the NestQuest database and "
|
||||
"exposes it to the Home Assistant integration (panel plane) and "
|
||||
"the admin PWA (admin plane)."
|
||||
),
|
||||
version="0.1.0",
|
||||
lifespan=_build_lifespan(cfg.db_path),
|
||||
)
|
||||
|
||||
@app.get("/health", summary="Service health")
|
||||
async def health() -> dict[str, str]:
|
||||
"""Return service health.
|
||||
|
||||
Returns ``200`` with ``{"status": "ok"}`` once the lifespan has
|
||||
opened and migrated the database. A cheap ``SELECT 1`` against
|
||||
the live connection confirms the database is reachable WITHOUT
|
||||
leaking the filesystem path on an unauthenticated route; if the
|
||||
connection is dead the query raises and the probe fails.
|
||||
"""
|
||||
state: DatabaseState = app.state.db
|
||||
await state.database.fetch_one("SELECT 1")
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
__all__ = ["create_app"]
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Configuration for the NestQuest API service.
|
||||
|
||||
The API reads its single configuration knob — the SQLite database path —
|
||||
from the ``NESTQUEST_DB_PATH`` environment variable. Unlike a dev
|
||||
convenience default, a missing or empty value is a configuration ERROR,
|
||||
not a silent fallback to a predictable world-writable temp path: the API
|
||||
owns the database (it is the writer of record per Feature 16's
|
||||
contract), and silently dropping data into ``$TMPDIR/nestquest.db``
|
||||
would lose it at reboot. ``ApiConfig.from_env`` therefore RAISES when
|
||||
the variable is unset or empty. This mirrors the integration's
|
||||
"explicit settings object" discipline (Feature 15's
|
||||
``core.settings``): the API never reads HA config; callers pass the
|
||||
resolved path into the database wiring.
|
||||
|
||||
Serving: the app is built with the factory ``api.app:create_app`` — run
|
||||
it with ``uvicorn --factory api.app:create_app`` so the factory reads
|
||||
the environment on each worker startup rather than at import time.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
#: Environment variable holding the SQLite file path. The API opens
|
||||
#: this file on startup (creating it if absent) and applies migrations.
|
||||
#: REQUIRED — unset or empty raises :class:`ConfigError` from
|
||||
#: :meth:`ApiConfig.from_env`.
|
||||
DB_PATH_ENV = "NESTQUEST_DB_PATH"
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Raised when required API configuration is missing or invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiConfig:
|
||||
"""Resolved API configuration.
|
||||
|
||||
Currently a single field — the SQLite path — but kept as a dataclass
|
||||
so later tasks (auth, SSE, settings persistence) can extend it
|
||||
without touching the call sites that read ``db_path``.
|
||||
"""
|
||||
|
||||
db_path: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig":
|
||||
"""Build configuration from the process environment.
|
||||
|
||||
``env`` defaults to ``os.environ``; tests pass an explicit dict
|
||||
so they are hermetic (no monkeypatch of the global environ).
|
||||
|
||||
Raises :class:`ConfigError` when ``NESTQUEST_DB_PATH`` is unset
|
||||
or empty/whitespace-only — there is no silent default, because a
|
||||
default in the temp directory would silently lose data at reboot.
|
||||
"""
|
||||
source = env if env is not None else os.environ
|
||||
raw = source.get(DB_PATH_ENV, "").strip()
|
||||
if not raw:
|
||||
raise ConfigError(
|
||||
f"{DB_PATH_ENV} must be set to a writable SQLite file path "
|
||||
f"(it is unset or empty — the API will not fall back to a "
|
||||
f"temp directory and silently lose data)"
|
||||
)
|
||||
return cls(db_path=raw)
|
||||
|
||||
|
||||
__all__ = ["ApiConfig", "ConfigError", "DB_PATH_ENV"]
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Database wiring for the NestQuest API service.
|
||||
|
||||
The API owns the database (it is the writer of record per Feature 16's
|
||||
contract). This module owns one application-lifetime
|
||||
:class:`core.db.NestQuestDatabase`: it opens the SQLite file at the
|
||||
configured path on startup, applies migrations (creating the schema on
|
||||
a fresh file), and closes the connection on shutdown.
|
||||
|
||||
The executor callable passed into ``NestQuestDatabase`` mirrors the
|
||||
contract of ``hass.async_add_executor_job``: schedule a synchronous
|
||||
callable on a worker and await its result. HA's own version lives on
|
||||
the event loop; the API is not HA, so it uses
|
||||
:func:`asyncio.to_thread` for the same effect — sqlite3 calls run on a
|
||||
worker thread and only plain data returns to the event loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from api.nestquest_core import core_db, core_migrations
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: The async SQLite wrapper. Re-exported so route handlers and later
|
||||
#: tasks reach it through the API package, not the bundled core.
|
||||
NestQuestDatabase = core_db.NestQuestDatabase
|
||||
|
||||
#: The migration runner. Re-exported for the same reason.
|
||||
apply_migrations = core_migrations.apply_migrations
|
||||
|
||||
|
||||
async def _executor(fn: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Run a synchronous callable on a worker thread and await its result.
|
||||
|
||||
Mirrors the contract of ``hass.async_add_executor_job`` so the
|
||||
bundled ``NestQuestDatabase`` (written against that contract) works
|
||||
unchanged in the API process: sqlite3 calls never block the event
|
||||
loop, and only plain Python data (row tuples,
|
||||
:class:`core.db.ExecutionResult`) crosses back.
|
||||
"""
|
||||
return await asyncio.to_thread(fn, *args, **kwargs)
|
||||
|
||||
|
||||
class DatabaseState:
|
||||
"""Holds the live database wrapper and its resolved path.
|
||||
|
||||
A small container so the lifespan and the route handlers share one
|
||||
authoritative reference; FastAPI's lifespan stores one of these on
|
||||
``app.state.db`` and the health route reads it back.
|
||||
"""
|
||||
|
||||
__slots__ = ("database", "db_path")
|
||||
|
||||
def __init__(self, database: NestQuestDatabase, db_path: str) -> None:
|
||||
self.database = database
|
||||
self.db_path = db_path
|
||||
|
||||
async def open_and_migrate(self) -> None:
|
||||
"""Open the connection and apply migrations to the latest version."""
|
||||
LOGGER.info("Opening NestQuest database at %s", self.db_path)
|
||||
await self.database.open(self.db_path)
|
||||
version = await apply_migrations(self.database)
|
||||
LOGGER.info("NestQuest schema at version %d", version)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the connection; safe to call even if open failed."""
|
||||
await self.database.close()
|
||||
|
||||
|
||||
def make_database(db_path: str) -> DatabaseState:
|
||||
"""Construct a :class:`DatabaseState` for the given path (not yet open)."""
|
||||
return DatabaseState(NestQuestDatabase(_executor), db_path)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DatabaseState",
|
||||
"NestQuestDatabase",
|
||||
"apply_migrations",
|
||||
"make_database",
|
||||
]
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Transitional coupling: import ``core`` without importing Home Assistant.
|
||||
|
||||
The NestQuest domain ``core`` package (Feature 15) is bundled INSIDE the
|
||||
Home Assistant integration at ``custom_components/nestquest/core/``.
|
||||
Importing it the obvious way — ``import custom_components.nestquest.core``
|
||||
— would execute ``custom_components/nestquest/__init__.py``, which
|
||||
imports ``homeassistant``. Home Assistant is NOT installed in the API
|
||||
service's runtime, so that import raises ``ModuleNotFoundError`` and the
|
||||
API cannot start.
|
||||
|
||||
``core`` itself is a clean, Home-Assistant-free package: its
|
||||
``__init__.py`` is a bare docstring and every internal import is
|
||||
relative (``from .db import ...``). It can therefore be imported by
|
||||
file location as a standalone package.
|
||||
|
||||
This module loads ``core`` with :func:`importlib.util.spec_from_file_location`
|
||||
— registering it under the distinct top-level name ``nestquest_core``
|
||||
with ``submodule_search_locations`` set to the bundled ``core``
|
||||
directory — so its relative imports resolve against that directory
|
||||
without touching ``sys.path``. No generic top-level name (``db``,
|
||||
``const``, ``schema``, ...) is published, so nothing here can silently
|
||||
shadow a future dependency, and the integration's HA-coupled
|
||||
``__init__.py`` is never executed: ``custom_components.nestquest`` is
|
||||
never imported.
|
||||
|
||||
Usage::
|
||||
|
||||
from api.nestquest_core import core_db, core_migrations
|
||||
# core_db.NestQuestDatabase, core_migrations.apply_migrations, ...
|
||||
|
||||
This is a TRANSITIONAL coupling. Feature 18 (task 430f7f98) converts
|
||||
the integration into a thin API client and moves ``core`` out of the
|
||||
integration bundle; once that lands, this bootstrap helper goes away and
|
||||
the API imports ``core`` as an ordinary installed dependency.
|
||||
|
||||
NOTE on the pytest process: the suite's ``conftest.py`` stubs the
|
||||
parent integration package and imports ``custom_components.nestquest.core.*``
|
||||
BEFORE any API test runs, so two copies of the core package coexist in
|
||||
the pytest process — the one registered here as ``nestquest_core.*``
|
||||
(loaded by file location) and the conftest-stubbed
|
||||
``custom_components.nestquest.core.*``. Later tasks must NOT compare
|
||||
class or exception identity across these two copies: a
|
||||
``nestquest_core.db.NestQuestDatabase`` is a DIFFERENT class object from
|
||||
``custom_components.nestquest.core.db.NestQuestDatabase`` even though
|
||||
they share source. Operate on a single copy within any one code path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
#: The repository root (this file is ``<root>/api/nestquest_core.py``).
|
||||
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: The bundled ``core`` package directory, inside the HA integration.
|
||||
_CORE_DIR: Path = _REPO_ROOT / "custom_components" / "nestquest" / "core"
|
||||
|
||||
#: The ``core`` package's ``__init__.py`` — a bare docstring, so loading
|
||||
#: it by file location executes no imports of its own.
|
||||
_CORE_INIT: Path = _CORE_DIR / "__init__.py"
|
||||
|
||||
#: The distinct top-level name under which the bundled ``core`` package
|
||||
#: is registered in ``sys.modules``. A distinctive name (rather than the
|
||||
#: generic ``core``) avoids publishing shadow-able top-level names and
|
||||
#: keeps the bundled copy separate from the conftest-stubbed
|
||||
#: ``custom_components.nestquest.core`` present in the pytest process.
|
||||
_CORE_MODULE_NAME = "nestquest_core"
|
||||
|
||||
|
||||
def _load_core() -> ModuleType:
|
||||
"""Load the bundled ``core`` package by file location and return it.
|
||||
|
||||
Registers the package under :data:`_CORE_MODULE_NAME` in
|
||||
``sys.modules`` with ``submodule_search_locations`` pointing at the
|
||||
bundled directory, so its relative imports (``from .db import ...``)
|
||||
resolve to ``nestquest_core.<submodule>`` against that directory
|
||||
without mutating ``sys.path``. Idempotent: a second call returns
|
||||
the already-registered module.
|
||||
"""
|
||||
existing = sys.modules.get(_CORE_MODULE_NAME)
|
||||
if existing is not None:
|
||||
return existing
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
_CORE_MODULE_NAME,
|
||||
_CORE_INIT,
|
||||
submodule_search_locations=[str(_CORE_DIR)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"could not load core from {_CORE_INIT}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[_CORE_MODULE_NAME] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
#: The bundled core package, loaded by file location and registered as
|
||||
#: ``nestquest_core`` in ``sys.modules``. Held here so callers import the
|
||||
#: submodules through it (``nestquest_core.db``, ``nestquest_core.migrations``)
|
||||
#: the same relative way the integration does.
|
||||
core: ModuleType = _load_core()
|
||||
|
||||
#: Re-exported for convenience; route handlers import these names from
|
||||
#: here rather than reaching into the core package directly.
|
||||
core_db = importlib.import_module(f"{_CORE_MODULE_NAME}.db")
|
||||
core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations")
|
||||
|
||||
__all__ = ["core", "core_db", "core_migrations"]
|
||||
@@ -496,7 +496,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass,
|
||||
entry_id=entry.entry_id,
|
||||
database=database,
|
||||
settings=settings,
|
||||
update_interval_seconds=entry.options.get(
|
||||
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
|
||||
),
|
||||
|
||||
@@ -11,15 +11,6 @@ refresh updates the whole board coherently.
|
||||
host clock and never UTC, matching the materialization and completion
|
||||
layers.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -36,11 +27,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .completion import derive_state
|
||||
from .const import (
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
@@ -48,21 +41,66 @@ from .const import (
|
||||
LOGGER,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
)
|
||||
from .core.db import NestQuestDatabase
|
||||
from .core.settings import NestQuestSettings
|
||||
from .core.snapshot import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestSnapshot,
|
||||
QuestInstanceView,
|
||||
build_snapshot,
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from .dao_rules import QuestDefinitionsDao
|
||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||
from .db import NestQuestDatabase
|
||||
from .presence import (
|
||||
MAX_PREVIEW_SCAN_DAYS,
|
||||
PresenceEngine,
|
||||
PresenceOverride,
|
||||
PresenceSchedule,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ChildDaySnapshot",
|
||||
"NestQuestCoordinator",
|
||||
"NestQuestSnapshot",
|
||||
"QuestInstanceView",
|
||||
]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestInstanceView:
|
||||
"""One instance plus everything the entities render from it."""
|
||||
|
||||
instance_id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
title: str
|
||||
icon: str | None
|
||||
window: str
|
||||
due_date: str
|
||||
due_time: str | None
|
||||
state: str # open | done | missed
|
||||
overdue: bool
|
||||
completed_at: str | None
|
||||
was_on_time: bool | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChildDaySnapshot:
|
||||
"""One active child's day: instances, presence, and rollup counts."""
|
||||
|
||||
child_id: int
|
||||
child_name: str
|
||||
instances: tuple[QuestInstanceView, ...]
|
||||
present: bool
|
||||
due_today: int
|
||||
completed_today: int
|
||||
remaining_today: int
|
||||
completion_pct: int
|
||||
#: For a child away today: the ISO date of their next present day
|
||||
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
||||
#: from it). ``None`` when the child is present today or no
|
||||
#: present day exists within the presence engine's scan cap.
|
||||
next_present: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NestQuestSnapshot:
|
||||
"""The coordinator's whole-entity data payload."""
|
||||
|
||||
today_iso: str
|
||||
children: tuple[ChildDaySnapshot, ...]
|
||||
cycle_day: int
|
||||
|
||||
|
||||
class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
@@ -83,7 +121,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
*,
|
||||
entry_id: str,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
update_interval_seconds: int | None = None,
|
||||
) -> None:
|
||||
interval = update_interval_seconds
|
||||
@@ -101,7 +138,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
)
|
||||
self.entry_id = entry_id
|
||||
self.database = database
|
||||
self.settings = settings
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
|
||||
async def async_refresh(self) -> None:
|
||||
@@ -117,6 +153,140 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
|
||||
async def _async_update_data(self) -> NestQuestSnapshot:
|
||||
now = self._local_now()
|
||||
return await build_snapshot(
|
||||
self.database, self.settings, now
|
||||
today = now.date()
|
||||
today_iso = today.isoformat()
|
||||
|
||||
children = await ChildrenDao(self.database).list_active()
|
||||
|
||||
schedules: dict[int, PresenceSchedule] = {}
|
||||
overrides: dict[int, list[PresenceOverride]] = {}
|
||||
for child in children:
|
||||
schedule = await PresenceSchedulesDao(self.database).get_by_child(
|
||||
child.id
|
||||
)
|
||||
if schedule is not None:
|
||||
schedules[child.id] = PresenceSchedule.decode(
|
||||
child.id, schedule.anchor_date, schedule.pattern
|
||||
)
|
||||
window_end = today + datetime.timedelta(
|
||||
days=MAX_PREVIEW_SCAN_DAYS
|
||||
)
|
||||
window_end_iso = window_end.isoformat()
|
||||
ranged = await PresenceOverridesDao(
|
||||
self.database
|
||||
).list_by_child_and_range(child.id, today_iso, window_end_iso)
|
||||
if ranged:
|
||||
# The engine validates MODEL objects, not DAO records;
|
||||
# each record converts losslessly (the schema guarantees
|
||||
# end >= start and a real is_present).
|
||||
overrides[child.id] = [
|
||||
PresenceOverride(
|
||||
record.child_id,
|
||||
record.start_date,
|
||||
record.end_date,
|
||||
record.is_present,
|
||||
record.note,
|
||||
)
|
||||
for record in ranged
|
||||
]
|
||||
engine = PresenceEngine(schedules, overrides)
|
||||
|
||||
instances_dao = QuestInstancesDao(self.database)
|
||||
events_dao = CompletionEventsDao(self.database)
|
||||
definitions_dao = QuestDefinitionsDao(self.database)
|
||||
|
||||
snapshots: list[ChildDaySnapshot] = []
|
||||
for child in children:
|
||||
instances = await instances_dao.list_by_date_range(
|
||||
child.id, today_iso, today_iso
|
||||
)
|
||||
views: list[QuestInstanceView] = []
|
||||
for instance in instances:
|
||||
latest = await events_dao.get_latest_for_instance(instance.id)
|
||||
definition = await definitions_dao.get(instance.definition_id)
|
||||
state = derive_state(instance, latest, today)
|
||||
overdue = False
|
||||
if state == "open" and instance.due_date == today_iso:
|
||||
if instance.due_time is not None:
|
||||
due_moment = datetime.datetime.strptime(
|
||||
instance.due_time, "%H:%M"
|
||||
).time()
|
||||
overdue = now.time() > due_moment
|
||||
views.append(
|
||||
QuestInstanceView(
|
||||
instance_id=instance.id,
|
||||
definition_id=instance.definition_id,
|
||||
child_id=instance.child_id,
|
||||
title=definition.title if definition else "Quest",
|
||||
icon=definition.icon if definition else None,
|
||||
window=instance.window,
|
||||
due_date=instance.due_date,
|
||||
due_time=instance.due_time,
|
||||
state=state,
|
||||
overdue=overdue,
|
||||
completed_at=(
|
||||
latest.occurred_at
|
||||
if latest is not None
|
||||
and latest.event_type == "completed"
|
||||
else None
|
||||
),
|
||||
was_on_time=(
|
||||
latest.was_on_time
|
||||
if latest is not None
|
||||
and latest.event_type == "completed"
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
due = len(views)
|
||||
completed = sum(1 for view in views if view.state == "done")
|
||||
remaining = due - completed
|
||||
child_present = engine.is_present(child.id, today)
|
||||
next_present: str | None = None
|
||||
if not child_present:
|
||||
try:
|
||||
next_present = engine.next_present_dates(
|
||||
child.id, today, 1
|
||||
)[0].isoformat()
|
||||
except ValueError:
|
||||
next_present = None
|
||||
snapshots.append(
|
||||
ChildDaySnapshot(
|
||||
child_id=child.id,
|
||||
child_name=child.display_name,
|
||||
instances=tuple(views),
|
||||
present=child_present,
|
||||
due_today=due,
|
||||
completed_today=completed,
|
||||
remaining_today=remaining,
|
||||
completion_pct=(
|
||||
round(completed * 100 / due) if due else 100
|
||||
),
|
||||
next_present=next_present,
|
||||
)
|
||||
)
|
||||
|
||||
return NestQuestSnapshot(
|
||||
today_iso=today_iso,
|
||||
children=tuple(snapshots),
|
||||
cycle_day=_cycle_day(schedules, children, today),
|
||||
)
|
||||
|
||||
|
||||
def _cycle_day(
|
||||
schedules: dict[int, PresenceSchedule], children, today: datetime.date
|
||||
) -> int:
|
||||
"""Return today's 1-based day in the first scheduled child's cycle.
|
||||
|
||||
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
|
||||
the schedule's own anchor, never ISO week numbers or parity, so a
|
||||
53-week year cannot silently invert the cycle.
|
||||
"""
|
||||
for child in children:
|
||||
schedule = schedules.get(child.id)
|
||||
if schedule is None:
|
||||
continue
|
||||
days = (today - schedule.anchor_date).days
|
||||
cycle_length = schedule.cycle_length_weeks * 7
|
||||
return (days % cycle_length) + 1
|
||||
return 0
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
"""Pure, Home-Assistant-free panel snapshot builder.
|
||||
|
||||
The household snapshot the NestQuest panel renders — active children in
|
||||
sort order, each child's today-instances in the documented payload shape
|
||||
(missed instances OMITTED from the entity-facing attribute payload, but
|
||||
retained on the snapshot for the count rollups), per-child counts and
|
||||
presence, and the custody cycle day — used to be assembled inline inside
|
||||
:mod:`custom_components.nestquest.coordinator`'s
|
||||
``_async_update_data``. That assembly is HA-coupled only because its
|
||||
host is a :class:`~homeassistant.helpers.update_coordinator.DataUpdateCoordinator`;
|
||||
the assembly itself touches no HA surface.
|
||||
|
||||
This module holds the pure pieces — the snapshot dataclasses
|
||||
(:class:`QuestInstanceView`, :class:`ChildDaySnapshot`,
|
||||
:class:`NestQuestSnapshot`), the cycle-day helper (:func:`_cycle_day`),
|
||||
the panel payload shaper (:func:`instance_payload`), and the async
|
||||
:func:`build_snapshot` builder — so the HA coordinator and the API
|
||||
service's panel snapshot route share ONE implementation of both the
|
||||
snapshot assembly and the entity-facing attribute payload shape.
|
||||
Nothing here imports :mod:`homeassistant`; the builder takes the
|
||||
database, the settings object, and the HA-local ``now`` (a single
|
||||
clock read the caller already resolved, from which it derives
|
||||
``today``) and returns the snapshot, mirroring exactly what the
|
||||
coordinator used to do inline.
|
||||
|
||||
``settings`` is accepted because the API route hands in the entry's
|
||||
:class:`~.settings.NestQuestSettings`; it is CURRENTLY UNUSED — the
|
||||
assembly reads nothing from it — and is retained so the builder's
|
||||
contract stays stable if a future panel feature derives scan caps or
|
||||
rollup behaviour from configuration. No speculative read is wired in.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .completion import derive_state
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_instances import CompletionEventsDao, QuestInstancesDao
|
||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||
from .dao_rules import QuestDefinitionsDao
|
||||
from .db import NestQuestDatabase
|
||||
from .presence import (
|
||||
MAX_PREVIEW_SCAN_DAYS,
|
||||
PresenceEngine,
|
||||
PresenceOverride,
|
||||
PresenceSchedule,
|
||||
)
|
||||
from .settings import NestQuestSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestInstanceView:
|
||||
"""One instance plus everything the entities render from it."""
|
||||
|
||||
instance_id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
title: str
|
||||
icon: str | None
|
||||
window: str
|
||||
due_date: str
|
||||
due_time: str | None
|
||||
state: str # open | done | missed
|
||||
overdue: bool
|
||||
completed_at: str | None
|
||||
was_on_time: bool | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChildDaySnapshot:
|
||||
"""One active child's day: instances, presence, and rollup counts."""
|
||||
|
||||
child_id: int
|
||||
child_name: str
|
||||
instances: tuple[QuestInstanceView, ...]
|
||||
present: bool
|
||||
due_today: int
|
||||
completed_today: int
|
||||
remaining_today: int
|
||||
completion_pct: int
|
||||
#: For a child away today: the ISO date of their next present day
|
||||
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
||||
#: from it). ``None`` when the child is present today or no
|
||||
#: present day exists within the presence engine's scan cap.
|
||||
next_present: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NestQuestSnapshot:
|
||||
"""The coordinator's whole-entity data payload."""
|
||||
|
||||
today_iso: str
|
||||
children: tuple[ChildDaySnapshot, ...]
|
||||
cycle_day: int
|
||||
|
||||
|
||||
def _cycle_day(
|
||||
schedules: dict[int, PresenceSchedule], children, today: datetime.date
|
||||
) -> int:
|
||||
"""Return today's 1-based day in the first scheduled child's cycle.
|
||||
|
||||
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
|
||||
the schedule's own anchor, never ISO week numbers or parity, so a
|
||||
53-week year cannot silently invert the cycle.
|
||||
"""
|
||||
for child in children:
|
||||
schedule = schedules.get(child.id)
|
||||
if schedule is None:
|
||||
continue
|
||||
days = (today - schedule.anchor_date).days
|
||||
cycle_length = schedule.cycle_length_weeks * 7
|
||||
return (days % cycle_length) + 1
|
||||
return 0
|
||||
|
||||
|
||||
def instance_payload(
|
||||
instances: Iterable[QuestInstanceView], *, include_missed: bool
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
|
||||
|
||||
The panel payload ``state`` is the documented ``open`` | ``completed``
|
||||
spelling, so the builder's derived ``done`` maps to ``completed`` here;
|
||||
``open`` passes through. ``missed`` instances are OMITTED from the
|
||||
entity-facing attribute payload (decision 5) unless ``include_missed``
|
||||
is set — the admin payload (D-009) keeps them with their ``missed``
|
||||
state. ``on_time`` passes the builder's ``was_on_time`` through
|
||||
verbatim (``True`` / ``False`` for a completed instance, ``None`` for
|
||||
an open one). This is the SINGLE shared implementation both the HA
|
||||
sensor layer and the API panel route read; it touches no Home
|
||||
Assistant surface.
|
||||
"""
|
||||
payload: list[dict[str, Any]] = []
|
||||
for view in instances:
|
||||
if view.state == "missed" and not include_missed:
|
||||
continue
|
||||
payload.append(
|
||||
{
|
||||
"id": view.instance_id,
|
||||
"definition_id": view.definition_id,
|
||||
"child_id": view.child_id,
|
||||
"title": view.title,
|
||||
"icon": view.icon,
|
||||
"window": view.window,
|
||||
"due_time": view.due_time,
|
||||
"state": (
|
||||
"completed" if view.state == "done" else view.state
|
||||
),
|
||||
"overdue": view.overdue,
|
||||
"completed_at": view.completed_at,
|
||||
"on_time": view.was_on_time,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def build_snapshot(
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
now: datetime.datetime,
|
||||
) -> NestQuestSnapshot:
|
||||
"""Build the household panel snapshot for ``today`` at the ``now`` instant.
|
||||
|
||||
Mirrors the assembly that lived in the coordinator's
|
||||
``_async_update_data``: active children in sort order, each child's
|
||||
today-instances with derived state (open / done / missed via
|
||||
:func:`~.completion.derive_state`) and the overdue flag, per-child
|
||||
presence (resolved through :class:`~.presence.PresenceEngine`), the
|
||||
per-child rollup counts, and the custody cycle day of the first
|
||||
scheduled active child. Missed instances are retained on the
|
||||
snapshot (the rollup counts and the panel's missed plate need them)
|
||||
but the entity-facing attribute payload omits them at the
|
||||
:func:`instance_payload` layer — this builder produces the shared
|
||||
shape both consumers read.
|
||||
|
||||
``now`` is the HA-local timezone-aware datetime the caller already
|
||||
resolved (a SINGLE clock read); the overdue check compares
|
||||
``now.time()`` against the instance's ``due_time`` for today's open
|
||||
instances. The builder performs NO internal clock read — it never
|
||||
calls :func:`datetime.datetime.now` — so the instant is pinned in
|
||||
tests. ``today`` is derived INSIDE the builder as ``now.date()``,
|
||||
so the calendar date that anchors the due-date and cycle-day
|
||||
arithmetic is always the SAME clock read as the wall-clock time;
|
||||
the two can never disagree. ``settings`` is accepted but currently
|
||||
unused (see the module docstring).
|
||||
"""
|
||||
today = now.date()
|
||||
today_iso = today.isoformat()
|
||||
|
||||
children = await ChildrenDao(database).list_active()
|
||||
|
||||
schedules: dict[int, PresenceSchedule] = {}
|
||||
overrides: dict[int, list[PresenceOverride]] = {}
|
||||
for child in children:
|
||||
schedule = await PresenceSchedulesDao(database).get_by_child(
|
||||
child.id
|
||||
)
|
||||
if schedule is not None:
|
||||
schedules[child.id] = PresenceSchedule.decode(
|
||||
child.id, schedule.anchor_date, schedule.pattern
|
||||
)
|
||||
window_end = today + datetime.timedelta(
|
||||
days=MAX_PREVIEW_SCAN_DAYS
|
||||
)
|
||||
window_end_iso = window_end.isoformat()
|
||||
ranged = await PresenceOverridesDao(
|
||||
database
|
||||
).list_by_child_and_range(child.id, today_iso, window_end_iso)
|
||||
if ranged:
|
||||
# The engine validates MODEL objects, not DAO records;
|
||||
# each record converts losslessly (the schema guarantees
|
||||
# end >= start and a real is_present).
|
||||
overrides[child.id] = [
|
||||
PresenceOverride(
|
||||
record.child_id,
|
||||
record.start_date,
|
||||
record.end_date,
|
||||
record.is_present,
|
||||
record.note,
|
||||
)
|
||||
for record in ranged
|
||||
]
|
||||
engine = PresenceEngine(schedules, overrides)
|
||||
|
||||
instances_dao = QuestInstancesDao(database)
|
||||
events_dao = CompletionEventsDao(database)
|
||||
definitions_dao = QuestDefinitionsDao(database)
|
||||
|
||||
snapshots: list[ChildDaySnapshot] = []
|
||||
for child in children:
|
||||
instances = await instances_dao.list_by_date_range(
|
||||
child.id, today_iso, today_iso
|
||||
)
|
||||
views: list[QuestInstanceView] = []
|
||||
for instance in instances:
|
||||
latest = await events_dao.get_latest_for_instance(instance.id)
|
||||
definition = await definitions_dao.get(instance.definition_id)
|
||||
state = derive_state(instance, latest, today)
|
||||
overdue = False
|
||||
if state == "open" and instance.due_date == today_iso:
|
||||
if instance.due_time is not None:
|
||||
due_moment = datetime.datetime.strptime(
|
||||
instance.due_time, "%H:%M"
|
||||
).time()
|
||||
overdue = now.time() > due_moment
|
||||
views.append(
|
||||
QuestInstanceView(
|
||||
instance_id=instance.id,
|
||||
definition_id=instance.definition_id,
|
||||
child_id=instance.child_id,
|
||||
title=definition.title if definition else "Quest",
|
||||
icon=definition.icon if definition else None,
|
||||
window=instance.window,
|
||||
due_date=instance.due_date,
|
||||
due_time=instance.due_time,
|
||||
state=state,
|
||||
overdue=overdue,
|
||||
completed_at=(
|
||||
latest.occurred_at
|
||||
if latest is not None
|
||||
and latest.event_type == "completed"
|
||||
else None
|
||||
),
|
||||
was_on_time=(
|
||||
latest.was_on_time
|
||||
if latest is not None
|
||||
and latest.event_type == "completed"
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
due = len(views)
|
||||
completed = sum(1 for view in views if view.state == "done")
|
||||
remaining = due - completed
|
||||
child_present = engine.is_present(child.id, today)
|
||||
next_present: str | None = None
|
||||
if not child_present:
|
||||
try:
|
||||
next_present = engine.next_present_dates(
|
||||
child.id, today, 1
|
||||
)[0].isoformat()
|
||||
except ValueError:
|
||||
next_present = None
|
||||
snapshots.append(
|
||||
ChildDaySnapshot(
|
||||
child_id=child.id,
|
||||
child_name=child.display_name,
|
||||
instances=tuple(views),
|
||||
present=child_present,
|
||||
due_today=due,
|
||||
completed_today=completed,
|
||||
remaining_today=remaining,
|
||||
completion_pct=(
|
||||
round(completed * 100 / due) if due else 100
|
||||
),
|
||||
next_present=next_present,
|
||||
)
|
||||
)
|
||||
|
||||
return NestQuestSnapshot(
|
||||
today_iso=today_iso,
|
||||
children=tuple(snapshots),
|
||||
cycle_day=_cycle_day(schedules, children, today),
|
||||
)
|
||||
@@ -39,7 +39,6 @@ from .coordinator import (
|
||||
NestQuestCoordinator,
|
||||
QuestInstanceView,
|
||||
)
|
||||
from .core.snapshot import instance_payload
|
||||
|
||||
#: Home Assistant's state machine refuses states longer than 255
|
||||
#: characters (its hard recorder/state limit). These sensors' states
|
||||
@@ -64,6 +63,40 @@ def _state_within_limit(state: Any) -> Any:
|
||||
return state
|
||||
|
||||
|
||||
def _instance_payload(
|
||||
instances: Iterable[QuestInstanceView], *, include_missed: bool
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
|
||||
|
||||
``state`` maps the coordinator's derived ``done`` to the panel's
|
||||
``completed`` spelling; ``missed`` instances are omitted unless
|
||||
``include_missed`` (the admin payload, D-009, keeps them with
|
||||
their ``missed`` state).
|
||||
"""
|
||||
payload: list[dict[str, Any]] = []
|
||||
for view in instances:
|
||||
if view.state == "missed" and not include_missed:
|
||||
continue
|
||||
payload.append(
|
||||
{
|
||||
"id": view.instance_id,
|
||||
"definition_id": view.definition_id,
|
||||
"child_id": view.child_id,
|
||||
"title": view.title,
|
||||
"icon": view.icon,
|
||||
"window": view.window,
|
||||
"due_time": view.due_time,
|
||||
"state": (
|
||||
"completed" if view.state == "done" else view.state
|
||||
),
|
||||
"overdue": view.overdue,
|
||||
"completed_at": view.completed_at,
|
||||
"on_time": view.was_on_time,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
#: State reported by the next-quest sensor when the child owes nothing
|
||||
#: else today. A documented sentinel, never an empty string (an empty
|
||||
#: state reads as "sensor went unavailable" in HA).
|
||||
@@ -182,10 +215,10 @@ class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor):
|
||||
if child is None:
|
||||
return None
|
||||
return {
|
||||
"instances": instance_payload(
|
||||
"instances": _instance_payload(
|
||||
child.instances, include_missed=False
|
||||
),
|
||||
"admin_instances": instance_payload(
|
||||
"admin_instances": _instance_payload(
|
||||
child.instances, include_missed=True
|
||||
),
|
||||
"child_id": child.child_id,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[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
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
description: Show the NestQuest development cycle status
|
||||
agent: cycle-control
|
||||
---
|
||||
|
||||
Run exactly this one shell command:
|
||||
|
||||
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status`
|
||||
|
||||
Report whether the controller is active and its last tick state.
|
||||
Do not suggest enabling automatic startup.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
description: Start the NestQuest development cycle on this host
|
||||
agent: cycle-control
|
||||
---
|
||||
|
||||
Run exactly this one shell command:
|
||||
|
||||
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start`
|
||||
|
||||
Report its status. If it says an interactive NestQuest OpenCode session is
|
||||
open, tell me to close this session; the background controller will begin
|
||||
afterward. Do not launch a developer agent or a controller tick in this chat.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Stop the NestQuest development cycle on this host
|
||||
agent: cycle-control
|
||||
---
|
||||
|
||||
Run exactly this one shell command:
|
||||
|
||||
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop`
|
||||
|
||||
Report the service status. Do not stop any other OpenCode session or task.
|
||||
@@ -0,0 +1,49 @@
|
||||
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",
|
||||
"START_COMMAND": "/start-examplequest-development",
|
||||
"STOP_COMMAND": "/stop-examplequest-development",
|
||||
"STATUS_COMMAND": "/examplequest-development-status",
|
||||
"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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,88 @@
|
||||
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()
|
||||
@@ -0,0 +1,108 @@
|
||||
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()
|
||||
@@ -0,0 +1,17 @@
|
||||
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()
|
||||
@@ -7,20 +7,11 @@ requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[dependency-groups]
|
||||
# fastapi and uvicorn live in the DEV group only so the monorepo's test
|
||||
# venv can exercise the API app (tests under tests/test_api_health.py).
|
||||
# They are NOT runtime dependencies of the NestQuest HA integration.
|
||||
# When the API deployable is packaged (Feature 17), its runtime
|
||||
# dependencies — fastapi, uvicorn, and any others — MUST be declared in
|
||||
# that deployable's own dependency manifest, not here.
|
||||
dev = [
|
||||
"fastapi>=0.115",
|
||||
"httpx>=0.28",
|
||||
"jinja2>=3",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pyyaml>=6",
|
||||
"uvicorn>=0.30",
|
||||
"voluptuous>=0.14",
|
||||
]
|
||||
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/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"
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/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)
|
||||
Executable
+277
@@ -0,0 +1,277 @@
|
||||
#!/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())
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Manually control the durable NestQuest development cycle from OpenCode.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: nq-cycle start|stop|status" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
unit=nestquest-controller.service
|
||||
controller=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller
|
||||
|
||||
show_status() {
|
||||
systemctl --user show "$unit" -p ActiveState -p SubState -p UnitFileState
|
||||
if [[ -f /home/overseer/.local/state/nestquest-controller/last-run.json ]]; then
|
||||
python3 -c 'import json,pathlib; p=pathlib.Path.home()/".local/state/nestquest-controller/last-run.json"; d=json.loads(p.read_text()); print("LastTickState="+str(d.get("state","unknown"))); print("LastTickTime="+str(d.get("time","unknown")))'
|
||||
fi
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
systemctl --user start "$unit"
|
||||
show_status
|
||||
if session_status=$("$controller" --check-interactive); then
|
||||
echo "$session_status"
|
||||
if [[ "$session_status" == "interactive NestQuest session open" ]]; then
|
||||
echo "Close the interactive NestQuest OpenCode session to let the controller run its first tick."
|
||||
fi
|
||||
else
|
||||
echo "The controller will wait for OpenCode session detection to recover."
|
||||
fi
|
||||
;;
|
||||
stop)
|
||||
systemctl --user stop "$unit"
|
||||
show_status
|
||||
;;
|
||||
status)
|
||||
show_status
|
||||
;;
|
||||
*)
|
||||
echo "usage: nq-cycle start|stop|status" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/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)
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,965 @@
|
||||
TYPE: PROJECT CONTEXT
|
||||
PROJECT: {{PROJECT_CONTEXT_LABEL}}
|
||||
STATUS: ACTIVE
|
||||
LAST-UPDATED: {{LAST_UPDATED}}
|
||||
TAGS:
|
||||
- {{PROJECT_CONTEXT_LABEL}}
|
||||
- CONTEXT
|
||||
--
|
||||
___
|
||||
|
||||
# {{PROJECT_NAME}} Persistent Autonomous Development Orchestrator
|
||||
|
||||
You are the primary autonomous engineering controller for the {{PROJECT_NAME}} {{WORK_TRACKER}} project.
|
||||
|
||||
You are not a one-shot planner, status reporter, or implementation agent.
|
||||
|
||||
You are a **persistent development-loop controller** responsible for continuously advancing the authorized project scope through:
|
||||
|
||||
**plan → implement → validate → review → remediate → re-review → merge → verify → advance**
|
||||
|
||||
until a legitimate GLOBAL TERMINAL CONDITION is reached.
|
||||
|
||||
---
|
||||
|
||||
# 1. PRIME DIRECTIVE
|
||||
|
||||
Once execution begins, continue performing useful engineering actions without requiring additional user prompts.
|
||||
|
||||
## Non-negotiable continuation rules
|
||||
|
||||
1. **Completing one action is a state transition, not permission to stop.**
|
||||
2. After EVERY tool result, subagent result, review result, merge result, CI result, or repository-changing event, immediately execute the `CONTROLLER_TICK` defined below.
|
||||
3. If an executable `NEXT_ACTION` exists, perform it before producing a final user-facing response.
|
||||
4. A nonterminal state MUST lead to another tool call, agent dispatch, review, merge operation, verification action, or other executable action.
|
||||
5. Do not end execution merely because:
|
||||
- A developer finished.
|
||||
- A pull request was opened.
|
||||
- A review finished.
|
||||
- Corrections were pushed.
|
||||
- A task was merged.
|
||||
- A feature was merged.
|
||||
- CI was started.
|
||||
- Another agent was dispatched.
|
||||
- One work item became blocked.
|
||||
6. Statements such as:
|
||||
- "The agent has been dispatched."
|
||||
- "The review is complete."
|
||||
- "The next step is..."
|
||||
- "I will now..."
|
||||
- "Waiting for..."
|
||||
|
||||
are NOT terminal outcomes.
|
||||
|
||||
If the stated next step can be performed with available tools, PERFORM IT.
|
||||
|
||||
7. A progress message never substitutes for the next required action.
|
||||
8. Never wait for the user merely to authorize routine continuation already permitted by this prompt.
|
||||
|
||||
---
|
||||
|
||||
# 2. EXECUTION CONFIGURATION
|
||||
|
||||
Use these exact roles.
|
||||
|
||||
## Orchestrator
|
||||
|
||||
- Harness: OpenCode
|
||||
- Provider: {{MODEL_PROVIDER}}
|
||||
- Model: `{{CONTROLLER_MODEL}}`
|
||||
- Role: Persistent controller and state-machine owner
|
||||
|
||||
The orchestrator does NOT implement application code.
|
||||
|
||||
## Development Agent
|
||||
|
||||
- Harness: OpenCode
|
||||
- Provider: {{MODEL_PROVIDER}}
|
||||
- Model: `{{DEVELOPER_MODEL}}`
|
||||
- Maximum concurrent development agents: {{MAX_DEVELOPERS}}
|
||||
|
||||
Development agents implement and remediate code.
|
||||
|
||||
## Independent Reviewer
|
||||
|
||||
- Harness: {{REVIEWER_HARNESS}}
|
||||
- Model: `{{REVIEWER_MODEL}}`
|
||||
- Reasoning effort: `high`
|
||||
- Fresh independent session for every required review
|
||||
- Read-only
|
||||
|
||||
Invocation:
|
||||
|
||||
```bash
|
||||
{{REVIEWER_CLI}} -p "<review prompt>" \
|
||||
--model {{REVIEWER_MODEL}} \
|
||||
--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:*)"
|
||||
```
|
||||
|
||||
The reviewer reviews code and test evidence but does not execute arbitrary test commands.
|
||||
|
||||
## Approver
|
||||
|
||||
- Harness: OpenCode
|
||||
- Provider: {{MODEL_PROVIDER}}
|
||||
- Model: `{{CONTROLLER_MODEL}}`
|
||||
- MUST use a separate agent session from both orchestrator and developer
|
||||
- Merge-control role only
|
||||
|
||||
Do not silently substitute models, harnesses, or roles.
|
||||
|
||||
---
|
||||
|
||||
# 3. SOURCES OF TRUTH
|
||||
|
||||
Use, in order:
|
||||
|
||||
1. {{WORK_TRACKER}}:
|
||||
- Feature scope
|
||||
- Tasks
|
||||
- Dependencies
|
||||
- Acceptance criteria
|
||||
- Status
|
||||
- Findings
|
||||
- Branches
|
||||
- Pull requests
|
||||
2. {{CONTEXT_STORE}} critical project context.
|
||||
3. Repository instructions and architecture.
|
||||
4. CI configuration and branch protections.
|
||||
5. This orchestration policy.
|
||||
|
||||
Repository security and branch protections always take precedence.
|
||||
|
||||
---
|
||||
|
||||
# 4. RUN SCOPE
|
||||
|
||||
Determine `MISSION_SCOPE` from the user's launch request.
|
||||
|
||||
Possible scopes:
|
||||
|
||||
- `SINGLE_TASK`
|
||||
- `SINGLE_FEATURE`
|
||||
- `PROJECT_QUEUE`
|
||||
- `RELEASE_PREPARATION`
|
||||
|
||||
If the user explicitly names one task or feature, respect that scope.
|
||||
|
||||
If the user launches the orchestrator for general/autonomous {{PROJECT_NAME}} development without limiting it to one feature, use:
|
||||
|
||||
`MISSION_SCOPE = PROJECT_QUEUE`
|
||||
|
||||
IMPORTANT:
|
||||
|
||||
**Feature completion is not inherently a terminal condition.**
|
||||
|
||||
When `MISSION_SCOPE = PROJECT_QUEUE`, completing a feature causes an immediate transition to selecting the next eligible project feature.
|
||||
|
||||
---
|
||||
|
||||
# 5. DURABLE CONTROLLER STATE
|
||||
|
||||
Maintain a durable orchestration checkpoint using {{WORK_TRACKER}} wherever its schema permits.
|
||||
|
||||
Track at minimum:
|
||||
|
||||
- `MISSION_SCOPE`
|
||||
- Current feature
|
||||
- Current controller phase
|
||||
- Active development agents
|
||||
- Active task IDs
|
||||
- Task branches
|
||||
- Pull requests
|
||||
- Current head SHAs
|
||||
- Pending reviews
|
||||
- Reviewed SHAs
|
||||
- Pending remediation
|
||||
- Merge-ready items
|
||||
- Blocked items
|
||||
- Tests/checks status
|
||||
- Last verified repository state
|
||||
- `NEXT_ACTION`
|
||||
|
||||
After every material state transition:
|
||||
|
||||
1. Update {{WORK_TRACKER}}.
|
||||
2. Set `NEXT_ACTION`.
|
||||
3. Continue execution.
|
||||
|
||||
`NEXT_ACTION` must be concrete and executable.
|
||||
|
||||
Good:
|
||||
|
||||
`Dispatch developer for {{TASK_ID_EXAMPLE}} from {{FEATURE_BRANCH_EXAMPLE}}.`
|
||||
|
||||
Bad:
|
||||
|
||||
`Continue development.`
|
||||
|
||||
---
|
||||
|
||||
# 6. CONTROLLER_TICK
|
||||
|
||||
Run this algorithm after initialization and after EVERY completed action.
|
||||
|
||||
## CONTROLLER_TICK
|
||||
|
||||
### A. RECONCILE
|
||||
|
||||
Inspect the actual current state of:
|
||||
|
||||
- {{WORK_TRACKER}}
|
||||
- Git repository
|
||||
- Relevant branches
|
||||
- Pull requests
|
||||
- Current head commits
|
||||
- CI/check status
|
||||
- Active child agents
|
||||
- Review results
|
||||
- Merge status
|
||||
|
||||
Never assume a previous action succeeded.
|
||||
|
||||
### B. INGEST RESULTS
|
||||
|
||||
Process every newly completed:
|
||||
|
||||
- Development result
|
||||
- Review
|
||||
- Remediation
|
||||
- CI/check result
|
||||
- Merge
|
||||
- Approver result
|
||||
|
||||
Update the durable state.
|
||||
|
||||
### C. IDENTIFY NEXT TRANSITION
|
||||
|
||||
Choose the highest-priority actionable transition in this order:
|
||||
|
||||
1. Resolve a failed mandatory engineering gate.
|
||||
2. Process `CHANGES_REQUIRED`.
|
||||
3. Re-review remediated current heads.
|
||||
4. Merge task PRs whose gates are fully satisfied.
|
||||
5. Dispatch reviews for review-ready tasks.
|
||||
6. Fill available development slots with eligible tasks.
|
||||
7. Begin feature integration when all required tasks are merged.
|
||||
8. Remediate feature-level review findings.
|
||||
9. Obtain feature approval.
|
||||
10. Merge an approved feature into `{{INTEGRATION_BRANCH}}`.
|
||||
11. Advance to the next eligible feature when mission scope permits.
|
||||
12. Begin a requested/scheduled release when appropriate.
|
||||
13. Continue unrelated work around locally blocked items.
|
||||
14. Escalate only when the defined escalation rules require it.
|
||||
|
||||
### D. EXECUTE
|
||||
|
||||
Execute the selected action NOW.
|
||||
|
||||
Do not merely describe it.
|
||||
|
||||
### E. REPEAT
|
||||
|
||||
When the action returns, immediately begin another `CONTROLLER_TICK`.
|
||||
|
||||
---
|
||||
|
||||
# 7. DEVELOPMENT SLOT SCHEDULER
|
||||
|
||||
Maximum concurrent development agents: **{{MAX_DEVELOPERS}}**.
|
||||
|
||||
Keep development capacity utilized when safe.
|
||||
|
||||
Do NOT use batch barriers.
|
||||
|
||||
Example:
|
||||
|
||||
If Developer A finishes while Developer B is still working:
|
||||
|
||||
1. Process Developer A's result immediately.
|
||||
2. Start review/remediation/merge work for A as appropriate.
|
||||
3. If another eligible task exists and a developer slot is free, fill it.
|
||||
4. Do not wait unnecessarily for Developer B.
|
||||
|
||||
Reviewers and approvers do not consume developer slots.
|
||||
|
||||
Avoid parallel development of tightly coupled tasks when overlapping modifications would create excessive merge conflict risk.
|
||||
|
||||
---
|
||||
|
||||
# 8. FEATURE INITIALIZATION
|
||||
|
||||
For a feature:
|
||||
|
||||
1. Fetch the latest validated `{{INTEGRATION_BRANCH}}`.
|
||||
2. Inspect the feature and existing {{WORK_TRACKER}} tasks.
|
||||
3. Determine acceptance criteria.
|
||||
4. Determine dependencies.
|
||||
5. Identify missing implementation, testing, migration, documentation, observability, rollout, or rollback work.
|
||||
6. Create genuinely missing {{WORK_TRACKER}} tasks.
|
||||
7. Construct the dependency graph.
|
||||
8. Create exactly one feature branch from validated `{{INTEGRATION_BRANCH}}`.
|
||||
9. Publish it.
|
||||
10. Record the source commit and feature branch.
|
||||
11. Populate the ready-task queue.
|
||||
12. Continue immediately into the task loop.
|
||||
|
||||
Do not repeatedly reconstruct the entire dependency graph when nothing affecting dependencies has changed.
|
||||
|
||||
Update it incrementally after:
|
||||
|
||||
- Task completion
|
||||
- Newly discovered work
|
||||
- Dependency changes
|
||||
- Blockers
|
||||
- Scope changes
|
||||
|
||||
---
|
||||
|
||||
# 9. TASK STATE MACHINE
|
||||
|
||||
Each task follows:
|
||||
|
||||
`PLANNED`
|
||||
→ `READY`
|
||||
→ `IN_PROGRESS`
|
||||
→ `PR_OPEN`
|
||||
→ `PRE_REVIEW_GATE`
|
||||
→ `REVIEW_PENDING`
|
||||
→ `APPROVED`
|
||||
→ `MERGE_READY`
|
||||
→ `MERGED`
|
||||
|
||||
Remediation path:
|
||||
|
||||
`REVIEW_PENDING`
|
||||
→ `CHANGES_REQUIRED`
|
||||
→ `REMEDIATION`
|
||||
→ `PRE_REVIEW_GATE`
|
||||
→ `REVIEW_PENDING`
|
||||
|
||||
Blocked path:
|
||||
|
||||
`ANY_STATE`
|
||||
→ `LOCALLY_BLOCKED`
|
||||
|
||||
`LOCALLY_BLOCKED` is NOT automatically a global terminal state.
|
||||
|
||||
---
|
||||
|
||||
# 10. DEVELOPMENT AGENT CONTRACT
|
||||
|
||||
Every development-agent assignment must include:
|
||||
|
||||
- {{WORK_TRACKER}} task ID
|
||||
- Purpose
|
||||
- Exact scope
|
||||
- Acceptance criteria
|
||||
- Base branch
|
||||
- Assigned task branch
|
||||
- Required tests/checks
|
||||
- Relevant repository instructions
|
||||
- Known review findings when performing remediation
|
||||
- Explicit instruction not to expand scope unnecessarily
|
||||
|
||||
The agent must:
|
||||
|
||||
1. Work only in its isolated task worktree.
|
||||
2. Implement the assigned task.
|
||||
3. Add/update appropriate tests.
|
||||
4. Run relevant local checks.
|
||||
5. Self-review the diff.
|
||||
6. Remove debug artifacts and unrelated changes.
|
||||
7. Commit the work.
|
||||
8. Push its task branch.
|
||||
9. Open/update the task PR against the feature branch.
|
||||
10. Return structured results.
|
||||
|
||||
Required return format:
|
||||
|
||||
```text
|
||||
STATUS: READY_FOR_REVIEW | BLOCKED | FAILED
|
||||
TASK_ID:
|
||||
BRANCH:
|
||||
HEAD_SHA:
|
||||
PR:
|
||||
FILES_CHANGED:
|
||||
ACCEPTANCE_CRITERIA:
|
||||
- criterion: PASS | FAIL
|
||||
TESTS:
|
||||
- command:
|
||||
result:
|
||||
CHECKS:
|
||||
BLOCKER:
|
||||
NOTES:
|
||||
```
|
||||
|
||||
The orchestrator must independently verify important repository/PR state.
|
||||
|
||||
---
|
||||
|
||||
# 11. PRE-REVIEW GATE
|
||||
|
||||
Do NOT spend an independent {{REVIEWER_VENDOR}} review on obviously unready code.
|
||||
|
||||
Before dispatching {{REVIEWER_VENDOR}}, verify:
|
||||
|
||||
- PR exists.
|
||||
- Expected head SHA exists.
|
||||
- Required local checks passed.
|
||||
- Required tests passed.
|
||||
- Build/lint/type/security checks required at this stage passed.
|
||||
- No unresolved merge conflict exists.
|
||||
- Acceptance criteria appear implemented.
|
||||
- No obvious debug/generated/unrelated files exist.
|
||||
|
||||
If a mandatory check fails:
|
||||
|
||||
Return the task to the development agent BEFORE independent review.
|
||||
|
||||
---
|
||||
|
||||
# 12. INDEPENDENT REVIEW CONTRACT
|
||||
|
||||
For task review, compare:
|
||||
|
||||
`task head → current feature-branch base`
|
||||
|
||||
For feature review, compare:
|
||||
|
||||
`feature head → current {{INTEGRATION_BRANCH}} base`
|
||||
|
||||
For release review, compare:
|
||||
|
||||
`release head → {{RELEASE_BRANCH}}`
|
||||
|
||||
Every review must state the exact reviewed head SHA.
|
||||
|
||||
Review:
|
||||
|
||||
- Correctness
|
||||
- Acceptance criteria
|
||||
- Test adequacy
|
||||
- Security/privacy
|
||||
- Data integrity
|
||||
- Error handling
|
||||
- Concurrency
|
||||
- Compatibility/regressions
|
||||
- Performance risk
|
||||
- Maintainability
|
||||
- Migration correctness
|
||||
- Documentation
|
||||
- Rollout/rollback
|
||||
|
||||
The reviewer is read-only.
|
||||
|
||||
The reviewer may inspect tests and supplied check evidence but is NOT expected to execute non-Git test/build commands with the restricted tool configuration.
|
||||
|
||||
## Reviewer result semantics
|
||||
|
||||
Return exactly one:
|
||||
|
||||
### `APPROVED`
|
||||
|
||||
Use when:
|
||||
|
||||
- No High findings exist.
|
||||
- No mandatory engineering gate is demonstrably violated.
|
||||
- Medium/Low findings may still exist.
|
||||
|
||||
### `CHANGES_REQUIRED`
|
||||
|
||||
Use only when:
|
||||
|
||||
- At least one High finding exists; OR
|
||||
- Acceptance criteria are not satisfied; OR
|
||||
- A mandatory engineering gate is violated.
|
||||
|
||||
### `BLOCKED`
|
||||
|
||||
Use only when the reviewer cannot reach a reliable conclusion because necessary code, repository state, evidence, or access is unavailable.
|
||||
|
||||
Every finding must include:
|
||||
|
||||
```text
|
||||
ID:
|
||||
SEVERITY: HIGH | MEDIUM | LOW
|
||||
FILE:
|
||||
LOCATION:
|
||||
EXPLANATION:
|
||||
IMPACT:
|
||||
EVIDENCE:
|
||||
RECOMMENDED_CORRECTION:
|
||||
```
|
||||
|
||||
Any new implementation commit invalidates the previous approval.
|
||||
|
||||
---
|
||||
|
||||
# 13. FINDING POLICY
|
||||
|
||||
## High
|
||||
|
||||
Must be corrected and independently re-reviewed.
|
||||
|
||||
Examples:
|
||||
|
||||
- Incorrect required behavior
|
||||
- Security/privacy exposure
|
||||
- Data corruption/loss
|
||||
- Major regression
|
||||
- Broken compatibility
|
||||
- Missing acceptance criterion
|
||||
|
||||
## Medium
|
||||
|
||||
Does not automatically block.
|
||||
|
||||
Orchestrator must either:
|
||||
|
||||
- Have it fixed; OR
|
||||
- Record a technical disposition explaining why deferral is acceptable.
|
||||
|
||||
## Low
|
||||
|
||||
May be deferred.
|
||||
|
||||
Record actionable low findings in {{WORK_TRACKER}} technical debt where appropriate.
|
||||
|
||||
Do not lower severity merely to enable a merge.
|
||||
|
||||
---
|
||||
|
||||
# 14. TASK MERGE GATE
|
||||
|
||||
A task PR may merge only when:
|
||||
|
||||
- Current head matches the approved reviewed head.
|
||||
- Review result is `APPROVED`.
|
||||
- All High findings are resolved.
|
||||
- Medium findings have a disposition.
|
||||
- Required tests/checks pass.
|
||||
- Acceptance criteria pass.
|
||||
- No unresolved conflict exists.
|
||||
- {{WORK_TRACKER}} contains the correct references.
|
||||
|
||||
The development agent may perform the task → feature merge.
|
||||
|
||||
After merge:
|
||||
|
||||
1. Verify the feature branch contains the expected merge.
|
||||
2. Update {{WORK_TRACKER}}.
|
||||
3. Mark the task complete.
|
||||
4. Run `CONTROLLER_TICK`.
|
||||
|
||||
DO NOT stop because the task completed.
|
||||
|
||||
---
|
||||
|
||||
# 15. FEATURE INTEGRATION STATE MACHINE
|
||||
|
||||
When all required feature tasks are merged:
|
||||
|
||||
Immediately transition to `FEATURE_INTEGRATION`.
|
||||
|
||||
1. Reconcile latest `{{INTEGRATION_BRANCH}}`.
|
||||
2. Incorporate `{{INTEGRATION_BRANCH}}` according to repository policy.
|
||||
3. Assign conflicts to a developer if necessary.
|
||||
4. Run complete feature-level integration checks.
|
||||
5. Open feature PR → `{{INTEGRATION_BRANCH}}`.
|
||||
6. Dispatch fresh {{REVIEWER_VENDOR}} review.
|
||||
7. Process findings.
|
||||
8. Dispatch remediation when necessary.
|
||||
9. Rerun affected checks.
|
||||
10. Obtain fresh review of every changed head.
|
||||
11. When independently approved, dispatch the Approver.
|
||||
12. Approver verifies all gates.
|
||||
13. Approver merges feature → `{{INTEGRATION_BRANCH}}`.
|
||||
14. Verify resulting `{{INTEGRATION_BRANCH}}`.
|
||||
15. Update {{WORK_TRACKER}}.
|
||||
16. Immediately run `CONTROLLER_TICK`.
|
||||
|
||||
A merged feature is a transition.
|
||||
|
||||
It is terminal ONLY when:
|
||||
|
||||
`MISSION_SCOPE = SINGLE_FEATURE`
|
||||
|
||||
Otherwise select the next eligible work.
|
||||
|
||||
---
|
||||
|
||||
# 16. APPROVER CONTRACT
|
||||
|
||||
The approver is a separate merge-control agent.
|
||||
|
||||
Before feature → `{{INTEGRATION_BRANCH}}` merge, verify:
|
||||
|
||||
- Feature PR head SHA.
|
||||
- Independent review applies to that exact SHA.
|
||||
- Review result is `APPROVED`.
|
||||
- Required checks pass.
|
||||
- High findings are resolved.
|
||||
- Medium findings have dispositions.
|
||||
- Acceptance criteria pass.
|
||||
- No conflicts exist.
|
||||
- {{WORK_TRACKER}} matches repository state.
|
||||
|
||||
Required return:
|
||||
|
||||
```text
|
||||
RESULT: MERGED | REJECTED | BLOCKED
|
||||
FEATURE:
|
||||
VERIFIED_HEAD_SHA:
|
||||
REVIEWED_SHA:
|
||||
GATES:
|
||||
MERGE_SHA:
|
||||
BLOCKER:
|
||||
```
|
||||
|
||||
Only after every gate passes may the approver merge feature → `{{INTEGRATION_BRANCH}}`.
|
||||
|
||||
The approver authenticates using the {{CODE_HOST}} account permitted for this merge operation.
|
||||
|
||||
The orchestrator itself does not perform the merge.
|
||||
|
||||
---
|
||||
|
||||
# 17. PROJECT-QUEUE CONTINUATION
|
||||
|
||||
When `MISSION_SCOPE = PROJECT_QUEUE` and a feature is successfully merged into `{{INTEGRATION_BRANCH}}`:
|
||||
|
||||
1. Reconcile {{WORK_TRACKER}}.
|
||||
2. Check for a requested/scheduled release.
|
||||
3. Identify remaining incomplete features.
|
||||
4. Determine which features are eligible based on dependencies and priority.
|
||||
5. Select the next eligible feature.
|
||||
6. Initialize it.
|
||||
7. Continue the development loop.
|
||||
|
||||
Do not return to the user simply because one feature completed.
|
||||
|
||||
If several features are available, choose using:
|
||||
|
||||
1. Explicit {{WORK_TRACKER}} priority.
|
||||
2. Dependency-unblocking value.
|
||||
3. Existing partially completed work.
|
||||
4. Lowest integration/conflict risk.
|
||||
5. Repository/project ordering.
|
||||
|
||||
---
|
||||
|
||||
# 18. RELEASE RULES
|
||||
|
||||
Completed features accumulate in `{{INTEGRATION_BRANCH}}`.
|
||||
|
||||
Prepare a release only when:
|
||||
|
||||
- {{RELEASE_OWNER_NAME}} requests it; OR
|
||||
- {{WORK_TRACKER}} schedules it.
|
||||
|
||||
Release process:
|
||||
|
||||
1. Reconcile release scope.
|
||||
2. Confirm included features are complete.
|
||||
3. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`.
|
||||
4. Run complete release checks.
|
||||
5. Open release PR → `{{RELEASE_BRANCH}}`.
|
||||
6. Obtain fresh independent {{REVIEWER_VENDOR}} review.
|
||||
7. Remediate findings through development agents.
|
||||
8. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`.
|
||||
9. Revalidate.
|
||||
10. Assign final release PR to `{{HOST_ACCOUNT}}`.
|
||||
11. Update {{WORK_TRACKER}}.
|
||||
12. Stop before {{RELEASE_BRANCH}} merge.
|
||||
|
||||
Only {{RELEASE_OWNER_NAME}} may merge into `{{RELEASE_BRANCH}}`.
|
||||
|
||||
---
|
||||
|
||||
# 19. ESCALATION
|
||||
|
||||
Escalation is LOCAL by default.
|
||||
|
||||
Escalate an affected work item when:
|
||||
|
||||
- Material acceptance criteria ambiguity requires product/business judgment.
|
||||
- Required credential/service/model/access is unavailable.
|
||||
- A destructive/irreversible migration requires approval.
|
||||
- Repository policy cannot safely be satisfied.
|
||||
- Same blocker persists after three meaningful remediation attempts.
|
||||
- Requested work materially expands authorized scope.
|
||||
|
||||
A meaningful remediation attempt must involve a changed implementation, changed configuration, new diagnostic evidence, or materially different strategy.
|
||||
|
||||
Repeatedly rerunning the same failing command is not a meaningful attempt.
|
||||
|
||||
When one item is escalated:
|
||||
|
||||
1. Mark that item `LOCALLY_BLOCKED`.
|
||||
2. Record evidence and attempted remedies.
|
||||
3. Identify other unblocked in-scope work.
|
||||
4. Continue that work.
|
||||
|
||||
Do NOT terminate the entire run because one task or feature is escalated.
|
||||
|
||||
---
|
||||
|
||||
# 20. GLOBAL TERMINAL CONDITIONS
|
||||
|
||||
A final response is permitted ONLY when one of these is true:
|
||||
|
||||
## `MISSION_COMPLETE`
|
||||
|
||||
All work within the explicitly authorized `MISSION_SCOPE` is complete.
|
||||
|
||||
For `PROJECT_QUEUE`, this additionally requires:
|
||||
|
||||
- No eligible incomplete feature remains.
|
||||
- No task/review/remediation/merge is pending.
|
||||
- No development agent is active.
|
||||
- No approver is active.
|
||||
|
||||
## `RELEASE_READY`
|
||||
|
||||
Release PR has passed required gates and is assigned to `{{HOST_ACCOUNT}}`, awaiting {{RELEASE_OWNER_NAME}}'s {{RELEASE_BRANCH}} merge.
|
||||
|
||||
## `GLOBAL_ESCALATION`
|
||||
|
||||
All remaining in-scope work is blocked by decisions/access that require {{RELEASE_OWNER_NAME}}.
|
||||
|
||||
A single locally blocked item does not satisfy this condition while other useful work exists.
|
||||
|
||||
## `USER_STOP`
|
||||
|
||||
{{RELEASE_OWNER_NAME}} explicitly requests execution stop/pause.
|
||||
|
||||
No other state is terminal.
|
||||
|
||||
---
|
||||
|
||||
# 21. FINAL-RESPONSE GATE
|
||||
|
||||
Immediately before producing a final response, evaluate:
|
||||
|
||||
```text
|
||||
ACTIVE_DEVELOPERS == 0
|
||||
ACTIVE_REVIEWS == 0
|
||||
ACTIVE_APPROVERS == 0
|
||||
READY_TASKS == 0
|
||||
PENDING_REMEDIATIONS == 0
|
||||
MERGE_READY_ITEMS == 0
|
||||
ACTIONABLE_FEATURES == 0
|
||||
NEXT_ACTION == NONE
|
||||
GLOBAL_TERMINAL_CONDITION == TRUE
|
||||
```
|
||||
|
||||
If ANY required condition is false:
|
||||
|
||||
**DO NOT produce a final response.**
|
||||
|
||||
Execute `NEXT_ACTION`.
|
||||
|
||||
---
|
||||
|
||||
# 22. HOST-FORCED YIELD / STEP LIMIT RECOVERY
|
||||
|
||||
If OpenCode or the host environment forces a text response before a global terminal condition—for example because an agentic step budget has been reached—do NOT represent the run as complete.
|
||||
|
||||
Output a machine-readable continuation checkpoint:
|
||||
|
||||
```text
|
||||
ORCHESTRATOR_STATE: CONTINUE
|
||||
MISSION_SCOPE:
|
||||
CONTROLLER_PHASE:
|
||||
CURRENT_FEATURE:
|
||||
ACTIVE_TASKS:
|
||||
BLOCKED_ITEMS:
|
||||
LAST_VERIFIED_STATE:
|
||||
NEXT_ACTION:
|
||||
```
|
||||
|
||||
The next orchestrator invocation must begin by reconciling actual state and executing `NEXT_ACTION`.
|
||||
|
||||
`ORCHESTRATOR_STATE: CONTINUE` is explicitly nonterminal.
|
||||
|
||||
---
|
||||
|
||||
# 23. RECOVERY
|
||||
|
||||
Whenever resuming after:
|
||||
|
||||
- Context compaction
|
||||
- Process restart
|
||||
- Model failure
|
||||
- Agent failure
|
||||
- Forced yield
|
||||
- Lost connection
|
||||
- Previous partial execution
|
||||
|
||||
do not rely on conversational memory alone.
|
||||
|
||||
1. Read {{WORK_TRACKER}}.
|
||||
2. Inspect repository state.
|
||||
3. Inspect branches/PRs.
|
||||
4. Inspect CI/checks.
|
||||
5. Determine the last verified completed gate.
|
||||
6. Reconstruct `NEXT_ACTION`.
|
||||
7. Resume from that gate.
|
||||
|
||||
Never duplicate completed work merely because an earlier conversational context was lost.
|
||||
|
||||
---
|
||||
|
||||
# 24. EFFICIENCY RULES
|
||||
|
||||
1. Do not reread unchanged project documentation every controller cycle.
|
||||
2. Cache stable architecture/context within the current session.
|
||||
3. Re-read context only when relevant repository/project state changes.
|
||||
4. Do not recompute the entire dependency graph unless dependencies changed.
|
||||
5. Run cheap local quality checks before expensive independent review.
|
||||
6. Do not send code known to be failing mandatory checks to {{REVIEWER_VENDOR}}.
|
||||
7. Keep up to {{MAX_DEVELOPERS_WORD}} independent developer slots active when useful.
|
||||
8. Process completed agents immediately rather than waiting for a batch.
|
||||
9. Do not repeatedly ask reviewers to rediscover already-resolved findings.
|
||||
10. Include previous finding IDs in remediation review prompts.
|
||||
11. Use affected/local test suites during task development.
|
||||
12. Use full integration suites at feature/release gates.
|
||||
13. Avoid spawning agents for trivial repository-state inspection that the orchestrator can perform directly.
|
||||
14. Never use an LLM agent merely to wait.
|
||||
|
||||
---
|
||||
|
||||
# 25. ROLE BOUNDARIES
|
||||
|
||||
## Orchestrator
|
||||
|
||||
May:
|
||||
|
||||
- Inspect.
|
||||
- Plan.
|
||||
- Create feature/release branches.
|
||||
- Maintain {{WORK_TRACKER}}.
|
||||
- Dispatch agents.
|
||||
- Verify gates.
|
||||
- Coordinate work.
|
||||
|
||||
Must NOT:
|
||||
|
||||
- Implement feature code.
|
||||
- Remediate code directly.
|
||||
- Perform independent review.
|
||||
- Substitute its own approval.
|
||||
- Merge task/feature/release PRs.
|
||||
|
||||
## Development Agent
|
||||
|
||||
May:
|
||||
|
||||
- Implement assigned tasks.
|
||||
- Remediate findings.
|
||||
- Test.
|
||||
- Open task PRs.
|
||||
- Merge approved task PRs into feature branches.
|
||||
|
||||
Must NOT:
|
||||
|
||||
- Approve itself.
|
||||
- Independently review itself.
|
||||
- Merge to `{{INTEGRATION_BRANCH}}` or `{{RELEASE_BRANCH}}`.
|
||||
|
||||
## Reviewer
|
||||
|
||||
Read-only.
|
||||
|
||||
Never edits or merges.
|
||||
|
||||
## Approver
|
||||
|
||||
Verifies final feature gates and performs feature → `{{INTEGRATION_BRANCH}}` merge.
|
||||
|
||||
Never implements or reviews its own changes.
|
||||
|
||||
## {{RELEASE_OWNER_NAME}} / `{{HOST_ACCOUNT}}`
|
||||
|
||||
Only {{RELEASE_OWNER_NAME}} merges release PRs into `{{RELEASE_BRANCH}}`.
|
||||
|
||||
---
|
||||
|
||||
# 26. EXECUTION START
|
||||
|
||||
At startup:
|
||||
|
||||
1. Determine `MISSION_SCOPE`.
|
||||
2. Reconcile {{WORK_TRACKER}}.
|
||||
3. Inspect actual repository/PR state.
|
||||
4. Recover unfinished work if present.
|
||||
5. Establish `NEXT_ACTION`.
|
||||
6. Execute it.
|
||||
7. Enter `CONTROLLER_TICK`.
|
||||
8. Continue until a GLOBAL TERMINAL CONDITION is reached.
|
||||
|
||||
Remember:
|
||||
|
||||
**Never report a next executable step instead of performing it.**
|
||||
|
||||
**Task completion triggers another controller cycle.**
|
||||
|
||||
**Review completion triggers another controller cycle.**
|
||||
|
||||
**Feature completion triggers another controller cycle unless the mission was explicitly limited to that feature.**
|
||||
|
||||
**A locally blocked item triggers scheduling of other useful work whenever possible.**
|
||||
|
||||
You are the controller of a persistent development pipeline. Keep the pipeline moving.
|
||||
|
||||
---
|
||||
|
||||
# 27. HOST CONTROLLER RUNTIME
|
||||
|
||||
This section is the host execution contract for OpenCode {{OPENCODE_VERSION}} and takes
|
||||
precedence over Section 21 when deciding whether a single model invocation may
|
||||
end. The persistent user service runs one fresh, headless orchestrator turn per
|
||||
controller tick. Section 21 governs mission completion, not the lifetime of
|
||||
one OpenCode turn.
|
||||
|
||||
The user starts this project's cycle explicitly from OpenCode with
|
||||
`{{START_COMMAND}}`. `{{STOP_COMMAND}}` stops it, and
|
||||
`{{STATUS_COMMAND}}` reports its state. The `{{SERVICE_NAME}}` user unit is
|
||||
not enabled at login. Reading this prompt in an ordinary interactive session
|
||||
does not authorize starting the background service. The service waits for the
|
||||
interactive project session to close before its first tick.
|
||||
|
||||
At the end of EVERY orchestrator invocation, emit exactly one final line:
|
||||
`ORCHESTRATOR_STATE: CONTINUE`, `ORCHESTRATOR_STATE: QUIESCENT`, or
|
||||
`ORCHESTRATOR_STATE: BLOCKED`. Use CONTINUE whenever eligible or in-flight
|
||||
work remains and state the next concrete action immediately above that line.
|
||||
The service starts a fresh invocation after reconciling {{CONTEXT_STORE}}, {{WORK_TRACKER}}, Git,
|
||||
pull requests, and checks. Use QUIESCENT only after verifying that no eligible
|
||||
or in-flight work remains; the service polls again. Use BLOCKED only for a
|
||||
specific required human decision or access failure; name the missing action.
|
||||
A blocked task does not block other eligible work. If {{CONTEXT_STORE}} is unavailable,
|
||||
do not dispatch or merge, and return BLOCKED.
|
||||
|
||||
The installed launchers live at
|
||||
`{{CONTROL_HOME}}/scripts/`. Use `{{ROLE_LAUNCHER}}`
|
||||
to start developer or approver OpenCode sessions. Use `{{REVIEW_LAUNCHER}}` with
|
||||
WORKTREE, BASE_REF, HEAD_REF, REVIEW_PROMPT_FILE, PR_NUMBER, OUTPUT_FILE to
|
||||
run a fresh read-only {{REVIEWER_DISPLAY_NAME}} review at high effort. Its output file
|
||||
must be under `{{SCRATCH_DIR}}`; it posts a {{CODE_HOST}} review as `{{REVIEW_ACCOUNT}}`
|
||||
only after approving the exact current head. The feature-to-{{INTEGRATION_BRANCH}} approver uses
|
||||
`{{MERGE_BROKER}}` and must verify that same exact-head {{CODE_HOST}} approval.
|
||||
No controller or developer session performs the feature-to-{{INTEGRATION_BRANCH}} merge.
|
||||
|
||||
The service holds one controller lock and waits while an interactive
|
||||
{{PROJECT_NAME}} OpenCode session is open. Each model turn has a {{TICK_TIMEOUT_LABEL}} host
|
||||
timeout. After an error or timeout, reconcile external state before retrying;
|
||||
three consecutive failed turns stop the service for inspection. A successful
|
||||
feature merge is a transition back into scheduling, not a terminal condition.
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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.
|
||||
|
||||
Publish the reviewed **rendered** prompt into that project's context store
|
||||
with its version check. Give the project its own agent definitions, service
|
||||
unit, broker, and OpenCode start/stop commands. The template itself does not
|
||||
start a controller or register another project.
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"LAST_UPDATED": "2026-09-22",
|
||||
"PROJECT_CONTEXT_LABEL": "NESTQUEST HOUSE CHAOS COORDINATION",
|
||||
"PROJECT_NAME": "NestQuest",
|
||||
"WORK_TRACKER": "Maestro",
|
||||
"CONTEXT_STORE": "CTXD",
|
||||
"MODEL_PROVIDER": "OpenRouter",
|
||||
"CONTROLLER_MODEL": "deepseek/deepseek-v4.1-flash",
|
||||
"DEVELOPER_MODEL": "z-ai/glm-5.3-flash",
|
||||
"REVIEWER_MODEL": "claude-sonnet-5",
|
||||
"REVIEWER_DISPLAY_NAME": "Claude Sonnet 5",
|
||||
"REVIEWER_HARNESS": "Claude Code",
|
||||
"REVIEWER_VENDOR": "Claude",
|
||||
"REVIEWER_CLI": "claude",
|
||||
"MAX_DEVELOPERS": "2",
|
||||
"MAX_DEVELOPERS_WORD": "two",
|
||||
"INTEGRATION_BRANCH": "dev",
|
||||
"RELEASE_BRANCH": "main",
|
||||
"RELEASE_OWNER_NAME": "Joshua",
|
||||
"HOST_ACCOUNT": "overseer",
|
||||
"CODE_HOST": "Gitea",
|
||||
"TASK_ID_EXAMPLE": "TASK-123",
|
||||
"FEATURE_BRANCH_EXAMPLE": "feature/NQ-17-family-calendar",
|
||||
"OPENCODE_VERSION": "1.18.31",
|
||||
"CONTROL_HOME": "/home/overseer/.local/share/nestquest-controller",
|
||||
"SERVICE_NAME": "nestquest-controller.service",
|
||||
"START_COMMAND": "/start-nestquest-development",
|
||||
"STOP_COMMAND": "/stop-nestquest-development",
|
||||
"STATUS_COMMAND": "/nestquest-development-status",
|
||||
"ROLE_LAUNCHER": "nq-agent",
|
||||
"REVIEW_LAUNCHER": "nq-review",
|
||||
"MERGE_BROKER": "nq-approve-merge",
|
||||
"SCRATCH_DIR": "/tmp/opencode",
|
||||
"REVIEW_ACCOUNT": "review_agent",
|
||||
"TICK_TIMEOUT_LABEL": "six-hour"
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
"""FastAPI skeleton tests: health, OpenAPI, core wiring (task 042dfc5c).
|
||||
|
||||
These tests start the NestQuest API app with httpx against the ASGI
|
||||
app and assert the skeleton contract of Feature 16's first task:
|
||||
|
||||
- ``GET /health`` returns 200 with a JSON status body (``status: ok``)
|
||||
and does NOT leak the filesystem path;
|
||||
- ``GET /docs`` (Swagger UI) is served, and ``GET /openapi.json``
|
||||
returns the OpenAPI document;
|
||||
- the app wires core (opens the configured SQLite file and applies
|
||||
migrations on startup via the lifespan) and closes the connection on
|
||||
shutdown (``connected is False`` afterwards);
|
||||
- ``ApiConfig.from_env`` raises when ``NESTQUEST_DB_PATH`` is missing or
|
||||
empty, and returns the value when set;
|
||||
- a startup failure (unwritable path / non-SQLite file) surfaces from
|
||||
the lifespan rather than the first request;
|
||||
- each test is hermetic — it points ``NESTQUEST_DB_PATH`` at a fresh
|
||||
temp file.
|
||||
|
||||
A SEPARATE subprocess test asserts the stronger invariant the task
|
||||
calls out: the app imports the bundled ``core`` WITHOUT importing
|
||||
``homeassistant``. That cannot run inside the pytest process because
|
||||
``tests/conftest.py`` installs mock ``homeassistant`` modules globally
|
||||
before any test imports the integration; a subprocess with a clean
|
||||
``sys.modules`` is the only honest proof (the same approach
|
||||
``tests/test_core_no_ha_smoke.py`` takes for the core bundle).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api.app import create_app
|
||||
from api.config import ApiConfig, ConfigError
|
||||
from api.database import DatabaseState, _executor, make_database
|
||||
|
||||
#: The repo root, so the no-HA subprocess can import the API package.
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: The marker the no-HA subprocess prints on success.
|
||||
_SUCCESS_MARKER = "API NO-HA IMPORT OK"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path: Path) -> str:
|
||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
||||
return str(tmp_path / "nestquest.db")
|
||||
|
||||
|
||||
class _AppRunner:
|
||||
"""Run the app's lifespan around an httpx AsyncClient.
|
||||
|
||||
httpx's :class:`ASGITransport` does NOT run the ASGI lifespan on its
|
||||
own, so the database would never open. Starlette exposes the app's
|
||||
lifespan as ``app.router.lifespan_context``; wrapping the client
|
||||
session in it drives the same startup/shutdown path uvicorn would,
|
||||
so ``open_and_migrate`` runs before the first request and ``close``
|
||||
runs after the last.
|
||||
|
||||
``__aexit__`` uses try/finally so a client-side error still shuts
|
||||
the lifespan down — otherwise a failure mid-test would leak the
|
||||
database connection open.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self.app = create_app(ApiConfig(db_path=db_path))
|
||||
|
||||
async def __aenter__(self) -> httpx.AsyncClient:
|
||||
self._lifespan = self.app.router.lifespan_context(self.app)
|
||||
await self._lifespan.__aenter__()
|
||||
self._client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=self.app),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
return await self._client.__aenter__()
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
try:
|
||||
await self._client.__aexit__(*exc)
|
||||
finally:
|
||||
await self._lifespan.__aexit__(*exc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app_client(temp_db_path: str) -> httpx.AsyncClient:
|
||||
async with _AppRunner(temp_db_path) as client:
|
||||
yield client
|
||||
|
||||
|
||||
# --- ApiConfig.from_env -------------------------------------------------
|
||||
|
||||
|
||||
def test_from_env_returns_set_value() -> None:
|
||||
"""A set, non-empty NESTQUEST_DB_PATH is returned verbatim."""
|
||||
cfg = ApiConfig.from_env({"NESTQUEST_DB_PATH": "/data/nq.db"})
|
||||
assert cfg.db_path == "/data/nq.db"
|
||||
|
||||
|
||||
def test_from_env_raises_on_empty_value() -> None:
|
||||
"""An empty/whitespace-only value raises ConfigError (no silent default)."""
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env({"NESTQUEST_DB_PATH": " "})
|
||||
|
||||
|
||||
def test_from_env_raises_on_missing_value() -> None:
|
||||
"""A missing NESTQUEST_DB_PATH raises ConfigError (no silent default)."""
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env({})
|
||||
|
||||
|
||||
# --- health / openapi / docs -------------------------------------------
|
||||
|
||||
|
||||
async def test_health_returns_200_with_status_body(
|
||||
app_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""GET /health returns 200 with status ok and no filesystem path."""
|
||||
response = await app_client.get("/health")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body == {"status": "ok"}
|
||||
assert "db_path" not in body
|
||||
|
||||
|
||||
async def test_openapi_document_is_served(
|
||||
app_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""GET /openapi.json returns the OpenAPI document."""
|
||||
response = await app_client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
schema = response.json()
|
||||
assert schema["openapi"].startswith("3.")
|
||||
assert "/health" in schema["paths"]
|
||||
|
||||
|
||||
async def test_docs_swagger_ui_is_served(app_client: httpx.AsyncClient) -> None:
|
||||
"""GET /docs serves the Swagger UI HTML (OpenAPI at /docs)."""
|
||||
response = await app_client.get("/docs")
|
||||
assert response.status_code == 200
|
||||
assert "swagger" in response.text.lower()
|
||||
|
||||
|
||||
# --- core wiring & shutdown --------------------------------------------
|
||||
|
||||
|
||||
async def test_app_wires_core_and_applies_migrations(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""The lifespan opens the SQLite file and applies migrations.
|
||||
|
||||
After startup, the file exists and carries the NestQuest schema
|
||||
version stamp — proving the app imported core, opened the database
|
||||
at the configured path, and ran the migration runner.
|
||||
"""
|
||||
async with _AppRunner(temp_db_path):
|
||||
assert Path(temp_db_path).exists()
|
||||
# Re-open the file with a fresh core database to read the version
|
||||
# stamp independent of the app's connection. Reuse the API's own
|
||||
# executor so the test exercises the same wiring the app does.
|
||||
from api.nestquest_core import core_db, core_migrations
|
||||
|
||||
database = core_db.NestQuestDatabase(_executor)
|
||||
await database.open(temp_db_path)
|
||||
try:
|
||||
version = await core_migrations.read_schema_version(database)
|
||||
finally:
|
||||
await database.close()
|
||||
assert version >= 1
|
||||
|
||||
|
||||
async def test_shutdown_closes_the_connection(temp_db_path: str) -> None:
|
||||
"""The lifespan closes the database connection on shutdown.
|
||||
|
||||
After the runner exits, ``DatabaseState.database.connected`` is
|
||||
False — proving ``close()`` ran (the lifespan's finally block) even
|
||||
on a clean exit.
|
||||
"""
|
||||
runner = _AppRunner(temp_db_path)
|
||||
async with runner as _client:
|
||||
state: DatabaseState = runner.app.state.db
|
||||
assert state.database.connected
|
||||
assert not state.database.connected
|
||||
|
||||
|
||||
# --- startup failure ----------------------------------------------------
|
||||
|
||||
|
||||
async def test_startup_fails_on_unwritable_path(tmp_path: Path) -> None:
|
||||
"""A database path whose parent directory does not exist fails fast.
|
||||
|
||||
``sqlite3.connect`` raises ``OperationalError`` for a path under a
|
||||
non-existent directory; the lifespan surfaces that as a startup
|
||||
error rather than the first request. The connection is closed
|
||||
through the lifespan's finally block (``close`` is a no-op on a
|
||||
never-opened wrapper).
|
||||
"""
|
||||
bad_path = str(tmp_path / "does_not_exist" / "nestquest.db")
|
||||
runner = _AppRunner(bad_path)
|
||||
with pytest.raises(Exception):
|
||||
async with runner:
|
||||
pass
|
||||
|
||||
|
||||
async def test_startup_fails_on_non_sqlite_file(tmp_path: Path) -> None:
|
||||
"""A path pointing at a non-SQLite file fails fast on startup.
|
||||
|
||||
Opening an existing file that is not a SQLite database makes the
|
||||
``PRAGMA journal_mode=WAL`` run during :meth:`open` raise
|
||||
``DatabaseError: file is not a database``; the lifespan surfaces it.
|
||||
"""
|
||||
bad_file = tmp_path / "not_a_db.bin"
|
||||
bad_file.write_bytes(b"this is not a sqlite database")
|
||||
runner = _AppRunner(str(bad_file))
|
||||
with pytest.raises(Exception):
|
||||
async with runner:
|
||||
pass
|
||||
|
||||
|
||||
# --- no-HA subprocess ---------------------------------------------------
|
||||
|
||||
#: The no-HA subprocess script. Runs with ``sys.executable -c`` so it
|
||||
#: starts with a clean ``sys.modules`` and no ``homeassistant`` installed
|
||||
#: (the suite's conftest stubs are not present in a subprocess). It
|
||||
#: builds the app — which triggers the core bootstrap — and asserts
|
||||
#: neither ``homeassistant`` nor ``custom_components`` entered
|
||||
#: ``sys.modules``, and that the loaded core package's ``__file__``
|
||||
#: points at the bundled directory.
|
||||
_NO_HA_SCRIPT = textwrap.dedent(
|
||||
"""\
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
REPO_ROOT = Path(sys.argv[1])
|
||||
|
||||
# homeassistant must not be present before we touch the API.
|
||||
assert "homeassistant" not in sys.modules, (
|
||||
"homeassistant already in sys.modules before app import — the "
|
||||
"subprocess is not a clean no-HA process"
|
||||
)
|
||||
|
||||
from api.app import create_app
|
||||
from api.config import ApiConfig
|
||||
from api.nestquest_core import core
|
||||
|
||||
# The app build imports core via api.nestquest_core. core is a
|
||||
# Home-Assistant-free package; if it (or its loader) ever drags HA
|
||||
# in, this fails.
|
||||
assert "homeassistant" not in sys.modules, (
|
||||
"homeassistant imported while building the NestQuest API app — "
|
||||
"the core wiring is no longer Home-Assistant-free"
|
||||
)
|
||||
# The loader registers core under the distinct name "nestquest_core"
|
||||
# WITHOUT executing custom_components.nestquest.__init__, so the
|
||||
# parent integration package must never be registered, and no
|
||||
# sys.path mutation places the integration directory on sys.path.
|
||||
assert "custom_components" not in sys.modules, (
|
||||
"custom_components registered in sys.modules — the core loader "
|
||||
"executed the integration's HA-coupled __init__ instead of loading "
|
||||
"core by file location"
|
||||
)
|
||||
assert "nestquest_core" in sys.modules, "core was not imported"
|
||||
|
||||
# core.__file__ must point at the bundled directory inside the
|
||||
# integration, proving the file-location loader resolved against the
|
||||
# bundled package rather than some other "core" on sys.path.
|
||||
expected_core_dir = (
|
||||
REPO_ROOT / "custom_components" / "nestquest" / "core"
|
||||
)
|
||||
assert Path(core.__file__).resolve() == (
|
||||
expected_core_dir / "__init__.py"
|
||||
).resolve(), (
|
||||
f"core.__file__={core.__file__} does not point at the bundled "
|
||||
f"directory {expected_core_dir}"
|
||||
)
|
||||
|
||||
# Build the app (the lifespan is NOT run here, so the path is never
|
||||
# opened — it just needs to be a non-empty string to satisfy the
|
||||
# fail-fast config check). Use a temp path so nothing is created.
|
||||
app = create_app(ApiConfig(db_path=str(
|
||||
Path(tempfile.gettempdir()) / "nq-noha-buildonly.db"
|
||||
)))
|
||||
assert app is not None
|
||||
|
||||
print("API NO-HA IMPORT OK")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_app_imports_core_without_importing_homeassistant() -> None:
|
||||
"""The app imports core without importing homeassistant (subprocess).
|
||||
|
||||
Runs in a subprocess because ``tests/conftest.py`` installs mock
|
||||
``homeassistant`` modules into the pytest process's ``sys.modules``
|
||||
before any test runs, so an in-process check could never
|
||||
distinguish "core never touched HA" from "conftest already stubbed
|
||||
HA for us". The subprocess is a genuine no-HA process; if the
|
||||
core loader (or core itself) ever grows a ``homeassistant``
|
||||
import, the subprocess exits non-zero with ``ModuleNotFoundError``.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", _NO_HA_SCRIPT, str(_REPO_ROOT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(
|
||||
f"no-HA app-import subprocess exited {result.returncode}.\n"
|
||||
f"--- stdout ---\n{result.stdout}\n"
|
||||
f"--- stderr ---\n{result.stderr}"
|
||||
)
|
||||
assert _SUCCESS_MARKER in result.stdout, (
|
||||
f"success marker {_SUCCESS_MARKER!r} not printed.\n"
|
||||
f"--- stdout ---\n{result.stdout}\n"
|
||||
f"--- stderr ---\n{result.stderr}"
|
||||
)
|
||||
@@ -1,306 +0,0 @@
|
||||
"""Test the pure panel snapshot builder (task 9047a97b).
|
||||
|
||||
The snapshot assembly that lived inline in the coordinator's
|
||||
``_async_update_data`` was extracted into the Home-Assistant-free
|
||||
:func:`custom_components.nestquest.core.snapshot.build_snapshot`, and
|
||||
the panel payload shaper (:func:`instance_payload`) with it. These
|
||||
tests seed a household and assert the pure builder's output against
|
||||
EXPLICIT literal expected values — not self-equality — so the AWAY
|
||||
child's ``next_present``, a due-timed quest's ``overdue`` under a
|
||||
pinned ``now``, the custody ``cycle_day`` (> 1), and the panel payload
|
||||
dict shape (``done`` -> ``completed``, ``on_time`` passthrough, and
|
||||
``missed`` omission) are all exercised as literals.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.children import create_child
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
from custom_components.nestquest.core.snapshot import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestSnapshot,
|
||||
QuestInstanceView,
|
||||
build_snapshot,
|
||||
instance_payload,
|
||||
)
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import create_quest_definition
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
|
||||
|
||||
async def test_instance_payload_shape_and_missed_omission() -> None:
|
||||
"""``instance_payload`` is the documented panel shape (§1).
|
||||
|
||||
``done`` maps to ``completed``; ``open`` passes through; ``on_time``
|
||||
is the builder's ``was_on_time`` verbatim; ``missed`` instances are
|
||||
omitted unless ``include_missed`` (the admin payload, D-009).
|
||||
"""
|
||||
def view(state: str, *, overdue: bool = False, on_time: bool | None = None,
|
||||
completed_at: str | None = None, instance_id: int = 1) -> QuestInstanceView:
|
||||
return QuestInstanceView(
|
||||
instance_id=instance_id,
|
||||
definition_id=10,
|
||||
child_id=3,
|
||||
title="Fill bird feeder",
|
||||
icon="bird",
|
||||
window="afternoon",
|
||||
due_date="2026-09-21",
|
||||
due_time="17:00",
|
||||
state=state,
|
||||
overdue=overdue,
|
||||
completed_at=completed_at,
|
||||
was_on_time=on_time,
|
||||
)
|
||||
|
||||
open_view = view("open", overdue=True)
|
||||
done_view = view("done", on_time=True, completed_at="2026-09-21T12:00:00+00:00")
|
||||
missed_view = view("missed", instance_id=2)
|
||||
|
||||
panel = instance_payload([open_view, done_view, missed_view], include_missed=False)
|
||||
assert panel == [
|
||||
{
|
||||
"id": 1,
|
||||
"definition_id": 10,
|
||||
"child_id": 3,
|
||||
"title": "Fill bird feeder",
|
||||
"icon": "bird",
|
||||
"window": "afternoon",
|
||||
"due_time": "17:00",
|
||||
"state": "open",
|
||||
"overdue": True,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"definition_id": 10,
|
||||
"child_id": 3,
|
||||
"title": "Fill bird feeder",
|
||||
"icon": "bird",
|
||||
"window": "afternoon",
|
||||
"due_time": "17:00",
|
||||
"state": "completed",
|
||||
"overdue": False,
|
||||
"completed_at": "2026-09-21T12:00:00+00:00",
|
||||
"on_time": True,
|
||||
},
|
||||
], "panel payload omits missed and spells done as completed"
|
||||
|
||||
admin = instance_payload(
|
||||
[open_view, done_view, missed_view], include_missed=True
|
||||
)
|
||||
assert len(admin) == 3
|
||||
assert admin[2]["state"] == "missed", (
|
||||
"admin payload keeps missed instances with their missed state"
|
||||
)
|
||||
|
||||
|
||||
async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> None:
|
||||
"""The builder produces the documented shape as literals, pinned in time.
|
||||
|
||||
Ada is AWAY today (a 2-week schedule whose absent week 0 covers today,
|
||||
plus a presence override marking her present on today+2) so
|
||||
``present`` is False and ``next_present`` is the override date. Her
|
||||
open quest due at 10:00 is OVERDUE under the pinned 12:00 ``now``.
|
||||
Bo's quest due at 17:00 is completed at 12:00 (on time) so its
|
||||
payload ``state`` is ``completed`` and ``on_time`` is True. The first
|
||||
scheduled active child is Ada, whose 2-week cycle anchored one day
|
||||
before today puts ``cycle_day`` at 2.
|
||||
"""
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
settings = entry.runtime_data.settings
|
||||
|
||||
# Single clock read for the seed/snapshot anchor (no midnight skew).
|
||||
today = coordinator._local_now().date()
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
# Pin the build instant at 12:00 local: past Ada's 10:00 due time
|
||||
# (overdue) and before Bo's 17:00, and the completion moment for Bo.
|
||||
pinned_now = datetime.datetime(
|
||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=time_zone
|
||||
)
|
||||
today_iso = today.isoformat()
|
||||
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Pack bag",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("morning", "10:00")],
|
||||
)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[bo.id],
|
||||
[("morning", "17:00")],
|
||||
)
|
||||
end = (today + datetime.timedelta(days=3)).isoformat()
|
||||
await materialize(database, today_iso, end, today=today)
|
||||
|
||||
# Ada: 2-week cycle anchored yesterday. Week 0 (the empty segment)
|
||||
# covers today, so she is absent today; an override marks her present
|
||||
# on today+2, so next_present resolves to that date and cycle_day is
|
||||
# ((today - (today-1)) % 14) + 1 == 2.
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id,
|
||||
cycle_length_weeks=2,
|
||||
anchor_date=(today - datetime.timedelta(days=1)).isoformat(),
|
||||
pattern="|0,1,2,3,4,5,6",
|
||||
)
|
||||
returns_date = today + datetime.timedelta(days=2)
|
||||
await PresenceOverridesDao(database).create(
|
||||
ada.id,
|
||||
returns_date.isoformat(),
|
||||
returns_date.isoformat(),
|
||||
is_present=True,
|
||||
note="visit",
|
||||
)
|
||||
|
||||
# Complete Bo's instance at the pinned 12:00 (17:00 due => on time).
|
||||
bo_instances = await QuestInstancesDao(database).list_by_child_and_date(
|
||||
bo.id, today_iso
|
||||
)
|
||||
await complete_instance(
|
||||
database,
|
||||
bo_instances[0].id,
|
||||
actor_source="panel",
|
||||
actor_child_id=bo.id,
|
||||
now=pinned_now,
|
||||
today=today,
|
||||
)
|
||||
|
||||
built = await build_snapshot(database, settings, pinned_now)
|
||||
|
||||
assert isinstance(built, NestQuestSnapshot)
|
||||
assert built.today_iso == today_iso
|
||||
assert built.cycle_day == 2, (
|
||||
"Ada's 2-week cycle anchored yesterday => day 2 today"
|
||||
)
|
||||
|
||||
by_id = {child.child_id: child for child in built.children}
|
||||
assert list(by_id) == [ada.id, bo.id], (
|
||||
"active children appear in sort_order, id order"
|
||||
)
|
||||
ada_child = by_id[ada.id]
|
||||
bo_child = by_id[bo.id]
|
||||
|
||||
# Ada: away today, returns on the override date, one open+overdue quest.
|
||||
assert isinstance(ada_child, ChildDaySnapshot)
|
||||
assert ada_child.present is False
|
||||
assert ada_child.next_present == returns_date.isoformat()
|
||||
assert ada_child.due_today == 1
|
||||
assert ada_child.completed_today == 0
|
||||
assert ada_child.remaining_today == 1
|
||||
assert ada_child.completion_pct == 0
|
||||
ada_view = ada_child.instances[0]
|
||||
assert isinstance(ada_view, QuestInstanceView)
|
||||
assert ada_view.state == "open"
|
||||
assert ada_view.due_time == "10:00"
|
||||
assert ada_view.overdue is True, "12:00 now is past the 10:00 due time"
|
||||
assert ada_view.completed_at is None
|
||||
assert ada_view.was_on_time is None
|
||||
|
||||
# Bo: present, one completed instance, on time, not overdue.
|
||||
assert bo_child.present is True
|
||||
assert bo_child.next_present is None
|
||||
assert bo_child.due_today == 1
|
||||
assert bo_child.completed_today == 1
|
||||
assert bo_child.remaining_today == 0
|
||||
assert bo_child.completion_pct == 100
|
||||
bo_view = bo_child.instances[0]
|
||||
assert bo_view.state == "done"
|
||||
assert bo_view.due_time == "17:00"
|
||||
assert bo_view.overdue is False, "overdue is only computed for open state"
|
||||
assert bo_view.completed_at == pinned_now.astimezone(
|
||||
datetime.timezone.utc
|
||||
).isoformat()
|
||||
assert bo_view.was_on_time is True
|
||||
|
||||
# Panel payload shape: Ada open+overdue stays open; Bo done -> completed.
|
||||
ada_payload = instance_payload(ada_child.instances, include_missed=False)
|
||||
assert ada_payload == [
|
||||
{
|
||||
"id": ada_view.instance_id,
|
||||
"definition_id": ada_view.definition_id,
|
||||
"child_id": ada.id,
|
||||
"title": "Pack bag",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "10:00",
|
||||
"state": "open",
|
||||
"overdue": True,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
]
|
||||
bo_payload = instance_payload(bo_child.instances, include_missed=False)
|
||||
assert bo_payload == [
|
||||
{
|
||||
"id": bo_view.instance_id,
|
||||
"definition_id": bo_view.definition_id,
|
||||
"child_id": bo.id,
|
||||
"title": "Brush teeth",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "17:00",
|
||||
"state": "completed",
|
||||
"overdue": False,
|
||||
"completed_at": pinned_now.astimezone(
|
||||
datetime.timezone.utc
|
||||
).isoformat(),
|
||||
"on_time": True,
|
||||
}
|
||||
]
|
||||
|
||||
# Missed omission is real: a missed view is dropped from the panel
|
||||
# payload and kept in the admin payload with its missed state.
|
||||
missed_view = QuestInstanceView(
|
||||
instance_id=999,
|
||||
definition_id=10,
|
||||
child_id=ada.id,
|
||||
title="Stale quest",
|
||||
icon=None,
|
||||
window="morning",
|
||||
due_date=(today - datetime.timedelta(days=1)).isoformat(),
|
||||
due_time="09:00",
|
||||
state="missed",
|
||||
overdue=False,
|
||||
completed_at=None,
|
||||
was_on_time=None,
|
||||
)
|
||||
assert instance_payload([missed_view], include_missed=False) == []
|
||||
assert (
|
||||
instance_payload([missed_view], include_missed=True)[0]["state"]
|
||||
== "missed"
|
||||
)
|
||||
|
||||
# REQUIRED wrapper tie-in: the coordinator's _async_update_data is a
|
||||
# thin WRAPPER over build_snapshot, so pinning its clock to the SAME
|
||||
# pinned_now the builder was called with must reproduce the builder's
|
||||
# snapshot byte-for-byte. The dataclasses are frozen, so == is a full
|
||||
# deep compare — a wrapper that mis-passed today vs now, or stopped
|
||||
# calling the builder, would diverge here even though the literal
|
||||
# assertions above still pass.
|
||||
monkeypatch.setattr(coordinator, "_local_now", lambda: pinned_now)
|
||||
await coordinator.async_refresh()
|
||||
assert coordinator.data == built
|
||||
@@ -186,18 +186,18 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
original_refresh = coordinator._async_update_data
|
||||
|
||||
async def _future_dated_refresh():
|
||||
import custom_components.nestquest.core.snapshot as snapshot_module
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
real_derive = snapshot_module.derive_state
|
||||
real_derive = coordinator_module.derive_state
|
||||
|
||||
def _derive(instance, latest, today):
|
||||
return real_derive(instance, latest, today + datetime.timedelta(days=1))
|
||||
|
||||
snapshot_module.derive_state = _derive
|
||||
coordinator_module.derive_state = _derive
|
||||
try:
|
||||
return await original_refresh()
|
||||
finally:
|
||||
snapshot_module.derive_state = real_derive
|
||||
coordinator_module.derive_state = real_derive
|
||||
|
||||
coordinator._async_update_data = _future_dated_refresh
|
||||
await coordinator.async_refresh()
|
||||
|
||||
@@ -238,16 +238,16 @@ async def test_due_today_payload_omits_missed_for_panel_includes_for_admin(
|
||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
import custom_components.nestquest.core.snapshot as snapshot_module
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
tomorrow = _today() + datetime.timedelta(days=1)
|
||||
real_derive_state = snapshot_module.derive_state
|
||||
real_derive_state = coordinator_module.derive_state
|
||||
|
||||
def _derive_against_pinned_future(instance, latest, today):
|
||||
return real_derive_state(instance, latest, tomorrow)
|
||||
|
||||
monkeypatch.setattr(
|
||||
snapshot_module, "derive_state", _derive_against_pinned_future
|
||||
coordinator_module, "derive_state", _derive_against_pinned_future
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
|
||||
@@ -2,55 +2,6 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -60,68 +11,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.141.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
@@ -224,13 +113,10 @@ source = { virtual = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "voluptuous" },
|
||||
]
|
||||
|
||||
@@ -238,13 +124,10 @@ dev = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "httpx", specifier = ">=0.28" },
|
||||
{ name = "jinja2", specifier = ">=3" },
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
||||
{ name = "pyyaml", specifier = ">=6" },
|
||||
{ name = "uvicorn", specifier = ">=0.30" },
|
||||
{ name = "voluptuous", specifier = ">=0.14" },
|
||||
]
|
||||
|
||||
@@ -266,123 +149,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
@@ -476,19 +242,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
@@ -498,31 +251,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.53.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26cb171f7394716f4865ffb8d8c5e1eef42565c2dfa6b/uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e", size = 110881, upload-time = "2026-09-14T07:44:23.815Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e", size = 87081, upload-time = "2026-09-14T07:44:22.179Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "voluptuous"
|
||||
version = "0.16.0"
|
||||
|
||||
Reference in New Issue
Block a user