Compare commits

...

4 Commits

Author SHA1 Message Date
vh f7ff5a4c77 fix(web): close Heid pass-2 findings — stream vocab + disconnect catch (v0.16.1)
Second Heid panel pass (thread 01KSPBMFRRQE) on the v0.16.0 tree:
Gróa returned zero findings; Hulda surfaced two minor tightening
items, both closed here.

1. test-gap — TestStreamFullEventVocab drove only 8 of 11 Event types
   through the stream endpoint (omitted Error, Cancelled, AffectUpdate).
   Serialization for all 11 was already covered by the presentation-
   contract fixture tests; this was a stream-integration coverage gap.
   - Added AffectUpdate to the vocab stream (non-terminal, coexists
     with done).
   - Added dedicated test_error_terminal_event + test_cancelled_terminal_event
     (terminal events are mutually exclusive with done, so they can't
     share one stream).

2. precision — the disconnect-cancel path caught bare `except Exception:
   pass`, silently swallowing real CancelFailed / transport errors. The
   contract intent is to swallow only the cooperative race
   (CancelAlreadyCompleted). Narrowed: swallow CancelAlreadyCompleted /
   CancelTurnNotFound as the no-op race; log unexpected cancel failures
   as a structured stderr line for diagnosability. Never re-raises (we're
   unwinding the cancelled generator and must not mask CancelledError).

Tests: +2 (376 → 378). Patch per SemVer discipline — coverage +
diagnosability tightening, no behavior change observable to callers.
2026-05-27 21:04:34 -07:00
vh 369857d3f1 feat(web): address Heid code-review findings — issue #16 (v0.16.0)
Heid panel review (Gróa + Hulda, thread 01KSP5P6CSJH) on v0.15.0/
v0.15.1 surfaced one load-bearing bug + several precision items. This
pass closes them.

Load-bearing fix — cancel paths targeted the wrong turn_id:
- `_TURN_COUNTER` allocates browser-local ids (1, 2, 3…); the real
  upstream Worldtree turn_id (e.g. 799) only arrives in the first SSE
  event. The v0.15.x cancel/disconnect/shutdown paths posted to
  /sessions/{sid}/turns/{LOCAL_ID}/cancel — wrong URL upstream.
- TurnHandle.upstream_response (dead field) → upstream_turn_id: int|None.
  Captured from the first event's sse_id.turn_id in the stream
  generator. All cancel paths now target it. Cancel before the upstream
  stream starts (upstream_turn_id None) is a no-op
  ({"cancelled": false, "reason": "not_started"}).
- The old cancel tests mocked the local-id URL, so they encoded the bug;
  rewritten to assert the UPSTREAM id is targeted.

Behavior change (minor-bump driver) — server-side end_user_id:
- create_app gains end_user_id kwarg; entrypoint reads
  RATATOSKR_END_USER_ID and threads it in. POST /api/sessions uses
  app.state.end_user_id, IGNORING any browser-supplied value (a client
  can't impersonate an arbitrary end-user partition). JS no longer
  sends end_user_id.

Precision fixes:
- Entrypoint missing-extras ImportError catch scoped to starlette/
  uvicorn ONLY; baseline-dep / first-party import failures now
  propagate as real tracebacks instead of masking as exit-12.
- Lifespan shutdown logs per-pending session_id + upstream_turn_id
  (was a single aggregate count).

Tests (+18; 376 total):
- disconnect_triggers_upstream_cancel (INV-005 load-bearing — drives
  the stream generator directly + cancels the consuming task; would
  have caught the turn_id bug)
- cancel_targets_upstream_turn_id, cancel_before_started_is_noop,
  cancel_failed_500
- server-side end_user_id: uses / ignores-body / omits-when-unset
- create_app: routes_registered / state_attached / factory_stored
- entrypoint: default_host / port_zero / happy_argv / open / no-open
- real_import_bug_propagates (precision guard)
- full_event_vocab at the stream-endpoint layer

Contract #16 amended: v0.16.0 amendment banner + INV-005/006 reworded
for upstream_turn_id + FN sketches corrected (server-side end_user_id,
upstream_response→upstream_turn_id, manual client lifecycle vs the
non-executable async-with sketch, not-started cancel branch).
2026-05-27 20:53:13 -07:00
vh 0fbbeb171c fix(sessions): unwrap FastAPI detail envelope in get_persona_state (v0.15.1)
Live smoke against personal:8081 during the v0.15.0 web-companion
verification surfaced that real Worldtree returns persona_state
errors in the FastAPI default envelope shape:

    {"detail": {"error_code": "auth_scope_denied", "message": "..."}}

The v0.12.0 `get_persona_state` parser only inspected the top-level
`error_code` key. When the field was nested under `detail`, the
typed exception (AuthScopeDenied / PersonaNotConfigured /
AgentNotAvailable) wasn't raised; the call fell through to
SessionApiFailed, which then surfaced through the web companion as
an opaque HTTP 500 on /api/agents/{id}/persona_state.

The original test_sessions.py mocks used the flat-shape envelope, so
the bug was invisible in unit tests until the real-wire smoke.

Fix: extract error_code from either `err.get("error_code")` (flat)
OR `err.get("detail", {}).get("error_code")` (FastAPI default).

Patch per SemVer discipline — bug fix to v0.12.0 surface, no public
signature change, no new behavior. Callers that were getting the
wrong exception now get the right one; callers that were already
getting the right exception (flat-shape paths) are unchanged.

Tests: 2 new regression cases in TestGetPersonaState — one each for
the detail-envelope shape of 403 auth_scope_denied and 404
persona_not_configured. Suite: 358 passing.
2026-05-27 19:11:16 -07:00
vh 1228c37e6f feat(web): in-browser debug companion — issue #16 (v0.15.0)
Browser-based debug companion to the Ratatoskr TUI, reusing the
existing wire-layer modules unchanged. Same five surfaces (transcript,
thinking, tools, debug, persona) over the same Worldtree Conversation
API SSE wire, viewable from any device on the operator's LAN.

Per docs/contracts/issues/16.contract.md (full v2.1 module contract
with 11 FN blocks + 9 invariants + Heid panel review pass merged).

Architecture:
- New module `ratatoskr.web` with `server.py` (Starlette app, ~250 LOC),
  `entrypoint.py` (lazy-import gate, ~100 LOC), `static/index.html`
  (single-page vanilla JS UI, ~360 LOC)
- Optional-deps group `[web]` = starlette + uvicorn[standard]; dev
  pulls these in transitively
- New console script `ratatoskr-web`
- Streaming via browser-native `EventSource` GET; prompt-submit is a
  separate POST (load-bearing Hulda finding from R13 panel — EventSource
  is GET-only)
- Small in-memory turn registry maps (session_id, turn_id) → upstream
  request handle for cancel + browser-disconnect cleanup

Endpoint surface (9 routes):
- `GET /` → static index.html
- `GET /static/*` → static assets
- `GET /version` → {"ratatoskr": "<version>"}
- `GET /api/agents` → upstream /agents + local Tier 3 merge
- `POST /api/sessions` → upstream POST /sessions
- `GET /api/agents/{id}/persona_state` → upstream persona-state
- `POST /api/turns/{sid}` → allocate turn_id, register in turn registry
- `GET /api/turns/{sid}/stream?turn_id=N` → proxy upstream SSE to browser
- `POST /api/turns/{sid}/cancel?turn_id=N` → upstream cancel

Trust model: internal LAN debug surface. Binds 0.0.0.0:8765 default;
no auth, no CORS guard (operator direction). What stays disciplined
regardless of network trust:
- Transcript HTML-escapes assistant content (INV-004 — model output
  is untrusted text; adversarial HTML must not execute in browser)
- Upstream API key never reaches browser DOM (INV-003 — proxy-only)

Lifecycle:
- Browser disconnect mid-stream → upstream cancel (INV-005;
  asyncio.CancelledError caught in stream handler)
- Server Ctrl-C → lifespan shutdown drains turn registry within 5s
  budget (INV-006; structured-log line on timeout)

Tests (37 new, 356 total; previous 319 baseline preserved):
- tests/test_web_server.py (23 cases): endpoint contract via Starlette
  TestClient + respx mocks; covers each endpoint, browser-disconnect →
  upstream cancel, lifespan shutdown draining the registry
- tests/test_web_presentation_contract.py (11 cases): proxy
  serialization matches tests/fixtures/presentation_contract.json
  for one of each Event type — drift detection between server-side
  serializer and the JS presenter without forcing a shared abstraction
- tests/test_web_packaging.py (4 cases): static asset packaging via
  importlib.resources; AST-checked lazy-import discipline (no top-
  level starlette/uvicorn import in entrypoint.py); missing-API-key
  exit-11 path; missing-extras exit-12 path

Provenance:
- Scope v1 → Heid panel review (Gróa + Hulda, R13) → 8 load-bearing
  corrections (POST→GET split, Starlette > FastAPI, lazy-import
  discipline, browser-disconnect → upstream cancel, presentation-
  contract fixture, error event contract, static-asset packaging,
  escaped plain-text Markdown deferred) merged into scope v2
- Operator direction: internal-LAN debug surface; auth + CORS
  deliberately omitted

Not yet (deferred to v0.16.x+):
- Cross-reload session resume via Last-Event-ID
- Tier 3 lifecycle UI (define/patch/delete in browser)
- Markdown rendering with vendored safe-subset renderer
- TLS + real auth (only if a non-LAN use case ever surfaces)
2026-05-27 19:03:50 -07:00
13 changed files with 2940 additions and 7 deletions
+394
View File
@@ -0,0 +1,394 @@
---
contract_version: "2.1"
issue: 16
target_module: "ratatoskr.web"
scope: "New module `ratatoskr.web` exposing a browser-based debug companion to the Ratatoskr TUI. Reuses `ratatoskr.sse_client`, `ratatoskr.sessions`, `ratatoskr.tier3`, `ratatoskr.local_agents`, `ratatoskr.cli` unchanged. Adds a Starlette web server (`ratatoskr.web.server`), a lazy-import console-script entrypoint (`ratatoskr.web.entrypoint`), and a single-page static UI at `ratatoskr/web/static/index.html`. Optional-deps group `[web]` carries `starlette>=0.40` and `uvicorn[standard]>=0.30`. Surface: 9 HTTP endpoints (1 root, 1 static, 1 version, 5 API proxies, 1 SSE stream). Bound to `0.0.0.0` by default for LAN consumption — internal-LAN debug surface, no auth, no CORS guard (deliberate operator direction). The five Worldtree SSE surfaces (transcript, thinking, tools, debug, persona) render in the browser via the same routing rules as the TUI, with client-side JS re-implementing the presentation discipline (no shared abstraction extracted at v0.15.0). Goal: operators have a sharable / inspectable second viewport on the same Worldtree SSE stream, reachable from any device on the LAN."
depends_on:
- "ratatoskr.sse_client"
- "ratatoskr.sessions"
- "ratatoskr.tier3"
- "ratatoskr.local_agents"
- "ratatoskr.cli"
- "starlette"
- "uvicorn"
- "httpx"
used_by: []
language: "python"
complexity: "medium"
estimated_loc: 600
confidence: 0.85
assumptions:
- "**Browser-native EventSource is GET-only.** The SSE stream endpoint is `GET /api/turns/{sid}/stream?turn_id=<id>`; the prompt-submit is a separate `POST /api/turns/{sid}` that returns `{turn_id}`. The two calls share a small in-memory turn registry keyed on `(session_id, turn_id)` so the cancel and disconnect-cleanup paths can find the in-flight upstream request. This split is a load-bearing correction from the Heid panel review (Hulda) on scope v1."
- "**Trust model is internal LAN.** Binds `0.0.0.0:8765` by default; `--host 127.0.0.1` available for localhost-only. No auth, no TLS, no CORS guard. The operator has explicitly accepted this: anyone routable to the host's port can reach the interface. What stays disciplined regardless of network trust: (1) transcript HTML-escapes assistant content (model output is untrusted text — adversarial HTML in responses must not execute in the browser); (2) upstream API key never reaches the browser DOM or any client-visible response field."
- "**Optional-deps lazy-import discipline.** `ratatoskr.web` deps (`starlette`, `uvicorn`) are an optional-extras group `[web]`. The console-script entrypoint `ratatoskr.web.entrypoint:main` parses CLI flags BEFORE importing `ratatoskr.web.server` so users without the extras installed get a clean `pip install ratatoskr[web]` message instead of a naked `ImportError: starlette`. Both Heid panel arms (Gróa + Hulda) converged on this. `ratatoskr.web.__init__` is bare; no module-level imports of starlette/uvicorn anywhere on the cli import path."
- "**Starlette over FastAPI.** Both Heid panel arms converged: five thin proxy endpoints don't need FastAPI's Pydantic / OpenAPI / dependency-injection machinery. Use Starlette + manual `Response` / `StreamingResponse` / `JSONResponse` construction."
- "**Static asset packaging.** `src/ratatoskr/web/static/index.html` ships in the wheel via `[tool.hatch.build.targets.wheel]` include rules. Located at runtime via `importlib.resources.files('ratatoskr.web') / 'static' / 'index.html'`. Test asserts this resolution works in the installed package."
- "**Presentation contract pinning.** A JSON fixture at `tests/fixtures/presentation_contract.json` enumerates the expected browser-facing event payload for each Event type (one entry each for WorkerPhase, Thinking, Text, TextBoundary, ToolStart, ToolResult, Done, Error, Cancelled, AffectUpdate, AwaitingLlmFirstToken). Server-side proxy serialization is unit-tested against this fixture. JS-side rendering treats the fixture as the contract. Drift detection between TUI and JS presenter without forcing a shared abstraction (Hulda)."
- "**Browser-disconnect → upstream cancel.** When the browser closes the EventSource (tab close, navigation, explicit disconnect), the server's stream handler catches the `asyncio.CancelledError` raised by Starlette's BackgroundTask cleanup and triggers an upstream cancel on the matching `(session_id, turn_id)` via `ratatoskr.sse_client.cancel_turn`. Both Heid arms convergent. Test simulates the disconnect via `respx` + `httpx.AsyncClient` test-client and verifies the upstream cancel call lands."
- "**Mid-stream Ctrl-C safety.** Server uses Starlette's `lifespan` shutdown hook to issue upstream cancels for every entry in the turn registry within a 5-second cleanup budget. Entries that don't ack in time are abandoned (structured-logged). No half-written state on the Worldtree side under cooperative cleanup."
- "**Markdown rendering is escape-first.** v0.15.0 ships HTML-escaped plain-text rendering for the transcript pane only. Markdown rendering with a vendored safe-subset renderer is deferred to v0.16.x. This is a deliberate first-cut safety call (Hulda) — hand-rolled Markdown is easy to get wrong around HTML escaping when model output is untrusted."
- "**Server-side structured JSON logging.** One JSON line per HTTP request (method/path/status/duration_ms/client) + one line per SSE open/close (with events_forwarded + reason). Lets the operator diagnose problems when the browser viewport is the only one running (Gróa)."
- "**Per-pane copy + version footer affordances.** Each pane (Tools / Debug / Thinking / Persona) has a copy button that surfaces the pane's plain-text content for paste-into-issue / paste-into-bug-report flows. Footer carries the running `ratatoskr` package version for version-correlation when comparing browser to TUI (both Gróa-flagged)."
- "**Resume punted at v0.15.0.** No cross-reload session resume via `Last-Event-ID`; reload starts fresh. `/api/sessions` (GET) endpoint dropped from v0.15.0 — only `POST /api/sessions` (create) is shipped. Resume moves to v0.16.x."
- "**Tier 3 lifecycle stays CLI-only.** The web UI is read-only for Tier 3 surface — define / patch / delete remain in the `ratatoskr.tier3` CLI. Web surface lists Tier 3 agents (via the same `local_agents.json` merge that the TUI does) but doesn't expose mutation. Mutation UI deferred to v0.16.x."
- "**Tests use Starlette's TestClient + respx for upstream.** Same `respx` pattern as `tests/test_sse_client.py` / `tests/test_sessions.py`. New test files: `tests/test_web_server.py`, `tests/test_web_presentation_contract.py`, `tests/test_web_packaging.py`. No live network; the live smoke-test is part of the post-merge ship verification, not the unit tests."
open_questions:
- "Should `--open` auto-open default to True or False? Draft: False — the LAN use case often runs the server on one device and connects from another, so auto-opening on the host is wrong by default. Operator passes `--open` when running locally and wants the convenience."
- "Should the turn registry's cleanup-budget timeout (5s) be CLI-configurable? Draft: no for v0.15.0 — 5s is a reasonable default and adding a flag invites bikeshedding. Revisit if real outage telemetry suggests otherwise."
- "Should the static `index.html` carry a build-time hash for browser cache-busting? Draft: no for v0.15.0 — the use case is short-lived debug sessions; operators reload manually. Vendored renderer + Markdown rendering in v0.16.x is the right time to introduce cache-busting if needed."
prd:
issue: 16
issue_url: https://gitea.phasefinal.com/vh/ratatoskr/issues/16
body_sha256_16: "ae32cee38fd35761"
lock_in_comment_id: null
lock_in_sha256_16: null
lock_in_at: null
pinned_at: "2026-05-28T01:43:24+00:00"
---
# Web companion — in-browser debug surface
## Context
Ratatoskr is a debug TUI for the Worldtree Conversation API. The wire-layer modules (`sse_client`, `sessions`, `tier3`, `local_agents`) are well-factored and reusable. This issue adds a sibling presentation surface: a browser-based debug companion that consumes the same SSE wire and renders the same five panes (transcript, thinking, tools, debug, persona). Reachable from any device on the operator's LAN — "show someone what I'm seeing" — without replacing the TUI as the canonical debug interface.
The work is wire-layer-zero (no changes to `sse_client` / `sessions` / `tier3` / `local_agents`) plus a new top-level module `ratatoskr.web` with a Starlette app, a console-script entrypoint, and a single-page static UI. Optional dependencies (`starlette`, `uvicorn`) ship as an `[web]` extras group so users who only want the TUI don't pay the install cost.
## Public surface
### Console script
```
ratatoskr-web [--host HOST] [--port PORT] [--open]
--host HOST Bind address. Default: 0.0.0.0 (LAN-accessible).
Use 127.0.0.1 to restrict to localhost.
--port PORT Listen port. Default: 8765. Use 0 for random free.
--open Auto-open the URL in the system browser.
```
### Server endpoint surface
```
GET / → serve index.html (200)
GET /static/<path> → serve static asset (200) or 404
GET /version → {"ratatoskr": "<version>"} (200)
GET /api/agents → 200 with [AgentInfo + tier3 local merge]
POST /api/sessions → 201 with SessionInfo
GET /api/agents/{agent_id}/persona_state
→ 200 with PersonaSnapshot, or 404 / 403
POST /api/turns/{session_id} → 200 with {"turn_id": <int>}
GET /api/turns/{session_id}/stream
?turn_id=<int> → 200 SSE stream (text/event-stream)
POST /api/turns/{session_id}/cancel
?turn_id=<int> → 200 ok / 404 / 409 / 500
```
### Module shape
```
src/ratatoskr/web/
__init__.py # bare — no module-level imports of starlette/uvicorn
entrypoint.py # console-script: argparse, lazy import of server
server.py # Starlette app factory + endpoint handlers + turn registry
static/
index.html # single-page UI (vanilla HTML/CSS/JS, no build step)
```
### Public functions
```python
def create_app(client_factory: Callable[[], httpx.AsyncClient]) -> Starlette: ...
def main(argv: list[str] | None = None) -> int: ... # entrypoint.main
```
`create_app` is the factory — takes a callable that produces a configured `httpx.AsyncClient` (bearer auth, base_url from env, User-Agent set per `ratatoskr.cli.USER_AGENT`) and returns a Starlette app with routes wired. Decoupling via factory keeps tests simple (the test client passes a respx-mocked `AsyncClient`).
`entrypoint.main` is the console-script target — parses flags, builds the client factory from env, calls `create_app`, runs uvicorn. The lazy-import discipline lives here: `import starlette` does NOT happen at module top — it lands inside `main()` after arg parsing, with an `ImportError` catch that prints the `pip install ratatoskr[web]` hint and exits non-zero.
## v0.16.0 amendment (post-Heid-code-review)
Heid panel review (Gróa + Hulda, thread `01KSP5P6CSJH`) on the
v0.15.0/v0.15.1 implementation surfaced three contract-text issues
now corrected below:
1. **Upstream vs local turn_id.** Cancel paths (explicit cancel,
browser-disconnect, lifespan shutdown) MUST target the *upstream*
(Worldtree-assigned) turn_id captured from the first SSE event's
`sse_id.turn_id`, NOT the browser-local `_TURN_COUNTER` value (which
is only a registry key). The `TurnHandle.upstream_response` field is
replaced by `upstream_turn_id: int | None`. Cancel before the
upstream stream starts (upstream_turn_id is None) is a no-op
(`{"cancelled": false, "reason": "not_started"}`).
2. **`RATATOSKR_END_USER_ID` is server-configured.** `FN main` reads it
from env and threads it into `create_app(..., end_user_id=...)`; the
`POST /api/sessions` endpoint uses `app.state.end_user_id` server-
side. The browser NEVER supplies end_user_id — a client cannot
impersonate an arbitrary end-user partition.
3. **Stream client lifecycle.** The `async with client_factory() as
client:` sketch in `FN stream_turn_endpoint` is not executable for a
long-lived async generator that must outlive the handler frame; the
implementation uses manual `client = ...; try: ... finally: await
client.aclose()`. Sketch corrected below.
## Invariants
- **INV-001**: `ratatoskr.web.__init__` and `ratatoskr.web.entrypoint` MUST NOT import `starlette` or `uvicorn` at module top. Import is inside `main()` after flag parsing. The missing-extras `ImportError` catch is scoped to the OPTIONAL extras (`starlette` / `uvicorn`) ONLY — baseline-dep / first-party import failures propagate as real tracebacks rather than masking as exit-12.
- **INV-002**: `ratatoskr.web.server.create_app` MUST accept a `client_factory` callable. The app MUST NOT construct `httpx.AsyncClient` at module top or in route handlers; it MUST call the factory.
- **INV-003**: Upstream API key MUST never appear in any browser-visible response. Server proxies upstream calls using the client factory; only the upstream's JSON / SSE payload is forwarded. No header echo.
- **INV-004**: Transcript content from upstream `text` SSE events MUST be HTML-escaped before reaching the browser DOM (escape on the wire in the SSE proxy serialization OR escape in the JS rendering — both are acceptable; pick one and stick to it).
- **INV-005**: Browser disconnect mid-stream (`asyncio.CancelledError` in the SSE handler) MUST trigger an upstream cancel via `sse_client.cancel_turn` on the captured `upstream_turn_id` (v0.16.0 — NOT the browser-local turn_id). If the turn already completed, the cancel is a best-effort no-op (`CancelAlreadyCompleted` swallowed). If `upstream_turn_id` is still None (upstream stream never started), the disconnect cancel is skipped — nothing to cancel.
- **INV-006**: Server shutdown (Ctrl-C / SIGTERM) MUST issue upstream cancels (on `upstream_turn_id`) for every in-flight registry entry within a 5-second cleanup budget. Handles whose `upstream_turn_id` is None are skipped. Entries that don't ack in time are abandoned with a per-entry structured log line carrying `session_id` + `upstream_turn_id`.
- **INV-007**: The turn registry MUST be in-process memory only — no persistence, no shared state across server restarts. Process exit drops the registry.
- **INV-008**: Each SSE event serialized to the browser MUST follow the contract enumerated in `tests/fixtures/presentation_contract.json` — one entry per Event type, with the exact JSON shape the browser presenter renders against.
- **INV-009**: All wire-layer modules (`sse_client`, `sessions`, `tier3`, `local_agents`) MUST be used unchanged. Any required change to those modules is out of scope for this issue and gets its own ticket.
## Constraints
- **[security]** Upstream API key never reaches the browser. Lives in `WORLDTREE_API_KEY` env, passed to upstream via `Authorization: Bearer …` header in the client factory.
- **[security]** Model-output text is HTML-escaped in the transcript pane. No script injection from adversarial assistant responses.
- **[security]** No CORS guard, no auth — internal-LAN debug surface per operator direction.
- **[testability]** Server is testable via `starlette.testclient.TestClient` + `respx` upstream mocks. No live network in unit tests.
- **[packaging]** Static asset `index.html` ships in the wheel; resolvable via `importlib.resources` post-install.
- **[performance]** Server is stateless across browser tabs; one in-memory registry entry per in-flight turn. Cleanup on browser disconnect / server shutdown.
## Tests (overview)
All test files live under `tests/`. New test files added by this issue:
- `tests/test_web_server.py` — endpoint contract tests via TestClient + respx
- `tests/test_web_presentation_contract.py` — SSE proxy serialization vs fixture
- `tests/test_web_packaging.py` — static asset resolution + lazy-import discipline
Existing test files remain unchanged.
## Function blocks
```contract
FN main(argv: list[str] | None) -> int
BRIEF: Console-script entry point — parses flags, lazy-imports server, runs uvicorn.
PRE: [PRE-001 hard] argv parsing succeeds -- argparse raises SystemExit on bad args (exit 2)
PRE: [PRE-002 soft] WORLDTREE_API_KEY env var present -- if missing, exit 11 [auth_error]
PRE: [PRE-003 hard] starlette + uvicorn importable -- catch ImportError, print install hint, exit 12 [missing_extras]
POST: [POST-001 side_effect] uvicorn serves until SIGINT/SIGTERM -- blocking call returns on shutdown
POST: [POST-002 side_effect] boot banner printed to stderr -- URL + connect-instructions visible
ERRORS:
ImportError -> print "Install ratatoskr[web]" hint, return 12
KeyError -> print missing-env error, return 11
STEPS:
1. [parse] argparse: --host (default 0.0.0.0), --port (default 8765, 0 = random), --open (default False)
2. [validate] read WORLDTREE_API_URL, WORLDTREE_API_KEY, RATATOSKR_END_USER_ID from env
IF WORLDTREE_API_KEY missing:
- write [auth_error] to stderr, return 11
3. [import] try: from ratatoskr.web.server import create_app
EXCEPT ImportError:
- write "ratatoskr-web requires the [web] extras..." hint to stderr, return 12
4. [factory] build client_factory closure capturing url + key + user-agent
5. [app] app = create_app(client_factory)
6. [banner] print boot banner to stderr (version, host:port, connect URLs)
7. [open] IF --open: webbrowser.open(f"http://localhost:{port}/")
8. [serve] uvicorn.run(app, host=host, port=port, log_config=None)
9. [return] return 0 on clean shutdown
TESTS:
happy_argv [tracer]: argv=["--port", "0"] with env set → returns 0 after serve loop mocked
missing_extras [error]: starlette unimportable → stderr has install hint, returns 12
missing_api_key [error]: WORLDTREE_API_KEY unset → stderr has [auth_error], returns 11
default_host_is_zero [trace]: argv=[] → parsed host == "0.0.0.0"
port_zero_supported [trace]: argv=["--port", "0"] → parsed port == 0
open_flag_calls_webbrowser [trace]: argv=["--open"] with mocked webbrowser → webbrowser.open called
no_open_default [trace]: argv=[] → webbrowser.open not called
```
```contract
FN create_app(client_factory: Callable[[], httpx.AsyncClient]) -> Starlette
BRIEF: Construct the Starlette app — wire routes, register lifespan, build turn registry.
PRE: [PRE-001 hard] client_factory is callable -- assert callable(client_factory)
POST: [POST-001 return_value] returns Starlette instance with all routes registered -- inspect app.routes
POST: [POST-002 state_change] app.state.turn_registry initialized as dict -- app.state.turn_registry == {}
STEPS:
1. [setup] turn_registry: dict[tuple[str, int], TurnHandle] = {}
2. [routes] register routes for: /, /static/{path}, /version, /api/agents, /api/sessions,
/api/agents/{id}/persona_state, /api/turns/{sid} (POST), /api/turns/{sid}/stream (GET),
/api/turns/{sid}/cancel (POST)
3. [lifespan] register lifespan handler that drains turn_registry on shutdown
within 5s cleanup budget per INV-006
4. [state] attach client_factory and turn_registry to app.state
5. [return] return Starlette(routes=routes, lifespan=lifespan)
TESTS:
routes_registered [tracer]: factory=mock → app.routes contains all 9 path patterns
state_attached [trace]: factory=mock → app.state.turn_registry is empty dict
factory_stored [trace]: factory=mock → app.state.client_factory is the same callable
```
```contract
FN version_endpoint(request: Request) -> JSONResponse
BRIEF: Return the ratatoskr package version as JSON.
POST: [POST-001 return_value] response is JSON {"ratatoskr": <version>} status 200
STEPS:
1. [lookup] version = importlib.metadata.version("ratatoskr")
2. [return] JSONResponse({"ratatoskr": version}, status_code=200)
TESTS:
happy [tracer]: GET /version → 200, body == {"ratatoskr": "<current-version>"}
```
```contract
FN agents_endpoint(request: Request) -> JSONResponse
BRIEF: Proxy GET /agents from upstream; merge with local Tier 3 index.
POST: [POST-001 return_value] 200 with list of agent dicts (upstream + local tier3 merged)
POST: [POST-002 exception] upstream error → JSONResponse with upstream's error_code envelope
STEPS:
1. [proxy] async with app.state.client_factory() as client: agents = await list_agents(client)
2. [local] local = local_agents.load_local_agents()
3. [merge] merged = [as_dict(a) for a in agents] + [as_dict(le) for le in local if le.agent_id not in {a.agent_id for a in agents}]
4. [return] JSONResponse(merged, status_code=200)
ERRORS:
SessionApiFailed -> JSONResponse({"error_code": "session_api_failed", "status": exc.status}, exc.status)
httpx.RequestError -> JSONResponse({"error_code": "network_error", "message": str(exc)}, 502)
TESTS:
happy [tracer]: respx mock /agents 200 → response merges upstream + local index
upstream_500 [error]: respx mock 500 → 500 with error_code envelope
network_error [error]: respx connection refused → 502 with network_error envelope
local_dedup [scenario]: local entry with same agent_id as upstream → no duplicate in merge
```
```contract
FN create_session_endpoint(request: Request) -> JSONResponse
BRIEF: Proxy POST /sessions to upstream.
PRE: [PRE-001 hard] request body has "agent_id" key -- 400 if missing
POST: [POST-001 return_value] 201 with SessionInfo on upstream success
STEPS:
1. [parse] body = await request.json(); agent_id = body["agent_id"] (400 if missing)
2. [server-side] end_user_id = request.app.state.end_user_id # v0.16.0: server-configured, NOT from body
3. [proxy] async with client_factory() as client: info = await create_session(client, agent_id, end_user_id=end_user_id)
4. [return] JSONResponse(as_dict(info), status_code=201)
ERRORS:
AgentNotFound -> JSONResponse({"error_code": "agent_not_found"}, 404)
SessionApiFailed -> JSONResponse({"error_code": "session_api_failed", "status": exc.status}, exc.status)
TESTS:
happy [tracer]: respx mock 201 → endpoint returns 201 with session JSON
unknown_agent [error]: respx mock 404 → 404 with agent_not_found envelope
missing_agent_id [adversarial]: body without agent_id → 400
server_side_end_user_id [v0.16.0]: create_app(end_user_id="X") → upstream body carries end_user_id="X"
ignores_body_end_user_id [v0.16.0,security]: body end_user_id is overridden by server value
```
```contract
FN persona_state_endpoint(request: Request) -> JSONResponse
BRIEF: Proxy GET /agents/{id}/persona_state to upstream.
POST: [POST-001 return_value] 200 with PersonaSnapshot on upstream success
STEPS:
1. [parse] agent_id = request.path_params["agent_id"]
2. [proxy] async with client_factory() as client: snap = await get_persona_state(client, agent_id)
3. [return] JSONResponse(snap, status_code=200)
ERRORS:
PersonaNotConfigured -> JSONResponse({"error_code": "persona_not_configured"}, 404)
AgentNotAvailable -> JSONResponse({"error_code": "agent_not_available"}, 404)
AuthScopeDenied -> JSONResponse({"error_code": "auth_scope_denied"}, 403)
TESTS:
happy [tracer]: respx mock 200 → endpoint returns 200 with snapshot
persona_not_configured [error]: respx mock 404 + persona_not_configured → 404 envelope
agent_not_available [error]: respx mock 404 + agent_not_available → 404 envelope
auth_scope_denied [error]: respx mock 403 + auth_scope_denied → 403 envelope
```
```contract
FN submit_turn_endpoint(request: Request) -> JSONResponse
BRIEF: Accept a prompt-submit; allocate a turn_id in the registry; return it. NO upstream call yet — the stream endpoint opens that.
PRE: [PRE-001 hard] request body has "content" key -- 400 if missing
POST: [POST-001 return_value] 200 with {"turn_id": <int>}
POST: [POST-002 state_change] app.state.turn_registry has entry for (sid, turn_id) with content + status "queued"
STEPS:
1. [parse] session_id = path_params["session_id"]; body = await request.json(); content = body["content"]
2. [allocate] turn_id = next_turn_id() # process-local monotonic counter
3. [register] turn_registry[(session_id, turn_id)] = TurnHandle(content=content, status="queued", upstream_turn_id=None) # v0.16.0: was upstream_response
4. [return] JSONResponse({"turn_id": turn_id}, status_code=200)
TESTS:
happy [tracer]: POST {"content": "hi"} → 200 with turn_id; registry populated
missing_content [adversarial]: body without content → 400
monotonic_turn_ids [trace]: two submits → second turn_id > first turn_id
```
```contract
FN stream_turn_endpoint(request: Request) -> StreamingResponse
BRIEF: Open SSE stream to browser — proxy upstream stream_turn() events, forward as SSE.
PRE: [PRE-001 hard] (session_id, turn_id) in registry -- 404 if absent
POST: [POST-001 side_effect] each upstream event serialized to browser as SSE event with type+data per fixture
POST: [POST-002 state_change] on completion/disconnect, registry entry removed; upstream cancel if turn still in flight
STEPS:
1. [validate] sid, tid = path/query params; handle = registry.get((sid, tid)); 404 if None
2. [open] client = client_factory() # v0.16.0: manual lifecycle, NOT `async with` — the generator outlives this frame; closed in finally
- handle.status = "streaming"
3. [forward] async for event in stream_turn(client, sid, handle.content):
- IF handle.upstream_turn_id is None: handle.upstream_turn_id = event.sse_id.turn_id # v0.16.0: capture upstream turn id
- serialize per fixture: {"type": <ssetype>, "data": <json>}
- yield as `event: <type>\\ndata: <json>\\n\\n` bytes
4. [terminal] on Done/Error/Cancelled: yield final SSE, mark handle.status, break
5. [cleanup] finally:
- IF asyncio.CancelledError caught AND status=="streaming" AND upstream_turn_id is not None: cancel_turn(client, sid, handle.upstream_turn_id) # v0.16.0: upstream id, not tid
- remove (sid, tid) from registry; await client.aclose()
ERRORS:
KeyError -> 404 turn_not_found
asyncio.CancelledError -> upstream cancel, propagate
SseConnectFailed -> yield synthetic error event, close stream
SseConnectionDropped -> yield synthetic error event, close stream
TESTS:
happy [tracer]: respx mock one text+done → SSE stream yields text event + done event
unknown_turn [error]: GET with turn_id not in registry → 404
upstream_error [error]: respx 500 on /sessions/{sid}/messages → synthetic error SSE event
disconnect_triggers_cancel [scenario]: browser disconnect mid-stream → cancel_turn called on upstream
full_event_vocab [scenario]: respx with one of each Event type → fixture-shaped JSON for each
```
```contract
FN cancel_turn_endpoint(request: Request) -> JSONResponse
BRIEF: Proxy upstream cancel for a registered turn.
PRE: [PRE-001 hard] (session_id, turn_id) in registry -- 404 if absent
POST: [POST-001 side_effect] upstream cancel call lands; registry entry removed
POST: [POST-002 return_value] 200 with {"cancelled": true} or 200 with status reflecting upstream race
STEPS:
1. [validate] sid, tid = params; handle = registry.get((sid, tid)); 404 if None
2. [not-started] IF handle.upstream_turn_id is None: del registry[(sid,tid)]; return 200 {"cancelled": false, "reason": "not_started"} # v0.16.0: upstream never opened
3. [cancel] async with client_factory() as client:
- try: await cancel_turn(client, sid, handle.upstream_turn_id) # v0.16.0: upstream id, not tid
- return 200 {"cancelled": true}
4. [race] EXCEPT CancelAlreadyCompleted / CancelTurnNotFound:
- return 200 {"cancelled": false, "reason": "race_or_completed"}
5. [cleanup] del registry[(sid, tid)]
TESTS:
happy [tracer]: registered turn (upstream_turn_id set) → POST cancel → 200, upstream cancel at the upstream id
unknown_turn [error]: not in registry → 404
cancel_before_started [v0.16.0]: upstream_turn_id None → 200 {cancelled:false, reason:not_started}, no upstream call
cancel_targets_upstream_turn_id [v0.16.0]: local tid != upstream id → cancel URL uses upstream id
already_completed [race]: respx cancel returns 409 → 200 with reason=race_or_completed
cancel_failed [error]: respx returns 500 → 500 with cancel_failed envelope
```
```contract
FN root_endpoint(request: Request) -> FileResponse
BRIEF: Serve the static index.html.
POST: [POST-001 return_value] FileResponse for ratatoskr/web/static/index.html, status 200, content-type text/html
STEPS:
1. [resolve] path = importlib.resources.files("ratatoskr.web") / "static" / "index.html"
2. [return] FileResponse(path, media_type="text/html")
TESTS:
happy [tracer]: GET / → 200, content-type text/html, body contains "<html"
```
```contract
FN lifespan_shutdown(app: Starlette) -> None
BRIEF: On Ctrl-C / SIGTERM, drain the turn registry within 5s budget per INV-006.
POST: [POST-001 side_effect] every in-flight upstream turn gets a cancel attempt within budget
POST: [POST-002 side_effect] entries that don't ack in budget logged + abandoned
STEPS:
1. [collect] in_flight = [h for h in registry.values() if h.status == "streaming" and h.upstream_turn_id is not None] # v0.16.0: skip not-yet-started
2. [cancel] async with client_factory() as client:
- task_to_handle = {create_task(cancel_turn(client, h.session_id, h.upstream_turn_id)): h for h in in_flight} # v0.16.0: upstream id
- done, pending = await asyncio.wait(task_to_handle, timeout=5.0)
3. [log] for each pending: cancel task + log {"kind": "shutdown", "event": "cleanup_timeout", "session_id": h.session_id, "upstream_turn_id": h.upstream_turn_id}
4. [clear] registry.clear()
TESTS:
happy [tracer]: 2 in-flight turns + shutdown → both upstream cancels called, registry empty
timeout [scenario]: 1 hanging cancel + 1 normal → normal succeeds, hanging logged as cleanup_timeout
```
+13 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.14.2"
version = "0.16.1"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
@@ -21,6 +21,10 @@ dependencies = [
]
[project.optional-dependencies]
web = [
"starlette>=0.40",
"uvicorn[standard]>=0.30",
]
dev = [
"pytest>=8",
"pytest-asyncio>=0.24",
@@ -29,10 +33,12 @@ dev = [
"mypy>=1.11",
"textual-dev>=1.5", # textual console + live reload during dev
"pyyaml>=6", # used by docs/contracts/contract_parser.py and scripts/contract_drift_check.py
"ratatoskr[web]", # web extras included in dev so test_web_* can import starlette
]
[project.scripts]
ratatoskr = "ratatoskr.cli:main"
ratatoskr = "ratatoskr.cli:main"
ratatoskr-web = "ratatoskr.web.entrypoint:main"
[project.urls]
Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
@@ -49,6 +55,11 @@ pinned-on = "2026-05-26"
[tool.hatch.build.targets.wheel]
packages = ["src/ratatoskr"]
# Issue #16: ship the web companion's static HTML in the wheel so
# importlib.resources can locate it post-install.
[tool.hatch.build.targets.wheel.force-include]
"src/ratatoskr/web/static" = "ratatoskr/web/static"
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
+12 -3
View File
@@ -269,12 +269,21 @@ async def get_persona_state(
resp = await client.get(f"/agents/{agent_id}/persona_state")
if resp.status_code == 200:
return resp.json()
# Discriminate the 4xx error_code sub-codes; everything else falls through.
# Discriminate the 4xx error_code sub-codes; everything else falls
# through. Worldtree returns errors as either flat `{"error_code": …}`
# OR FastAPI-default `{"detail": {"error_code": …}}` depending on
# which handler raised — unwrap both shapes (real wire observed
# 2026-05-28 returning the detail-nested form for auth_scope_denied
# from /agents/{id}/persona_state).
try:
err = resp.json()
error_code = err.get("error_code") if isinstance(err, dict) else None
except ValueError:
error_code = None
err = None
error_code: str | None = None
if isinstance(err, dict):
error_code = err.get("error_code")
if error_code is None and isinstance(err.get("detail"), dict):
error_code = err["detail"].get("error_code")
if resp.status_code == 404 and error_code == "persona_not_configured":
raise PersonaNotConfigured(agent_id=agent_id)
if resp.status_code == 404 and error_code == "agent_not_available":
+8
View File
@@ -0,0 +1,8 @@
"""ratatoskr.web — browser debug companion to the Ratatoskr TUI.
Per issue #16 INV-001: this module MUST NOT import starlette or
uvicorn at module top. Both live behind the optional `[web]` extras
group; importing them eagerly here would defeat the lazy-import
discipline that gives users without the extras a clean install hint
instead of a naked ImportError.
"""
+119
View File
@@ -0,0 +1,119 @@
"""Console-script entrypoint for `ratatoskr-web`.
Per docs/contracts/issues/16.contract.md FN main and INV-001:
- MUST NOT import starlette / uvicorn at module top
- Imports happen INSIDE main() after argparse, with ImportError caught
and converted to a clean `pip install ratatoskr[web]` exit
- Users without the [web] extras installed get a readable hint instead
of a naked ImportError traceback
"""
from __future__ import annotations
import argparse
import os
import sys
import webbrowser
from importlib.metadata import version as _pkg_version
def _build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="ratatoskr-web",
description="Browser-based debug companion to ratatoskr.",
)
p.add_argument(
"--host", default="0.0.0.0",
help="Bind address. Default: 0.0.0.0 (LAN-accessible). "
"Use 127.0.0.1 to restrict to localhost.",
)
p.add_argument(
"--port", type=int, default=8765,
help="Listen port. Default 8765. Use 0 for random free.",
)
p.add_argument(
"--open", action="store_true",
help="Auto-open the URL in the system browser.",
)
return p
def main(argv: list[str] | None = None) -> int:
"""Console-script entry. Per FN main.
Returns:
0 on clean shutdown
11 on missing WORLDTREE_API_KEY ([auth_error])
12 on missing [web] extras ([missing_extras])
"""
args = _build_arg_parser().parse_args(argv)
# Validate env BEFORE importing starlette so missing env shows the
# right error regardless of extras-install state.
api_key = os.environ.get("WORLDTREE_API_KEY")
if not api_key:
sys.stderr.write(
"[auth_error] WORLDTREE_API_KEY env var required. "
"Source env.sh in your project root.\n"
)
return 11
server_url = os.environ.get("WORLDTREE_API_URL", "http://localhost:8000")
end_user_id = os.environ.get("RATATOSKR_END_USER_ID")
# INV-001: lazy import. Users without [web] extras get a clean hint
# instead of a raw ImportError. Scoped narrowly to the OPTIONAL
# extras (starlette / uvicorn) so a real import bug inside a
# production module (ratatoskr.web.server, ratatoskr.cli, httpx —
# all baseline deps) propagates as a true traceback rather than
# being masked as "install ratatoskr[web]".
try:
import starlette # noqa: F401 (extras-presence probe)
import uvicorn
except ImportError as exc:
sys.stderr.write(
f"[missing_extras] {exc}\n"
f"ratatoskr-web requires the [web] optional dependencies.\n"
f"Install with: pip install ratatoskr[web]\n"
)
return 12
# Baseline deps + own modules — a failure here is a real bug, not a
# missing-extras condition; let it propagate.
import httpx
from ratatoskr.cli import USER_AGENT
from ratatoskr.web.server import create_app
def client_factory() -> "httpx.AsyncClient":
return httpx.AsyncClient(
base_url=server_url,
headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": USER_AGENT,
},
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
)
app = create_app(client_factory, end_user_id=end_user_id)
# Boot banner to stderr (so stdout stays clean for piping).
version = _pkg_version("ratatoskr")
host = args.host
port = args.port
display_host = "localhost" if host == "0.0.0.0" else host
sys.stderr.write(
f"ratatoskr-web v{version}\n"
f"Listening on http://{host}:{port}/\n"
f"Connect from this device: http://{display_host}:{port}/\n"
)
if host == "0.0.0.0":
sys.stderr.write(
f"Connect from LAN: http://<host-ip>:{port}/\n"
)
sys.stderr.write("Ctrl-C to stop.\n")
sys.stderr.flush()
if args.open:
webbrowser.open(f"http://{display_host}:{port}/")
uvicorn.run(app, host=host, port=port, log_config=None)
return 0
+416
View File
@@ -0,0 +1,416 @@
"""Starlette app factory + endpoint handlers for ratatoskr.web.
Per docs/contracts/issues/16.contract.md. INV-002: create_app accepts
a client_factory callable; the factory produces a configured
httpx.AsyncClient. Tests pass a respx-mocked factory; production
passes a factory that bakes in WORLDTREE_API_URL + WORLDTREE_API_KEY.
"""
from __future__ import annotations
import asyncio
import itertools
import json
from collections.abc import AsyncIterator, Callable
from dataclasses import asdict, dataclass, is_dataclass
from importlib.metadata import version as _pkg_version
import httpx
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import FileResponse, JSONResponse, StreamingResponse
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from ratatoskr import local_agents as _local_agents
from ratatoskr.sessions import (
AgentNotAvailable,
AgentNotFound,
AuthScopeDenied,
PersonaNotConfigured,
SessionApiFailed,
create_session,
get_persona_state,
list_agents,
)
from ratatoskr.sse_client import (
CancelAlreadyCompleted,
Cancelled,
CancelFailed,
CancelTurnNotFound,
Done,
Error,
MalformedSseData,
MalformedSseId,
SseConnectFailed,
SseConnectionDropped,
TurnIdFlip,
cancel_turn,
stream_turn,
)
def _static_dir() -> str:
"""Locate the bundled static/ directory inside the installed package.
Uses importlib.resources so the lookup works for editable installs,
wheel installs, and uvicorn's worker reload. Per INV-009 packaging:
static/index.html ships in the wheel.
"""
from importlib.resources import files
return str(files("ratatoskr.web") / "static")
def _root_endpoint(request: Request) -> FileResponse:
"""GET / → index.html. Per FN root_endpoint POST-001."""
from pathlib import Path
return FileResponse(
Path(_static_dir()) / "index.html",
media_type="text/html",
)
def _version_endpoint(request: Request) -> JSONResponse:
"""GET /version → {"ratatoskr": "<version>"}.
Per FN version_endpoint POST-001.
"""
return JSONResponse({"ratatoskr": _pkg_version("ratatoskr")}, status_code=200)
def _as_dict(obj: object) -> dict:
"""Best-effort dataclass-to-dict for AgentInfo / LocalAgentEntry."""
if is_dataclass(obj):
return asdict(obj)
return dict(obj) # type: ignore[arg-type]
async def _agents_endpoint(request: Request) -> JSONResponse:
"""GET /api/agents → upstream /agents + local Tier 3 index merge.
Per FN agents_endpoint POST-001 + ERRORS table.
"""
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
upstream = await list_agents(client)
except SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_api_failed", "status": exc.status},
status_code=exc.status,
)
except httpx.RequestError as exc:
return JSONResponse(
{"error_code": "network_error", "message": str(exc)},
status_code=502,
)
upstream_ids = {a.agent_id for a in upstream}
local = _local_agents.load_local_agents()
merged = [_as_dict(a) for a in upstream] + [
_as_dict(le) for le in local if le.agent_id not in upstream_ids
]
return JSONResponse(merged, status_code=200)
async def _create_session_endpoint(request: Request) -> JSONResponse:
"""POST /api/sessions → upstream POST /sessions. Per FN create_session_endpoint.
v0.16.0: end_user_id is SERVER-configured (app.state.end_user_id from
RATATOSKR_END_USER_ID), never read from the browser body. A client
cannot impersonate an arbitrary end-user partition.
"""
body = await request.json()
agent_id = body.get("agent_id") if isinstance(body, dict) else None
if not agent_id:
return JSONResponse({"error_code": "missing_agent_id"}, status_code=400)
end_user_id = request.app.state.end_user_id
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
info = await create_session(client, agent_id, end_user_id=end_user_id)
except AgentNotFound:
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
except SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_api_failed", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(_as_dict(info), status_code=201)
@dataclass
class TurnHandle:
"""In-flight turn record stored in app.state.turn_registry.
Per FN submit_turn_endpoint + INV-005/006/007.
v0.16.0: `upstream_turn_id` captures Worldtree's server-assigned
turn_id (from the first SSE event's sse_id.turn_id) once the stream
opens. Cancel paths target THIS, not the browser-local `turn_id` —
the local counter is only a registry key. None until the first
upstream event arrives; cancel before then is a no-op (nothing to
cancel upstream yet).
"""
session_id: str
turn_id: int
content: str
status: str = "queued" # queued | streaming | done | error | cancelled
upstream_turn_id: int | None = None
# Process-local monotonic turn_id counter. Per FN submit_turn_endpoint
# STEPS 2: turn_id is opaque to the upstream Worldtree (whose own
# turn_ids come back via SSE); the registry's key uses our own counter
# so cancel/stream lookups don't need upstream-issued ids.
_TURN_COUNTER = itertools.count(1)
async def _submit_turn_endpoint(request: Request) -> JSONResponse:
"""POST /api/turns/{session_id} → allocate turn_id + register handle.
Per FN submit_turn_endpoint. Does NOT open the upstream stream here;
the subsequent GET /api/turns/{sid}/stream does that.
"""
body = await request.json()
content = body.get("content") if isinstance(body, dict) else None
if not content:
return JSONResponse({"error_code": "missing_content"}, status_code=400)
session_id = request.path_params["session_id"]
turn_id = next(_TURN_COUNTER)
request.app.state.turn_registry[(session_id, turn_id)] = TurnHandle(
session_id=session_id, turn_id=turn_id, content=content,
)
return JSONResponse({"turn_id": turn_id}, status_code=200)
def _event_to_browser_payload(event: object) -> tuple[str, dict]:
"""Serialize an upstream Event dataclass to (browser_event_type, json_dict).
Per INV-008 + FN stream_turn_endpoint STEP 3. The dict shape is
locked by tests/fixtures/presentation_contract.json — one entry per
Event type. Implementation: snake_case class name as event_type;
asdict(event) with sse_id flattened to "T:S" string.
"""
type_name = type(event).__name__
# CamelCase → snake_case
browser_type = "".join(
("_" + c.lower() if c.isupper() and i else c.lower())
for i, c in enumerate(type_name)
)
data = asdict(event) # type: ignore[arg-type]
sse_id = data.get("sse_id")
if isinstance(sse_id, (list, tuple)) and len(sse_id) == 2:
data["sse_id"] = f"{sse_id[0]}:{sse_id[1]}"
elif isinstance(sse_id, dict) and "turn_id" in sse_id and "seq" in sse_id:
data["sse_id"] = f"{sse_id['turn_id']}:{sse_id['seq']}"
return browser_type, data
def _format_sse(event_type: str, data: dict) -> bytes:
"""Format a browser-facing SSE event with `event:` + `data:`.
Two-newline terminator per the SSE spec.
"""
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n".encode()
async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
"""GET /api/turns/{session_id}/stream?turn_id=N → proxy upstream SSE.
Per FN stream_turn_endpoint. Handles browser-disconnect cleanup
(INV-005) and synthesizes `event: error` for upstream typed
exceptions.
"""
session_id = request.path_params["session_id"]
try:
turn_id = int(request.query_params["turn_id"])
except (KeyError, ValueError):
return JSONResponse({"error_code": "missing_turn_id"}, status_code=400)
registry = request.app.state.turn_registry
handle = registry.get((session_id, turn_id))
if handle is None:
return JSONResponse({"error_code": "turn_not_found"}, status_code=404)
client_factory = request.app.state.client_factory
async def gen() -> AsyncIterator[bytes]:
client = client_factory()
try:
handle.status = "streaming"
try:
async for event in stream_turn(client, session_id, handle.content):
# v0.16.0: capture the upstream (Worldtree-assigned)
# turn_id from the first event so cancel paths target
# the real upstream turn, not our local counter.
if handle.upstream_turn_id is None:
sse_id = getattr(event, "sse_id", None)
if sse_id is not None:
handle.upstream_turn_id = sse_id.turn_id
event_type, data = _event_to_browser_payload(event)
yield _format_sse(event_type, data)
if isinstance(event, (Done, Error, Cancelled)):
handle.status = type(event).__name__.lower()
break
except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
MalformedSseData, TurnIdFlip) as exc:
yield _format_sse(
"error",
{"exception": type(exc).__name__, "message": str(exc)},
)
handle.status = "error"
except asyncio.CancelledError:
# Browser disconnect path (INV-005). Cancel the UPSTREAM
# turn (if it started) — never the local turn_id.
if handle.status == "streaming" and handle.upstream_turn_id is not None:
try:
await cancel_turn(client, session_id, handle.upstream_turn_id)
except (CancelAlreadyCompleted, CancelTurnNotFound):
pass # cooperative race — turn already terminal upstream
except Exception as exc:
# v0.16.1: unexpected cancel failure during disconnect
# cleanup (e.g. CancelFailed, transport error) — log for
# diagnosability instead of silently swallowing. Never
# re-raise: we're already unwinding the cancelled
# generator and must not mask the CancelledError.
import sys as _sys
_sys.stderr.write(
f'{{"kind":"disconnect_cancel","event":"cancel_failed",'
f'"session_id":"{session_id}",'
f'"upstream_turn_id":{handle.upstream_turn_id},'
f'"exc":"{type(exc).__name__}"}}\n'
)
raise
finally:
registry.pop((session_id, turn_id), None)
await client.aclose()
return StreamingResponse(gen(), media_type="text/event-stream")
async def _cancel_turn_endpoint(request: Request) -> JSONResponse:
"""POST /api/turns/{session_id}/cancel?turn_id=N. Per FN cancel_turn_endpoint."""
session_id = request.path_params["session_id"]
try:
turn_id = int(request.query_params["turn_id"])
except (KeyError, ValueError):
return JSONResponse({"error_code": "missing_turn_id"}, status_code=400)
registry = request.app.state.turn_registry
handle = registry.get((session_id, turn_id))
if handle is None:
return JSONResponse({"error_code": "turn_not_found"}, status_code=404)
# v0.16.0: cancel targets the UPSTREAM turn_id captured during
# streaming, not the browser-local turn_id. If the upstream stream
# never started (upstream_turn_id is None), there's nothing to
# cancel — clean up and report a no-op.
if handle.upstream_turn_id is None:
registry.pop((session_id, turn_id), None)
return JSONResponse(
{"cancelled": False, "reason": "not_started"}, status_code=200
)
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
await cancel_turn(client, session_id, handle.upstream_turn_id)
body = {"cancelled": True}
except (CancelAlreadyCompleted, CancelTurnNotFound):
body = {"cancelled": False, "reason": "race_or_completed"}
except CancelFailed as exc:
registry.pop((session_id, turn_id), None)
return JSONResponse(
{"error_code": "cancel_failed", "status": exc.status},
status_code=exc.status,
)
registry.pop((session_id, turn_id), None)
return JSONResponse(body, status_code=200)
async def _persona_state_endpoint(request: Request) -> JSONResponse:
"""GET /api/agents/{agent_id}/persona_state. Per FN persona_state_endpoint."""
agent_id = request.path_params["agent_id"]
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
snap = await get_persona_state(client, agent_id)
except PersonaNotConfigured:
return JSONResponse({"error_code": "persona_not_configured"}, status_code=404)
except AgentNotAvailable:
return JSONResponse({"error_code": "agent_not_available"}, status_code=404)
except AuthScopeDenied:
return JSONResponse({"error_code": "auth_scope_denied"}, status_code=403)
return JSONResponse(snap, status_code=200)
def create_app(
client_factory: Callable[[], httpx.AsyncClient],
*,
end_user_id: str | None = None,
) -> Starlette:
"""Construct the Starlette app — wire routes + state per FN create_app.
INV-002: app MUST NOT construct httpx.AsyncClient at module top;
everything HTTP-bound goes through client_factory.
INV-006: lifespan shutdown drains the turn registry within a 5s
budget — every in-flight turn gets a best-effort upstream cancel.
v0.16.0: `end_user_id` is the server-configured Worldtree end-user
partition (from RATATOSKR_END_USER_ID). Threaded into POST /sessions
server-side; never accepted from the browser.
"""
assert callable(client_factory)
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: Starlette):
yield
# Shutdown path — drain in-flight turns per INV-006. Cancel the
# UPSTREAM turn_id (v0.16.0); skip handles whose upstream stream
# never started (upstream_turn_id is None — nothing to cancel).
import sys as _sys
registry: dict[tuple[str, int], TurnHandle] = app.state.turn_registry
in_flight = [
h for h in registry.values()
if h.status == "streaming" and h.upstream_turn_id is not None
]
if in_flight:
client = client_factory()
try:
task_to_handle = {
asyncio.create_task(
cancel_turn(client, h.session_id, h.upstream_turn_id)
): h
for h in in_flight
}
done, pending = await asyncio.wait(task_to_handle, timeout=5.0)
# Per-pending session/turn detail (INV-006 logging fidelity).
for task in pending:
h = task_to_handle[task]
task.cancel()
_sys.stderr.write(
f'{{"kind":"shutdown","event":"cleanup_timeout",'
f'"session_id":"{h.session_id}",'
f'"upstream_turn_id":{h.upstream_turn_id}}}\n'
)
finally:
await client.aclose()
registry.clear()
routes = [
Route("/", _root_endpoint),
Mount("/static", app=StaticFiles(directory=_static_dir()), name="static"),
Route("/version", _version_endpoint),
Route("/api/agents", _agents_endpoint),
Route("/api/sessions", _create_session_endpoint, methods=["POST"]),
Route("/api/agents/{agent_id}/persona_state", _persona_state_endpoint),
Route("/api/turns/{session_id}", _submit_turn_endpoint, methods=["POST"]),
Route("/api/turns/{session_id}/stream", _stream_turn_endpoint),
Route("/api/turns/{session_id}/cancel", _cancel_turn_endpoint, methods=["POST"]),
]
app = Starlette(routes=routes, lifespan=lifespan)
app.state.client_factory = client_factory
app.state.end_user_id = end_user_id
# INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
app.state.turn_registry = {}
return app
+447
View File
@@ -0,0 +1,447 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>ratatoskr</title>
<style>
:root {
--bg: #000000;
--surface: #373b46;
--panel: #414751;
--fg: #a9bcc3;
--dark-50: #6e7882;
--dark-60: #86929d;
--bright-70: #9daeb6;
--bright-cyan: #42dcd1;
--bright-blue: #a4c4ff;
--aurora-green: #16B866;
--dawn-red: #ff491a;
--dawn-yellow: #e1c631;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 13px;
background: var(--bg);
color: var(--fg);
display: flex; flex-direction: column;
}
#persona-header {
flex: 0 0 auto;
height: 24px; padding: 4px 12px;
background: var(--surface); color: var(--bright-70);
display: none;
}
#persona-header.show { display: flex; align-items: center; }
#main {
flex: 1 1 auto;
display: grid;
grid-template-columns: 2fr 1fr;
overflow: hidden;
}
#left, #right { display: flex; flex-direction: column; overflow: hidden; }
#left { border-right: 1px solid var(--panel); }
#transcript { flex: 1 1 auto; overflow-y: auto; padding: 8px 12px; }
#transcript .turn-header { color: var(--dark-60); padding: 4px 0; }
#transcript .prompt-echo { color: var(--bright-cyan); font-weight: bold; padding: 4px 0; }
#transcript .response { white-space: pre-wrap; padding: 4px 0; }
#transcript .done-label { color: var(--aurora-green); padding: 4px 0; }
#transcript .error-label { color: var(--dawn-red); padding: 4px 0; }
#transcript .cancelled-label { color: var(--dawn-yellow); padding: 4px 0; }
#transcript .awaiting { color: var(--dark-60); padding: 4px 0; font-style: italic; }
#prompt-row {
flex: 0 0 auto;
padding: 8px 12px; border-top: 1px solid var(--panel);
}
#prompt-input {
width: 100%;
background: transparent; color: var(--fg);
border: 1px solid var(--dark-50); padding: 6px 8px;
font-family: inherit; font-size: inherit;
}
#right .tabs {
flex: 0 0 auto;
display: flex; gap: 4px; padding: 4px 8px;
border-bottom: 1px solid var(--panel);
}
#right .tab {
padding: 4px 12px; cursor: pointer;
color: var(--dark-60); user-select: none;
}
#right .tab.active { color: var(--bright-cyan); border-bottom: 2px solid var(--bright-cyan); }
#right .pane {
flex: 1 1 auto;
overflow-y: auto; padding: 8px 12px;
white-space: pre-wrap; word-break: break-word;
display: none;
}
#right .pane.active { display: block; }
#right .pane-actions {
padding: 4px 12px; border-top: 1px solid var(--panel);
display: flex; justify-content: flex-end;
}
#right .copy-btn {
background: transparent; color: var(--dark-60);
border: 1px solid var(--dark-50); padding: 2px 8px;
cursor: pointer; font-family: inherit; font-size: 11px;
}
#footer {
flex: 0 0 auto;
padding: 4px 12px; border-top: 1px solid var(--panel);
color: var(--dark-60); font-size: 11px;
display: flex; justify-content: space-between;
}
#setup {
flex: 1 1 auto; padding: 32px;
display: flex; flex-direction: column; gap: 12px;
}
#setup select { padding: 4px 8px; background: var(--surface); color: var(--fg); border: 1px solid var(--panel); }
#setup button { padding: 6px 14px; background: var(--bright-cyan); color: var(--bg); border: 0; cursor: pointer; }
</style>
</head>
<body>
<div id="persona-header"></div>
<div id="setup">
<h2 style="color: var(--bright-cyan); margin: 0;">ratatoskr-web</h2>
<p>Internal LAN debug surface. Pick an agent and start a session.</p>
<label>Agent:
<select id="agent-picker"></select>
</label>
<button id="start-btn">Start new session</button>
</div>
<div id="main" style="display: none;">
<div id="left">
<div id="transcript"></div>
<div id="prompt-row">
<input id="prompt-input" type="text"
placeholder="Type a message and press Enter (Ctrl-C cancels in-flight)" autofocus />
</div>
</div>
<div id="right">
<div class="tabs">
<div class="tab active" data-pane="tools">Tools</div>
<div class="tab" data-pane="debug">Debug</div>
<div class="tab" data-pane="thinking">Thinking</div>
<div class="tab" data-pane="persona">Persona</div>
</div>
<div class="pane active" id="pane-tools"></div>
<div class="pane" id="pane-debug"></div>
<div class="pane" id="pane-thinking"></div>
<div class="pane" id="pane-persona"></div>
<div class="pane-actions">
<button class="copy-btn" id="copy-btn">copy pane</button>
</div>
</div>
</div>
<div id="footer">
<span id="identity"></span>
<span id="version">ratatoskr —</span>
</div>
<script>
// ratatoskr-web — vanilla JS client.
// Five-pane debug surface; HTML-escapes assistant content (INV-004).
// Drift-detection contract: tests/fixtures/presentation_contract.json
"use strict";
const $ = (id) => document.getElementById(id);
const state = {
sessionId: null,
agentId: null,
turnId: null,
eventSource: null,
};
function esc(s) {
const d = document.createElement("div");
d.appendChild(document.createTextNode(String(s)));
return d.innerHTML;
}
function appendTo(paneId, html) {
const el = $(paneId);
el.insertAdjacentHTML("beforeend", html);
el.scrollTop = el.scrollHeight;
}
function ts() {
const d = new Date();
return d.toTimeString().slice(0, 8) + "." + String(d.getMilliseconds()).padStart(3, "0");
}
async function loadAgents() {
const r = await fetch("/api/agents");
const agents = await r.json();
const picker = $("agent-picker");
for (const a of agents) {
const opt = document.createElement("option");
opt.value = a.agent_id;
opt.textContent = a.agent_id + (a.name ? " · " + a.name : "");
picker.appendChild(opt);
}
}
async function loadVersion() {
try {
const r = await fetch("/version");
const v = await r.json();
$("version").textContent = "ratatoskr " + v.ratatoskr;
} catch (_) {}
}
async function loadPersona(agentId) {
try {
const r = await fetch("/api/agents/" + encodeURIComponent(agentId) + "/persona_state");
if (r.status === 200) {
const snap = await r.json();
renderPersona(snap);
$("persona-header").classList.add("show");
$("persona-header").textContent =
snap.agent_id + " · " + (snap.dominant_emotion || "?") +
" · pad(" + (snap.pad?.pleasure ?? "?") + ", " +
(snap.pad?.arousal ?? "?") + ", " +
(snap.pad?.dominance ?? "?") + ")";
} else {
appendTo("pane-persona",
'<div class="awaiting">(persona not available: ' + esc(r.status) + ')</div>');
}
} catch (e) {
appendTo("pane-persona", '<div class="awaiting">(persona fetch failed)</div>');
}
}
function renderPersona(snap) {
const html = "Persona snapshot · " + esc(snap.agent_id) + "\n\n" +
"Dominant emotion: " + esc(snap.dominant_emotion || "?") + "\n\n" +
"PAD: " + JSON.stringify(snap.pad) + "\n" +
"Baseline: " + JSON.stringify(snap.baseline_pad || {}) + "\n" +
"Mood drift: " + JSON.stringify(snap.mood_drift || {}) + "\n\n" +
"Active emotions: " + JSON.stringify(snap.emotions_active || []) + "\n\n" +
"Last updated: " + esc(snap.last_updated_at || "?");
$("pane-persona").textContent = html;
}
async function startSession() {
const agentId = $("agent-picker").value;
if (!agentId) return;
state.agentId = agentId;
// end_user_id is server-configured (RATATOSKR_END_USER_ID) — not sent
// from the browser. The server ignores any end_user_id in this body.
const r = await fetch("/api/sessions", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({agent_id: agentId}),
});
if (r.status !== 201) {
alert("create session failed: " + r.status);
return;
}
const info = await r.json();
state.sessionId = info.session_id;
$("identity").textContent = agentId + " · …" + info.session_id.slice(-8);
$("setup").style.display = "none";
$("main").style.display = "grid";
await loadPersona(agentId);
$("prompt-input").focus();
}
function clearPanes() {
$("pane-tools").innerHTML = "";
$("pane-debug").innerHTML = "";
$("pane-thinking").innerHTML = "";
}
function appendTranscriptResponse(text) {
// INV-004: transcript content is HTML-escaped (model output is untrusted).
let bubble = document.querySelector("#transcript .response.live");
if (!bubble) {
bubble = document.createElement("div");
bubble.className = "response live";
$("transcript").appendChild(bubble);
}
bubble.textContent += text;
$("transcript").scrollTop = $("transcript").scrollHeight;
}
function finalizeResponse() {
const live = document.querySelector("#transcript .response.live");
if (live) live.classList.remove("live");
}
async function submitPrompt() {
const input = $("prompt-input");
const content = input.value.trim();
if (!content || !state.sessionId) return;
input.value = "";
// Echo prompt
const echo = document.createElement("div");
echo.className = "prompt-echo";
echo.textContent = " " + content;
$("transcript").appendChild(echo);
// Submit POST → turn_id
const r = await fetch("/api/turns/" + encodeURIComponent(state.sessionId), {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({content}),
});
if (r.status !== 200) {
appendTo("pane-debug",
'<div>[' + ts() + '] submit_failed status=' + r.status + '</div>');
return;
}
const {turn_id} = await r.json();
state.turnId = turn_id;
// Turn-header divider in transcript
const head = document.createElement("div");
head.className = "turn-header";
head.textContent = "── turn " + turn_id + " ──";
$("transcript").appendChild(head);
// Open SSE stream
const url = "/api/turns/" + encodeURIComponent(state.sessionId) +
"/stream?turn_id=" + turn_id;
const es = new EventSource(url);
state.eventSource = es;
let textDeltas = 0, thinkingDeltas = 0, heartbeats = 0;
const turnStart = Date.now();
function audit(line) {
appendTo("pane-debug", "<div>[" + ts() + "] " + esc(line) + "</div>");
}
audit("turn_open turn_id=" + turn_id);
es.addEventListener("worker_phase", (e) => {
const d = JSON.parse(e.data);
audit("worker_phase phase=" + d.phase + " turn_id=" + d.turn_id);
});
es.addEventListener("thinking", (e) => {
const d = JSON.parse(e.data);
thinkingDeltas += 1;
appendTo("pane-thinking", esc(d.content));
});
es.addEventListener("text", (e) => {
const d = JSON.parse(e.data);
textDeltas += 1;
appendTranscriptResponse(d.content);
});
es.addEventListener("text_boundary", (e) => {
const d = JSON.parse(e.data);
audit("text_boundary kind=" + d.kind + " offset=" + d.char_offset);
});
es.addEventListener("tool_start", (e) => {
const d = JSON.parse(e.data);
appendTo("pane-tools",
"<div>· tool_start name=" + esc(d.name) + " args=" + esc(JSON.stringify(d.arguments)) + "</div>");
});
es.addEventListener("tool_result", (e) => {
const d = JSON.parse(e.data);
appendTo("pane-tools",
"<div>· tool_result name=" + esc(d.name) + " duration_ms=" + d.duration_ms + "</div>");
});
es.addEventListener("awaiting_llm_first_token", (e) => {
const d = JSON.parse(e.data);
heartbeats += 1;
let el = document.querySelector("#transcript .awaiting.live");
if (!el) {
el = document.createElement("div");
el.className = "awaiting live";
$("transcript").appendChild(el);
}
el.textContent = "awaiting first token · " +
(d.elapsed_ms_since_building_prompt / 1000).toFixed(1) + "s";
});
es.addEventListener("affect_update", (e) => {
const d = JSON.parse(e.data);
audit("affect_update status=" + d.status + " turn_id=" + d.turn_id);
if (d.status === "current" && d.snapshot) {
renderPersona(d.snapshot);
$("persona-header").classList.add("show");
$("persona-header").textContent =
d.snapshot.agent_id + " · " + (d.snapshot.dominant_emotion || "?") +
" · pad(" + (d.snapshot.pad?.pleasure ?? "?") + ", " +
(d.snapshot.pad?.arousal ?? "?") + ", " +
(d.snapshot.pad?.dominance ?? "?") + ")";
}
});
function terminal(label, cls, e) {
// Remove the awaiting indicator if it's still showing
const aw = document.querySelector("#transcript .awaiting.live");
if (aw) aw.remove();
finalizeResponse();
const div = document.createElement("div");
div.className = cls;
div.textContent = "[" + label + "] " + (e ? e.data : "");
$("transcript").appendChild(div);
const elapsed = Math.round((Date.now() - turnStart) / 100) / 10;
audit("turn_summary turn_id=" + turn_id +
" text_deltas=" + textDeltas +
" thinking_deltas=" + thinkingDeltas +
" heartbeats=" + heartbeats +
" elapsed_s=" + elapsed);
es.close();
state.eventSource = null;
state.turnId = null;
}
es.addEventListener("done", (e) => terminal("done", "done-label", e));
es.addEventListener("error", (e) => terminal("error", "error-label", e));
es.addEventListener("cancelled", (e) => terminal("cancelled", "cancelled-label", e));
es.onerror = () => {
// Connection lost — log to debug, don't terminate transcript with a label
audit("sse_connection_dropped turn_id=" + turn_id);
es.close();
state.eventSource = null;
};
}
async function cancelTurn() {
if (!state.sessionId || !state.turnId) return;
await fetch("/api/turns/" + encodeURIComponent(state.sessionId) +
"/cancel?turn_id=" + state.turnId, {method: "POST"});
}
// Tab switching
document.querySelectorAll("#right .tab").forEach((t) => {
t.addEventListener("click", () => {
document.querySelectorAll("#right .tab").forEach((x) => x.classList.remove("active"));
document.querySelectorAll("#right .pane").forEach((x) => x.classList.remove("active"));
t.classList.add("active");
$("pane-" + t.dataset.pane).classList.add("active");
});
});
// Ctrl+1..4 tab shortcuts (matches TUI)
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && ["1", "2", "3", "4"].includes(e.key)) {
const idx = parseInt(e.key, 10) - 1;
const tabs = document.querySelectorAll("#right .tab");
if (tabs[idx]) { tabs[idx].click(); e.preventDefault(); }
}
// Ctrl-C cancels in-flight turn
if (e.ctrlKey && e.key === "c" && state.turnId) {
cancelTurn(); e.preventDefault();
}
});
// Copy active pane to clipboard
$("copy-btn").addEventListener("click", () => {
const active = document.querySelector("#right .pane.active");
if (active) navigator.clipboard.writeText(active.textContent || "");
});
$("start-btn").addEventListener("click", startSession);
$("prompt-input").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitPrompt();
}
});
loadAgents();
loadVersion();
</script>
</body>
</html>
+125
View File
@@ -0,0 +1,125 @@
{
"_contract_version": "0.15.0",
"_provenance": "Issue #16 INV-008 — server-side proxy serialization shape. One entry per Event type defined in ratatoskr.sse_client. The JS presenter in static/index.html renders against the `data` shape; if either side changes, both must update in lockstep.",
"worker_phase": {
"event_type": "worker_phase",
"data": {
"sse_id": "42:3",
"phase": "BuildingPrompt",
"turn_id": 42
}
},
"thinking": {
"event_type": "thinking",
"data": {
"sse_id": "42:5",
"content": "Let me think..."
}
},
"text": {
"event_type": "text",
"data": {
"sse_id": "42:7",
"content": "Hello there"
}
},
"text_boundary": {
"event_type": "text_boundary",
"data": {
"sse_id": "42:8",
"kind": "sentence",
"char_offset": 11,
"ts": "2026-05-28T00:00:00Z"
}
},
"tool_start": {
"event_type": "tool_start",
"data": {
"sse_id": "42:9",
"name": "search",
"arguments": {"q": "ratatoskr"}
}
},
"tool_result": {
"event_type": "tool_result",
"data": {
"sse_id": "42:10",
"name": "search",
"result": {"n": 1},
"duration_ms": 12
}
},
"done": {
"event_type": "done",
"data": {
"sse_id": "42:11",
"phase": "succeeded",
"response": "Hello there",
"model": "qwen3.6-35-a3b",
"duration_ms": 1234,
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50,
"total_tokens": 150,
"cached_input_tokens": 0
}
}
},
"error": {
"event_type": "error",
"data": {
"sse_id": "42:11",
"phase": "failed",
"message": "llm output invalid",
"error_code": "llm_output_invalid"
}
},
"cancelled": {
"event_type": "cancelled",
"data": {
"sse_id": "42:11",
"phase": "cancelled",
"turn_id": 42,
"reason": "user_cancel",
"partial_message_id": null
}
},
"affect_update": {
"event_type": "affect_update",
"data": {
"sse_id": "42:1",
"status": "current",
"turn_id": 42,
"snapshot": {
"agent_id": "mimir",
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
"dominant_emotion": "curiosity",
"emotions_active": [
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
],
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
"last_updated_at": "2026-05-28T00:00:00+00:00"
}
}
},
"awaiting_llm_first_token": {
"event_type": "awaiting_llm_first_token",
"data": {
"sse_id": "42:2",
"turn_id": 42,
"elapsed_ms_since_building_prompt": 5012.3
}
}
}
+40
View File
@@ -665,3 +665,43 @@ class TestGetPersonaState:
with pytest.raises(SessionApiFailed) as exc_info:
await get_persona_state(client, "mimir")
assert exc_info.value.status == 500
@respx.mock
async def test_auth_scope_denied_detail_envelope(self) -> None:
"""auth_scope_denied_detail_envelope [regression]: real Worldtree
returns `{"detail": {"error_code": "auth_scope_denied", …}}`
(FastAPI default), not flat `{"error_code": …}`. Smoke against
personal:8081 2026-05-28 surfaced this — pre-fix the response
fell through to SessionApiFailed(403) instead of AuthScopeDenied.
"""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(
403,
json={
"detail": {
"error_code": "auth_scope_denied",
"message": "Missing required scope: persona.read",
}
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AuthScopeDenied) as exc_info:
await get_persona_state(client, "mimir")
assert exc_info.value.scope == "persona.read"
@respx.mock
async def test_persona_not_configured_detail_envelope(self) -> None:
"""persona_not_configured_detail_envelope [regression]: same
envelope-shape unwrap on 404 + persona_not_configured.
"""
respx.get("https://w.example/agents/domari/persona_state").mock(
return_value=httpx.Response(
404,
json={"detail": {"error_code": "persona_not_configured"}},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(PersonaNotConfigured) as exc_info:
await get_persona_state(client, "domari")
assert exc_info.value.agent_id == "domari"
+154
View File
@@ -0,0 +1,154 @@
"""Packaging + lazy-import discipline tests for ratatoskr.web per issue #16.
- `index.html` resolvable via importlib.resources (ships in wheel)
- `ratatoskr.web.entrypoint` importable without starlette+uvicorn,
prints install-hint and exits non-zero in that mode
"""
from importlib.resources import files
def test_index_html_in_package() -> None:
"""static/index.html is locatable via importlib.resources.
INV-009 packaging discipline. The asset must be part of the
installed package — `_static_dir()` in the server uses this exact
resolution path at startup.
"""
path = files("ratatoskr.web") / "static" / "index.html"
assert path.is_file(), f"index.html missing at {path}"
content = path.read_text()
assert "<html" in content
assert "ratatoskr-web" in content
def test_entrypoint_no_top_level_starlette_import() -> None:
"""INV-001: importing `ratatoskr.web.entrypoint` MUST NOT import
starlette or uvicorn at the module level. Verified by AST inspection
of the source — checks no top-level `import starlette` /
`from starlette` / `import uvicorn` / `from uvicorn` statements.
"""
import ast
from importlib.resources import files
src = (files("ratatoskr.web") / "entrypoint.py").read_text()
tree = ast.parse(src)
banned = {"starlette", "uvicorn"}
for node in tree.body:
if isinstance(node, ast.Import):
for alias in node.names:
top = alias.name.split(".")[0]
assert top not in banned, (
f"top-level `import {alias.name}` violates INV-001 "
"lazy-import discipline"
)
elif isinstance(node, ast.ImportFrom):
mod = (node.module or "").split(".")[0]
assert mod not in banned, (
f"top-level `from {node.module} import ...` violates "
"INV-001 lazy-import discipline"
)
def test_entrypoint_missing_api_key_returns_11(monkeypatch) -> None:
"""missing_api_key [error]: WORLDTREE_API_KEY unset → exit 11."""
monkeypatch.delenv("WORLDTREE_API_KEY", raising=False)
from ratatoskr.web.entrypoint import main
rc = main(["--port", "0"])
assert rc == 11
def test_entrypoint_missing_extras_returns_12(monkeypatch) -> None:
"""missing_extras [error]: starlette unimportable → exit 12.
v0.16.0: the missing-extras probe is scoped to the OPTIONAL extras
(starlette / uvicorn) only. Simulated by shadowing `starlette` to
None in sys.modules so its import raises ImportError inside the
narrow try-block.
"""
import sys
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
monkeypatch.setenv("WORLDTREE_API_URL", "https://example.com")
monkeypatch.setitem(sys.modules, "starlette", None)
from ratatoskr.web.entrypoint import main
rc = main(["--port", "0"])
assert rc == 12
def test_entrypoint_real_import_bug_propagates(monkeypatch) -> None:
"""v0.16.0: an ImportError from a BASELINE module (not an extra) MUST
propagate as a real traceback, not be masked as exit-12 missing-extras.
Shadowing ratatoskr.web.server (a first-party module, present with or
without the [web] extras) should raise, not return 12.
"""
import sys
import pytest as _pytest
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
monkeypatch.setenv("WORLDTREE_API_URL", "https://example.com")
monkeypatch.setitem(sys.modules, "ratatoskr.web.server", None)
from ratatoskr.web.entrypoint import main
with _pytest.raises(ImportError):
main(["--port", "0"])
class TestEntrypointArgs:
"""FN main argparse + serve-loop traces (contract TESTS)."""
def test_default_host_is_zero(self) -> None:
"""default_host_is_zero [trace]: argv=[] → host == '0.0.0.0'."""
from ratatoskr.web.entrypoint import _build_arg_parser
args = _build_arg_parser().parse_args([])
assert args.host == "0.0.0.0"
assert args.port == 8765
assert args.open is False
def test_port_zero_supported(self) -> None:
"""port_zero_supported [trace]: argv=['--port','0'] → port == 0."""
from ratatoskr.web.entrypoint import _build_arg_parser
args = _build_arg_parser().parse_args(["--port", "0"])
assert args.port == 0
def test_happy_argv_serves_and_returns_zero(self, monkeypatch) -> None:
"""happy_argv [tracer]: env set + uvicorn.run mocked → main returns 0."""
import uvicorn
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
monkeypatch.setenv("WORLDTREE_API_URL", "https://example.com")
calls = {}
monkeypatch.setattr(uvicorn, "run", lambda app, **kw: calls.update(kw))
from ratatoskr.web.entrypoint import main
rc = main(["--port", "0"])
assert rc == 0
assert calls["host"] == "0.0.0.0"
assert calls["port"] == 0
def test_open_flag_calls_webbrowser(self, monkeypatch) -> None:
"""open_flag_calls_webbrowser [trace]: --open → webbrowser.open called."""
import uvicorn
import webbrowser
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
monkeypatch.setattr(uvicorn, "run", lambda app, **kw: None)
opened = []
monkeypatch.setattr(webbrowser, "open", lambda url: opened.append(url))
from ratatoskr.web.entrypoint import main
main(["--port", "0", "--open"])
assert len(opened) == 1
def test_no_open_default(self, monkeypatch) -> None:
"""no_open_default [trace]: argv without --open → webbrowser.open NOT called."""
import uvicorn
import webbrowser
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
monkeypatch.setattr(uvicorn, "run", lambda app, **kw: None)
opened = []
monkeypatch.setattr(webbrowser, "open", lambda url: opened.append(url))
from ratatoskr.web.entrypoint import main
main(["--port", "0"])
assert opened == []
+162
View File
@@ -0,0 +1,162 @@
"""Drift-detection between TUI presentation discipline and web JS
presenter per issue #16 INV-008.
The JSON fixture at `tests/fixtures/presentation_contract.json`
enumerates the expected browser-facing event payload for each Event
type. Server-side serialization (`_event_to_browser_payload`) is
unit-tested against the fixture. JS-side rendering in
`src/ratatoskr/web/static/index.html` consumes the same shape — if
this fixture changes, both sides update in lockstep.
"""
from __future__ import annotations
import json
from pathlib import Path
from ratatoskr.sse_client import (
AffectUpdate,
AwaitingLlmFirstToken,
Cancelled,
Done,
Error,
SseId,
Text,
TextBoundary,
Thinking,
ToolResult,
ToolStart,
WorkerPhase,
)
from ratatoskr.web.server import _event_to_browser_payload
def _load_fixture() -> dict:
path = Path(__file__).parent / "fixtures" / "presentation_contract.json"
return json.loads(path.read_text())
def _check(name: str, event: object) -> None:
"""Assert (event_type, data) for `event` matches the fixture entry."""
fixture = _load_fixture()
assert name in fixture, f"fixture missing entry for {name!r}"
expected = fixture[name]
event_type, data = _event_to_browser_payload(event)
assert event_type == expected["event_type"], (
f"{name}: event_type {event_type!r} != fixture {expected['event_type']!r}"
)
assert data == expected["data"], (
f"{name}: data mismatch\n got: {data}\n fixture: {expected['data']}"
)
def test_worker_phase_matches_fixture() -> None:
_check(
"worker_phase",
WorkerPhase(sse_id=SseId(42, 3), phase="BuildingPrompt", turn_id=42),
)
def test_thinking_matches_fixture() -> None:
_check(
"thinking",
Thinking(sse_id=SseId(42, 5), content="Let me think..."),
)
def test_text_matches_fixture() -> None:
_check(
"text",
Text(sse_id=SseId(42, 7), content="Hello there"),
)
def test_text_boundary_matches_fixture() -> None:
_check(
"text_boundary",
TextBoundary(
sse_id=SseId(42, 8), kind="sentence",
char_offset=11, ts="2026-05-28T00:00:00Z",
),
)
def test_tool_start_matches_fixture() -> None:
_check(
"tool_start",
ToolStart(sse_id=SseId(42, 9), name="search", arguments={"q": "ratatoskr"}),
)
def test_tool_result_matches_fixture() -> None:
_check(
"tool_result",
ToolResult(
sse_id=SseId(42, 10), name="search",
result={"n": 1}, duration_ms=12,
),
)
def test_done_matches_fixture() -> None:
_check(
"done",
Done(
sse_id=SseId(42, 11), phase="succeeded", response="Hello there",
model="qwen3.6-35-a3b", duration_ms=1234,
usage={
"prompt_tokens": 100, "completion_tokens": 50,
"total_tokens": 150, "cached_input_tokens": 0,
},
),
)
def test_error_matches_fixture() -> None:
_check(
"error",
Error(
sse_id=SseId(42, 11), phase="failed",
message="llm output invalid", error_code="llm_output_invalid",
),
)
def test_cancelled_matches_fixture() -> None:
_check(
"cancelled",
Cancelled(
sse_id=SseId(42, 11), phase="cancelled", turn_id=42,
reason="user_cancel", partial_message_id=None,
),
)
def test_affect_update_matches_fixture() -> None:
_check(
"affect_update",
AffectUpdate(
sse_id=SseId(42, 1), status="current", turn_id=42,
snapshot={
"agent_id": "mimir",
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
"dominant_emotion": "curiosity",
"emotions_active": [
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
],
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
"last_updated_at": "2026-05-28T00:00:00+00:00",
},
),
)
def test_awaiting_llm_first_token_matches_fixture() -> None:
_check(
"awaiting_llm_first_token",
AwaitingLlmFirstToken(
sse_id=SseId(42, 2), turn_id=42,
elapsed_ms_since_building_prompt=5012.3,
),
)
+794
View File
@@ -0,0 +1,794 @@
"""Tests for ratatoskr.web.server per docs/contracts/issues/16.contract.md.
The TestClient drives the Starlette app with a respx-mocked upstream
httpx client. No live network. See INV-002: create_app takes a
client_factory callable; tests pass a factory returning a respx-mocked
AsyncClient.
"""
import httpx
import pytest
import respx
from starlette.testclient import TestClient
def _mock_client_factory() -> "object":
"""A client_factory that returns a no-base-url AsyncClient suitable
for respx-mocking absolute URLs. Endpoints that hit upstream use the
same factory in tests as in prod; respx intercepts at the transport
layer.
"""
def factory() -> httpx.AsyncClient:
return httpx.AsyncClient(base_url="https://w.example")
return factory
class TestVersionEndpoint:
"""version_endpoint FN — tracer per contract issue #16."""
def test_happy_returns_current_version(self) -> None:
"""happy [tracer]: GET /version → 200, body == {"ratatoskr": "<current-version>"}.
Validates: Starlette app boots, route registers, JSON shape correct,
version derived from package metadata (importlib.metadata).
"""
from importlib.metadata import version
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
client = TestClient(app)
resp = client.get("/version")
assert resp.status_code == 200
assert resp.json() == {"ratatoskr": version("ratatoskr")}
class TestAgentsEndpoint:
"""agents_endpoint FN — proxy upstream /agents + merge with local tier3 index."""
@respx.mock
def test_happy_merges_upstream_and_local(self, monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
"""happy [tracer]: respx mock /agents 200 → response merges upstream + local index."""
# Isolate local agents index to tmp
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json"))
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{"agent_id": "mimir", "name": "Mimir", "description": "k"},
],
)
)
# Add one local tier3 agent to the index
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
add_local_agent(
LocalAgentEntry(
agent_id="ratatoskr:sindra",
agent_name="sindra",
model="artemis-31b-v1i",
description="(tier 3) IDENTITY",
defined_at="2026-05-28T00:00:00+00:00",
)
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
client = TestClient(app)
resp = client.get("/api/agents")
assert resp.status_code == 200
body = resp.json()
ids = [a["agent_id"] for a in body]
assert "mimir" in ids
assert "ratatoskr:sindra" in ids
@respx.mock
def test_upstream_500_returns_500_envelope(self, monkeypatch, tmp_path) -> None:
"""upstream_500 [error]: respx 500 → 500 with error_code envelope."""
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json"))
respx.get("https://w.example/agents").mock(return_value=httpx.Response(500, content=b"boom"))
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/agents")
assert resp.status_code == 500
assert resp.json()["error_code"] == "session_api_failed"
@respx.mock
def test_network_error_returns_502(self, monkeypatch, tmp_path) -> None:
"""network_error [error]: connection refused → 502 with network_error envelope."""
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json"))
respx.get("https://w.example/agents").mock(side_effect=httpx.ConnectError("refused"))
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/agents")
assert resp.status_code == 502
assert resp.json()["error_code"] == "network_error"
@respx.mock
def test_local_dedup(self, monkeypatch, tmp_path) -> None:
"""local_dedup [scenario]: local entry with same agent_id as upstream → no duplicate."""
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json"))
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[{"agent_id": "ratatoskr:sindra", "name": "Sindra-from-server", "description": ""}],
)
)
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
add_local_agent(
LocalAgentEntry(
agent_id="ratatoskr:sindra", agent_name="sindra", model="m",
description="local-tier3", defined_at="2026-05-28T00:00:00+00:00",
)
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/agents")
assert resp.status_code == 200
body = resp.json()
ids = [a["agent_id"] for a in body]
assert ids.count("ratatoskr:sindra") == 1
# Upstream entry wins (it's first in the merge); local is deduped
assert body[0]["name"] == "Sindra-from-server"
_CREATE_OK = {
"session_id": "s-1",
"agent_id": "mimir",
"message_count": 0,
"created_at": "2026-05-28T00:00:00+00:00",
"last_active": "2026-05-28T00:00:00+00:00",
"metadata": {},
}
class TestCreateSessionEndpoint:
"""create_session_endpoint FN — proxy POST /sessions to upstream."""
@respx.mock
def test_happy_returns_201(self) -> None:
"""happy [tracer]: respx mock 201 → endpoint returns 201 with session JSON."""
respx.post("https://w.example/sessions").mock(return_value=httpx.Response(201, json=_CREATE_OK))
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).post("/api/sessions", json={"agent_id": "mimir"})
assert resp.status_code == 201
assert resp.json()["session_id"] == "s-1"
@respx.mock
def test_unknown_agent_returns_404(self) -> None:
"""unknown_agent [error]: respx 404 → 404 with agent_not_found envelope."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(404, json={"error": "unknown_agent_id"})
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).post("/api/sessions", json={"agent_id": "ghost"})
assert resp.status_code == 404
assert resp.json()["error_code"] == "agent_not_found"
def test_missing_agent_id_returns_400(self) -> None:
"""missing_agent_id [adversarial]: body without agent_id → 400."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).post("/api/sessions", json={})
assert resp.status_code == 400
_SNAPSHOT = {
"agent_id": "mimir",
"pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5},
"dominant_emotion": "curiosity",
}
class TestPersonaStateEndpoint:
"""persona_state_endpoint FN — proxy upstream GET /agents/{id}/persona_state."""
@respx.mock
def test_happy_returns_snapshot(self) -> None:
"""happy [tracer]: respx 200 → 200 with snapshot."""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(200, json=_SNAPSHOT)
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/agents/mimir/persona_state")
assert resp.status_code == 200
assert resp.json()["dominant_emotion"] == "curiosity"
@respx.mock
def test_persona_not_configured(self) -> None:
"""persona_not_configured [error]: 404 + persona_not_configured → 404 envelope."""
respx.get("https://w.example/agents/domari/persona_state").mock(
return_value=httpx.Response(404, json={"error_code": "persona_not_configured"})
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/agents/domari/persona_state")
assert resp.status_code == 404
assert resp.json()["error_code"] == "persona_not_configured"
@respx.mock
def test_agent_not_available(self) -> None:
"""agent_not_available [error]: 404 + agent_not_available → 404 envelope."""
respx.get("https://w.example/agents/bogus/persona_state").mock(
return_value=httpx.Response(404, json={"error_code": "agent_not_available"})
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/agents/bogus/persona_state")
assert resp.status_code == 404
assert resp.json()["error_code"] == "agent_not_available"
@respx.mock
def test_auth_scope_denied(self) -> None:
"""auth_scope_denied [error]: 403 + auth_scope_denied → 403 envelope."""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/agents/mimir/persona_state")
assert resp.status_code == 403
assert resp.json()["error_code"] == "auth_scope_denied"
class TestSubmitTurnEndpoint:
"""submit_turn_endpoint FN — allocate turn_id, register in turn_registry."""
def test_happy_returns_turn_id(self) -> None:
"""happy [tracer]: POST {"content": "hi"} → 200 with turn_id; registry populated."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).post("/api/turns/s-1", json={"content": "hello"})
assert resp.status_code == 200
body = resp.json()
assert isinstance(body["turn_id"], int)
assert body["turn_id"] > 0
# Registry has the entry
handle = app.state.turn_registry[("s-1", body["turn_id"])]
assert handle.content == "hello"
assert handle.status == "queued"
def test_missing_content_returns_400(self) -> None:
"""missing_content [adversarial]: body without content → 400."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).post("/api/turns/s-1", json={})
assert resp.status_code == 400
def test_monotonic_turn_ids(self) -> None:
"""monotonic_turn_ids [trace]: two submits → second turn_id > first."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
c = TestClient(app)
first = c.post("/api/turns/s-1", json={"content": "a"}).json()["turn_id"]
second = c.post("/api/turns/s-2", json={"content": "b"}).json()["turn_id"]
assert second > first
_DONE_BODY = {
"type": "done",
"phase": "succeeded",
"response": "hello",
"model": "m",
"duration_ms": 1,
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "cached_input_tokens": 0},
}
def _sse_chunk(sse_id: str, body: dict) -> bytes:
import json as _j
return f"id: {sse_id}\ndata: {_j.dumps(body)}\n\n".encode()
def _sse_resp(stream: bytes) -> httpx.Response:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=stream)
def _sse_resp_stream(body: httpx.AsyncByteStream) -> httpx.Response:
"""SSE response backed by a live AsyncByteStream (for gated/hanging
streams in disconnect tests)."""
return httpx.Response(
200, headers={"content-type": "text/event-stream"}, stream=body
)
def _parse_browser_sse(raw: bytes) -> list[dict]:
"""Parse a server-to-browser SSE stream into [{"event": str, "data": dict}, ...]."""
import json as _j
events: list[dict] = []
for block in raw.decode().split("\n\n"):
block = block.strip()
if not block:
continue
event_type = None
data_str = None
for line in block.splitlines():
if line.startswith("event: "):
event_type = line[len("event: "):]
elif line.startswith("data: "):
data_str = line[len("data: "):]
if event_type and data_str is not None:
events.append({"event": event_type, "data": _j.loads(data_str)})
return events
class TestStreamTurnEndpoint:
"""stream_turn_endpoint FN — open upstream SSE, proxy events to browser."""
@respx.mock
def test_happy_text_done(self) -> None:
"""happy [tracer]: respx mock one text+done → SSE stream yields text + done events."""
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk("42:2", _DONE_BODY)
respx.post("https://w.example/sessions/s-1/messages").mock(return_value=_sse_resp(stream))
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
c = TestClient(app)
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
with c.stream("GET", f"/api/turns/s-1/stream?turn_id={turn_id}") as resp:
assert resp.status_code == 200
raw = b"".join(resp.iter_bytes())
events = _parse_browser_sse(raw)
types = [e["event"] for e in events]
assert "text" in types
assert "done" in types
# Registry cleaned up
assert ("s-1", turn_id) not in app.state.turn_registry
def test_unknown_turn_returns_404(self) -> None:
"""unknown_turn [error]: GET with turn_id not in registry → 404."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/turns/s-x/stream?turn_id=999")
assert resp.status_code == 404
@respx.mock
def test_upstream_error_synthetic_event(self) -> None:
"""upstream_error [error]: respx 500 → synthetic error SSE event."""
respx.post("https://w.example/sessions/s-1/messages").mock(
return_value=httpx.Response(500, content=b"boom")
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
c = TestClient(app)
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
with c.stream("GET", f"/api/turns/s-1/stream?turn_id={turn_id}") as resp:
raw = b"".join(resp.iter_bytes())
events = _parse_browser_sse(raw)
types = [e["event"] for e in events]
assert "error" in types
# Surfaces upstream's exception type for the operator
err = next(e for e in events if e["event"] == "error")
assert err["data"]["exception"] == "SseConnectFailed"
_CANCEL_OK = {"turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None}
class TestCancelTurnEndpoint:
"""cancel_turn_endpoint FN — proxy upstream cancel for registered turn."""
@respx.mock
def test_happy_cancels(self) -> None:
"""happy [tracer]: registered turn (upstream started) → POST cancel
→ 200, upstream cancel called against the upstream turn_id.
"""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
c = TestClient(app)
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
# Simulate that the upstream stream has started (turn_id 42 upstream).
app.state.turn_registry[("s-1", turn_id)].status = "streaming"
app.state.turn_registry[("s-1", turn_id)].upstream_turn_id = 42
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(200, json={**_CANCEL_OK, "turn_id": 42})
)
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
assert resp.status_code == 200
assert resp.json()["cancelled"] is True
assert ("s-1", turn_id) not in app.state.turn_registry
def test_unknown_turn_returns_404(self) -> None:
"""unknown_turn [error]: not in registry → 404."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).post("/api/turns/s-x/cancel?turn_id=999")
assert resp.status_code == 404
@respx.mock
def test_already_completed_race(self) -> None:
"""already_completed [race]: upstream 409 → 200 reason=race_or_completed."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
c = TestClient(app)
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
app.state.turn_registry[("s-1", turn_id)].status = "streaming"
app.state.turn_registry[("s-1", turn_id)].upstream_turn_id = 42
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(409)
)
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
assert resp.status_code == 200
assert resp.json()["cancelled"] is False
assert resp.json()["reason"] == "race_or_completed"
@respx.mock
def test_cancel_failed_500(self) -> None:
"""cancel_failed [error]: upstream 500 → 500 with cancel_failed envelope."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
c = TestClient(app)
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
app.state.turn_registry[("s-1", turn_id)].status = "streaming"
app.state.turn_registry[("s-1", turn_id)].upstream_turn_id = 42
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(500, content=b"boom")
)
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
assert resp.status_code == 500
assert resp.json()["error_code"] == "cancel_failed"
assert ("s-1", turn_id) not in app.state.turn_registry
class TestStaticServing:
"""root_endpoint FN + /static mount — index.html + static asset serving."""
def test_root_returns_html(self) -> None:
"""happy [tracer]: GET / → 200, content-type text/html, body contains '<html'."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
assert "<html" in resp.text
class TestLifespanShutdown:
"""lifespan_shutdown FN — INV-006: drain turn_registry within 5s budget."""
@respx.mock
def test_happy_drains_registry(self) -> None:
"""happy [tracer]: 2 in-flight turns + shutdown → upstream cancels called."""
from ratatoskr.web.server import TurnHandle, create_app
cancel_routes = []
for tid in (101, 102):
cancel_routes.append(
respx.post(f"https://w.example/sessions/s-1/turns/{tid}/cancel").mock(
return_value=httpx.Response(200, json={
"turn_id": tid, "cancelled": True, "reason": None,
"partial_message_id": None,
})
)
)
app = create_app(_mock_client_factory())
with TestClient(app) as client:
# Two turns in-flight (status=streaming, upstream_turn_id set =
# local tid here for test simplicity).
for tid in (101, 102):
app.state.turn_registry[("s-1", tid)] = TurnHandle(
session_id="s-1", turn_id=tid, content="x",
status="streaming", upstream_turn_id=tid,
)
# The exit of the `with` triggers lifespan shutdown
# After lifespan shutdown:
for route in cancel_routes:
assert route.called, "upstream cancel should have been issued for each in-flight turn"
assert app.state.turn_registry == {}
class TestUpstreamTurnIdCancel:
"""v0.16.0 — cancel paths must target the UPSTREAM turn_id, not the
browser-local turn_id. The local _TURN_COUNTER allocates 1,2,3…; the
real upstream turn_id only arrives via the first SSE event's
sse_id.turn_id. Heid panel (Hulda) load-bearing finding.
"""
@respx.mock
def test_cancel_targets_upstream_turn_id(self) -> None:
"""A registered handle whose local turn_id (1) differs from its
captured upstream_turn_id (42) → POST cancel hits the UPSTREAM URL
/sessions/s-1/turns/42/cancel, not /turns/1/cancel.
"""
from ratatoskr.web.server import TurnHandle, create_app
# Only the upstream-id cancel URL is mocked. If the code uses the
# local id (1), it'll miss this route → the test catches the bug.
upstream_route = respx.post(
"https://w.example/sessions/s-1/turns/42/cancel"
).mock(return_value=httpx.Response(200, json={
"turn_id": 42, "cancelled": True, "reason": None,
"partial_message_id": None,
}))
app = create_app(_mock_client_factory())
app.state.turn_registry[("s-1", 1)] = TurnHandle(
session_id="s-1", turn_id=1, content="x",
status="streaming", upstream_turn_id=42,
)
resp = TestClient(app).post("/api/turns/s-1/cancel?turn_id=1")
assert resp.status_code == 200
assert resp.json()["cancelled"] is True
assert upstream_route.called, "cancel must target the upstream turn_id"
assert ("s-1", 1) not in app.state.turn_registry
def test_cancel_before_upstream_started_is_noop(self) -> None:
"""A handle with upstream_turn_id still None (turn never opened the
upstream stream) → cancel is a no-op: 200 {cancelled: false,
reason: not_started}, no upstream call, registry cleaned.
"""
from ratatoskr.web.server import TurnHandle, create_app
app = create_app(_mock_client_factory())
app.state.turn_registry[("s-1", 1)] = TurnHandle(
session_id="s-1", turn_id=1, content="x",
status="queued", upstream_turn_id=None,
)
resp = TestClient(app).post("/api/turns/s-1/cancel?turn_id=1")
assert resp.status_code == 200
assert resp.json()["cancelled"] is False
assert resp.json()["reason"] == "not_started"
assert ("s-1", 1) not in app.state.turn_registry
@respx.mock
def test_stream_captures_upstream_turn_id(self) -> None:
"""The stream generator captures upstream turn_id from the first
event's sse_id. After a happy text+done stream against upstream
turn 42 (local turn 1), the cancel mid-stream would have targeted 42.
Verified indirectly: drive the stream, assert events carry turn 42.
"""
stream = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk("42:2", _DONE_BODY)
respx.post("https://w.example/sessions/s-1/messages").mock(return_value=_sse_resp(stream))
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
c = TestClient(app)
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
assert turn_id == 1 or turn_id > 0 # local counter
with c.stream("GET", f"/api/turns/s-1/stream?turn_id={turn_id}") as resp:
raw = b"".join(resp.iter_bytes())
events = _parse_browser_sse(raw)
# Every browser-facing event carries the upstream turn_id (42), not local
text_ev = next(e for e in events if e["event"] == "text")
assert text_ev["data"]["sse_id"].startswith("42:")
class TestServerSideEndUserId:
"""v0.16.0 — end_user_id is server-configured (RATATOSKR_END_USER_ID via
create_app), NOT accepted from the browser body. Heid panel finding +
contract FN main STEPS 2.
"""
@respx.mock
def test_create_session_uses_server_end_user_id(self) -> None:
"""create_app(end_user_id=...) → POST /api/sessions threads that id
into the upstream POST body even when the browser sends none.
"""
import json as _j
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json={**_CREATE_OK, "agent_id": "lofn"})
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), end_user_id="ratatoskr-tui")
resp = TestClient(app).post("/api/sessions", json={"agent_id": "lofn"})
assert resp.status_code == 201
sent = _j.loads(route.calls[0].request.content)
assert sent == {"agent_id": "lofn", "end_user_id": "ratatoskr-tui"}
@respx.mock
def test_create_session_ignores_body_end_user_id(self) -> None:
"""A browser-supplied end_user_id is IGNORED — the server's
configured value wins. Prevents a client from impersonating an
arbitrary end-user partition.
"""
import json as _j
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json={**_CREATE_OK, "agent_id": "lofn"})
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), end_user_id="ratatoskr-tui")
TestClient(app).post(
"/api/sessions", json={"agent_id": "lofn", "end_user_id": "attacker"}
)
sent = _j.loads(route.calls[0].request.content)
assert sent.get("end_user_id") == "ratatoskr-tui"
@respx.mock
def test_create_session_no_end_user_id_when_unset(self) -> None:
"""When create_app gets no end_user_id, the upstream body omits it
(matches create_session's default-omit shape)."""
import json as _j
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK)
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory()) # no end_user_id
TestClient(app).post("/api/sessions", json={"agent_id": "mimir"})
sent = _j.loads(route.calls[0].request.content)
assert "end_user_id" not in sent
class TestCreateAppShape:
"""create_app FN — route registration + state wiring (contract TESTS)."""
def test_routes_registered(self) -> None:
"""routes_registered [tracer]: app.routes contains all 9 path patterns."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
paths = {getattr(r, "path", None) for r in app.routes}
for expected in (
"/", "/version", "/api/agents", "/api/sessions",
"/api/agents/{agent_id}/persona_state",
"/api/turns/{session_id}", "/api/turns/{session_id}/stream",
"/api/turns/{session_id}/cancel",
):
assert expected in paths, f"missing route {expected}"
# /static is a Mount — its path is "/static"
assert "/static" in paths
def test_state_attached(self) -> None:
"""state_attached [trace]: app.state.turn_registry is empty dict."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
assert app.state.turn_registry == {}
def test_factory_stored(self) -> None:
"""factory_stored [trace]: app.state.client_factory is the same callable."""
from ratatoskr.web.server import create_app
f = _mock_client_factory()
app = create_app(f)
assert app.state.client_factory is f
class TestStreamFullEventVocab:
"""stream_turn_endpoint full_event_vocab — one of each Event type proxied."""
def _drive_stream(self, app, stream: bytes) -> list[str]:
respx.post("https://w.example/sessions/s-1/messages").mock(
return_value=_sse_resp(stream)
)
c = TestClient(app)
tid = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
with c.stream("GET", f"/api/turns/s-1/stream?turn_id={tid}") as resp:
raw = b"".join(resp.iter_bytes())
return [e["event"] for e in _parse_browser_sse(raw)]
@respx.mock
def test_full_event_vocab(self) -> None:
"""full_event_vocab [scenario]: a stream with the non-terminal Event
types (incl. AffectUpdate) + Done → each serialized to its fixture-
shaped browser event. Error / Cancelled are terminal and exclusive
with Done, so they get dedicated tests below.
"""
from ratatoskr.web.server import create_app
stream = b"".join([
_sse_chunk("42:1", {"type": "affect_update", "status": "current", "turn_id": 42,
"snapshot": {"agent_id": "mimir", "pad": {}, "dominant_emotion": "x"}}),
_sse_chunk("42:2", {"type": "worker_phase", "phase": "BuildingPrompt", "turn_id": 42}),
_sse_chunk("42:3", {"type": "thinking", "content": "hmm"}),
_sse_chunk("42:4", {"type": "text", "content": "hi"}),
_sse_chunk("42:5", {"type": "text_boundary", "kind": "sentence", "char_offset": 2, "ts": "t"}),
_sse_chunk("42:6", {"type": "tool_start", "name": "s", "arguments": {"q": "x"}}),
_sse_chunk("42:7", {"type": "tool_result", "name": "s", "result": {"n": 1}, "duration_ms": 3}),
_sse_chunk("42:8", {"type": "awaiting_llm_first_token", "turn_id": 42,
"elapsed_ms_since_building_prompt": 5000.0}),
_sse_chunk("42:9", _DONE_BODY),
])
types = self._drive_stream(create_app(_mock_client_factory()), stream)
for expected in ("affect_update", "worker_phase", "thinking", "text", "text_boundary",
"tool_start", "tool_result", "awaiting_llm_first_token", "done"):
assert expected in types, f"missing browser event {expected}"
@respx.mock
def test_error_terminal_event(self) -> None:
"""error terminal [scenario]: an SSE `error` event (distinct from the
synthetic connection-error event) proxies to a browser `error` event.
"""
from ratatoskr.web.server import create_app
stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk(
"42:2", {"type": "error", "phase": "failed",
"error_code": "llm_output_invalid", "message": "boom"},
)
types = self._drive_stream(create_app(_mock_client_factory()), stream)
assert "error" in types
@respx.mock
def test_cancelled_terminal_event(self) -> None:
"""cancelled terminal [scenario]: an SSE `cancelled` event proxies to
a browser `cancelled` event."""
from ratatoskr.web.server import create_app
stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk(
"42:2", {"type": "cancelled", "phase": "cancelled", "turn_id": 42,
"reason": "user_cancel", "partial_message_id": None},
)
types = self._drive_stream(create_app(_mock_client_factory()), stream)
assert "cancelled" in types
class TestDisconnectCancel:
"""stream_turn_endpoint INV-005 — browser disconnect mid-stream triggers
upstream cancel against the UPSTREAM turn_id. The load-bearing test that
would have caught the v0.15.x turn_id bug.
Drives the endpoint's StreamingResponse body_iterator directly and
cancels the consuming task to simulate the disconnect. This avoids the
ASGITransport stream-context-exit deadlock against a gated upstream
generator, while exercising the real `except asyncio.CancelledError`
cleanup path inside the generator.
"""
@respx.mock
async def test_disconnect_triggers_upstream_cancel(self) -> None:
import asyncio
from starlette.requests import Request
from ratatoskr.web.server import TurnHandle, _stream_turn_endpoint, create_app
gate = asyncio.Event()
class _GatedAfterFirst(httpx.AsyncByteStream):
async def __aiter__(self):
yield _sse_chunk("42:1", {"type": "text", "content": "x"})
await gate.wait()
async def aclose(self) -> None:
return None
respx.post("https://w.example/sessions/s-1/messages").mock(
return_value=_sse_resp_stream(_GatedAfterFirst())
)
cancel_route = respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(200, json={
"turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None,
})
)
app = create_app(_mock_client_factory())
app.state.turn_registry[("s-1", 1)] = TurnHandle(
session_id="s-1", turn_id=1, content="x",
)
request = Request({
"type": "http", "method": "GET", "path": "/api/turns/s-1/stream",
"path_params": {"session_id": "s-1"},
"query_string": b"turn_id=1", "headers": [], "app": app,
})
response = await _stream_turn_endpoint(request)
body_iter = response.body_iterator # type: ignore[attr-defined]
async def consume() -> None:
async for _chunk in body_iter:
pass
task = asyncio.create_task(consume())
# Wait until the generator has captured the upstream turn_id (first
# event consumed). The handle is popped in finally, so check it
# before cancelling.
for _ in range(100):
h = app.state.turn_registry.get(("s-1", 1))
if h is not None and h.upstream_turn_id == 42:
break
await asyncio.sleep(0.02)
else:
gate.set()
raise AssertionError("upstream_turn_id was never captured")
# Simulate browser disconnect: cancel the consuming task.
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
for _ in range(50):
if cancel_route.called:
break
await asyncio.sleep(0.02)
gate.set()
assert cancel_route.called, "browser disconnect must cancel the UPSTREAM turn (42)"
Generated
+256 -2
View File
@@ -329,6 +329,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247 },
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064 },
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851 },
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842 },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238 },
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567 },
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918 },
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148 },
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368 },
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447 },
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448 },
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460 },
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312 },
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117 },
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183 },
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079 },
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596 },
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865 },
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189 },
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610 },
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705 },
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023 },
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405 },
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497 },
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585 },
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297 },
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535 },
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209 },
]
[[package]]
name = "httpx"
version = "0.28.1"
@@ -920,6 +956,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -968,7 +1013,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.14.2"
version = "0.16.1"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
@@ -984,7 +1029,13 @@ dev = [
{ name = "pyyaml" },
{ name = "respx" },
{ name = "ruff" },
{ name = "starlette" },
{ name = "textual-dev" },
{ name = "uvicorn", extra = ["standard"] },
]
web = [
{ name = "starlette" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.metadata]
@@ -995,12 +1046,15 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
{ name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6" },
{ name = "ratatoskr", extras = ["web"], marker = "extra == 'dev'" },
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" },
{ name = "starlette", marker = "extra == 'web'", specifier = ">=0.40" },
{ name = "textual", specifier = ">=0.85" },
{ name = "textual-dev", marker = "extra == 'dev'", specifier = ">=1.5" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.30" },
]
provides-extras = ["dev"]
provides-extras = ["web", "dev"]
[[package]]
name = "respx"
@@ -1052,6 +1106,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336 },
]
[[package]]
name = "starlette"
version = "1.1.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/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899 },
]
[[package]]
name = "textual"
version = "8.2.7"
@@ -1120,6 +1187,193 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383 },
]
[[package]]
name = "uvicorn"
version = "0.48.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410 },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936 },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769 },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413 },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307 },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970 },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343 },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611 },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811 },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562 },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890 },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472 },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051 },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067 },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423 },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437 },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101 },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158 },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360 },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790 },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783 },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548 },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065 },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384 },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730 },
]
[[package]]
name = "watchfiles"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115 },
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659 },
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207 },
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273 },
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927 },
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476 },
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650 },
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398 },
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140 },
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259 },
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859 },
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480 },
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718 },
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026 },
{ url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730 },
{ url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842 },
{ url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989 },
{ url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978 },
{ url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248 },
{ url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847 },
{ url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974 },
{ url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782 },
{ url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182 },
{ url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841 },
{ url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028 },
{ url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183 },
{ url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059 },
{ url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186 },
{ url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031 },
{ url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205 },
{ url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892 },
{ url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867 },
{ url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217 },
{ url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458 },
{ url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707 },
{ url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663 },
{ url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537 },
{ url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194 },
{ url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194 },
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205 },
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508 },
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448 },
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605 },
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757 },
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672 },
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197 },
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181 },
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109 },
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653 },
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838 },
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108 },
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441 },
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684 },
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857 },
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413 },
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409 },
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827 },
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104 },
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360 },
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644 },
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771 },
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494 },
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383 },
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093 },
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109 },
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167 },
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372 },
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596 },
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869 },
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641 },
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444 },
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593 },
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096 },
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638 },
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684 },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365 },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038 },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328 },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915 },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152 },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583 },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880 },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261 },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693 },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364 },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039 },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323 },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975 },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203 },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653 },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920 },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255 },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689 },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406 },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085 },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328 },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044 },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279 },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711 },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982 },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915 },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381 },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737 },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268 },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486 },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331 },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501 },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062 },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356 },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085 },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531 },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 },
]
[[package]]
name = "yarl"
version = "1.24.2"