Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 369857d3f1 | |||
| 0fbbeb171c | |||
| 1228c37e6f | |||
| 85143b866c | |||
| 00854ce618 | |||
| 78bfcadb9e | |||
| 44138590ad | |||
| d516537b08 | |||
| 92aa05c688 | |||
| 209427ab23 | |||
| 139771c8d8 |
+13
-5
@@ -7,11 +7,19 @@ documents the pin, the vendored artifacts, and the bump procedure.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Worldtree git SHA | `55101e909abcd2219833266b6f905c5bc956e0f0` |
|
||||
| Worldtree HEAD message | `memory: snapshot — #177 Vili v1 + persona async-decouple shipped as v0.19.0` |
|
||||
| Pinned on | 2026-05-20 |
|
||||
| Pinned by | brokkr-smithy-dev (initial scaffold) |
|
||||
| Worldtree version at pin | `v0.19.0` |
|
||||
| Worldtree git SHA | `562001af28d752c3a60d449c7ddd09f44fa9dc9a` |
|
||||
| Worldtree HEAD message | `feat(#201): v0.29.0 — awaiting_llm_first_token SSE heartbeat` |
|
||||
| Pinned on | 2026-05-26 |
|
||||
| Pinned by | ratatoskr-dev (bump for #201 awaiting_llm_first_token SSE) |
|
||||
| Worldtree version at pin | `v0.29.0` |
|
||||
|
||||
## Pin history
|
||||
|
||||
| Date | SHA | Version | Notable deltas consumed |
|
||||
|---|---|---|---|
|
||||
| 2026-05-26 | `562001a` | v0.29.0 | #201 — new SSE event `awaiting_llm_first_token` (heartbeat during BuildingPrompt → CallingLLM gap, default 5s interval) |
|
||||
| 2026-05-25 | `da93ca7` | v0.28.0 | #204 — new SSE event `affect_update` (current/scheduled), new endpoint `GET /agents/{id}/persona_state`, auth-model doc edits |
|
||||
| 2026-05-20 | `55101e9` | v0.19.0 | initial scaffold pin |
|
||||
|
||||
## Vendored artifacts
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -55,6 +55,69 @@ conversation_api:
|
||||
|
||||
---
|
||||
|
||||
## Authorization model — agent invocation
|
||||
|
||||
When you call `POST /sessions` against an agent, the authorization check that fires depends on **which kind of agent** you target. There are two distinct scope namespaces — the spelling differs by one character (`agent` vs `agents`) and the granting mechanism differs entirely. Confusing the two is a common source of bug reports.
|
||||
|
||||
### Tier 1 — foundational agents (no `:` in agent_id)
|
||||
|
||||
Agents bundled with Worldtree: `mimir`, `lofn`, `soong`, `forseti`, `domari`, `vili`, `actor`, `saga`, `bragi`, `leif`, `troi`, `cara`, `glados`, and any future Asgardian. The agent_id is a simple slug like `mimir` — no colon.
|
||||
|
||||
> **About tiers:** Your `tier` is set on the `users` table row your API key resolves to, assigned at key-mint time (see `POST /admin/keys`). Tiers are `anonymous` (dev-mode unauthenticated), `user` (default for newly-issued keys), `free`/`pro` (subscription-shaped, not actively differentiated), and `admin`. The tier you have is visible via `GET /me`'s `tier` field. Tier-derived scopes come from `config/policies.yaml > tiers.<tier>.scopes` — there is no per-key scope override.
|
||||
|
||||
**Authorization rule (singular `agent`):**
|
||||
|
||||
```yaml
|
||||
- id: agent-call-baseline-allow
|
||||
principal:
|
||||
tiers: ["anonymous", "user", "free", "pro", "admin"]
|
||||
action: "agent.call:*"
|
||||
resource: "*"
|
||||
effect: allow
|
||||
```
|
||||
|
||||
This baseline rule lives at `config/policies.yaml`. Every authenticated tier — including the `user` tier that newly-issued keys default to — already passes this check for every Tier 1 agent. **There is no per-agent scope you can add to "grant" Tier 1 access; it's covered by tier.**
|
||||
|
||||
If you get a 422 calling a Tier 1 agent (e.g., `lofn` rejecting with `end_user_id_required`), that's a **request-body validation**, not a scope denial. Check the `error_code` in the response detail — `END_USER_ID_REQUIRED` means the agent requires an `end_user_id` field in the request body; `AUTH_SCOPE_DENIED` (403) would be the actual scope problem. They're not interchangeable.
|
||||
|
||||
### Tier 3 — consumer-defined agents (`:` in agent_id)
|
||||
|
||||
Agents created at runtime via `POST /agents/define`. The agent_id is `<owner_user_id>:<agent_name>`, e.g., `acme:support-bot`. The `:` in the path is the trigger that switches the auth model.
|
||||
|
||||
**Authorization is DB-backed per-resource, NOT policy-driven (plural `agents`):**
|
||||
|
||||
```
|
||||
scope action checked: agents.call:<owner_user_id>:<agent_name>
|
||||
^^^^^^
|
||||
PLURAL — different namespace from Tier 1
|
||||
```
|
||||
|
||||
There is **no blanket allow rule** for `agents.call:*` in policy. The grant comes from the live `consumer_agents` table:
|
||||
|
||||
- A non-soft-deleted row in `consumer_agents` owned by `ctx.user_id` IS the grant.
|
||||
- Cascade soft-delete and owner-initiated `DELETE` revoke it.
|
||||
- Missing row → policy defaults to deny (403 `auth_scope_denied`).
|
||||
|
||||
To "add the scope" for a Tier 3 agent, you don't amend any config or call an admin endpoint — you `POST /agents/define` to register it under your `user_id`. Owning the row IS the grant. You cannot call another user's Tier 3 agent; ownership is checked at session-create (`row.user_id == ctx.user_id`).
|
||||
|
||||
### Common pitfalls
|
||||
|
||||
- **Singular vs plural.** Tier 1 uses `agent.call:*` (singular `agent`). Tier 3 uses `agents.call:<owner>:<name>` (plural `agents`). One character difference, two completely different mechanisms. There is no Tier 1 scope named `agent.call:mimir` or `agents.call:mimir` — Tier 1 is granted by baseline rule, not per-agent name.
|
||||
- **No scope-mutation API.** `POST /admin/keys` accepts `{user_id, label, tier}` only. There is no per-key scope override mechanism in the storage schema. To change a user's effective scopes, change their `tier`, not their key. Per-resource Tier 3 grants flow through `POST /agents/define` (and its DELETE counterpart), not through admin endpoints.
|
||||
- **422 vs 403.** A 422 is body-validation (e.g., `end_user_id_required`); a 403 is auth-policy denial (`auth_scope_denied`). Different fix paths. Read the `error_code` in `detail`.
|
||||
|
||||
### Quick decision table for consumers
|
||||
|
||||
| Target | Auth requirement |
|
||||
|---|---|
|
||||
| Tier 1 agent (e.g., `mimir`) | Authenticated tier ≥ `user`. No additional body requirements |
|
||||
| Tier 1 agent `lofn` (the default welcoming intermediary) | Authenticated tier ≥ `user` + `end_user_id` field required in request body. 422 `END_USER_ID_REQUIRED` if absent |
|
||||
| Tier 3 agent (any agent_id containing `:`) | `end_user_id` field required in body. AND the row must be owner-matched: `POST /agents/define` first to create a row under your `user_id`, then session-create works against your existing key. Cross-user Tier 3 invocation is rejected with 403 |
|
||||
|
||||
> **Programmatic discovery of `end_user_id` requirements:** as of v0.22.x there is no field on `GET /agents` indicating which agents require `end_user_id` — the spec line above (lofn + Tier 3) is the authoritative list, and 422 `END_USER_ID_REQUIRED` is the fallback signal at request time. Adding a discoverable `requires_end_user_id` field on `AgentInfoResponse` is on the table as a small future capability; ping if you want to drive it.
|
||||
|
||||
---
|
||||
|
||||
## GET /me
|
||||
|
||||
Returns the authenticated principal's identity and key metadata. Lets a client verify its key on boot without triggering agent-config-loading side effects.
|
||||
@@ -1878,6 +1941,65 @@ Tool-using turns cycle through `CallingLLM → ProcessingTools → CallingLLM
|
||||
|
||||
Clients that don't need phase events can filter on `event["type"] != "worker_phase"` client-side. Existing SSE consumers that switch on `event["type"]` ignore this event type without code changes.
|
||||
|
||||
### affect_update
|
||||
|
||||
Persona-state observability event (issue #204). Fires twice per turn for agents with `persona.enabled: true` on non-ephemeral sessions; suppressed entirely for persona-disabled agents (e.g. `domari`, `muninn`), Tier 3 consumer-defined agents (Phase 2.0), and ephemeral sessions.
|
||||
|
||||
**Start-of-turn — `status: "current"`:**
|
||||
|
||||
Emitted immediately at the start of each qualifying turn, before any `worker_phase` event. Carries the agent's current persona snapshot reflecting all prior turns' completed appraisals.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "affect_update",
|
||||
"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-25T22:30:18+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**End-of-turn — `status: "scheduled"`:**
|
||||
|
||||
Emitted after the post-turn appraisal task has been scheduled (per #177 Phase A's fire-and-forget discipline) and before `done`. Lightweight notification — no PAD numbers, since the appraisal is still running asynchronously. The result lands in the NEXT turn's `status: "current"` snapshot.
|
||||
|
||||
```json
|
||||
{"type": "affect_update", "status": "scheduled", "turn_id": 42}
|
||||
```
|
||||
|
||||
`scheduled` is skipped on turn failure/cancel paths (the appraisal was never reached); `current` still fires unconditionally for qualifying turns.
|
||||
|
||||
Bootstrap reads available via `GET /agents/{agent_id}/persona_state` (same `snapshot` shape, requires `persona.read` scope).
|
||||
|
||||
### awaiting_llm_first_token
|
||||
|
||||
Periodic heartbeat event (issue #201) emitted at a configurable interval during the gap between `worker_phase: phase="BuildingPrompt"` and `worker_phase: phase="CallingLLM"`. Solves the legitimate-slow first-token visibility gap: consumer TUIs can render a "thinking for Ns…" timer rather than a frozen line during heavy-CoT prompt warmup.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 5012.3
|
||||
}
|
||||
```
|
||||
|
||||
`elapsed_ms_since_building_prompt` is the server-authoritative wall-clock milliseconds since `BuildingPrompt` was emitted. Independent of network latency or clock skew.
|
||||
|
||||
Heartbeats stop the moment the engine produces its first event (the `CallingLLM` marker). They do NOT re-fire during tool-roundtrip `CallingLLM` re-entries — the heartbeat is scoped to the FIRST `BuildingPrompt → CallingLLM` gap only.
|
||||
|
||||
**Configuration:** `conversation_api.awaiting_llm_first_token_heartbeat_s` (default `5.0`). Per-agent override via `agent.conversation.awaiting_llm_first_token_heartbeat_s`. Value `0.0` disables emission entirely.
|
||||
|
||||
Cancellation paths (stall watchdog, user-cancel) also stop the heartbeat — no `awaiting_llm_first_token` event appears after the terminal `cancelled` event.
|
||||
|
||||
### thinking
|
||||
|
||||
Incremental reasoning/thinking content (from thinking-enabled models).
|
||||
|
||||
@@ -1851,6 +1851,103 @@ SQLite `consumer_agents` table.
|
||||
before any other processing; non-slug user_ids return 403
|
||||
`tier3_user_id_unsupported`.
|
||||
|
||||
### Persona-state observability (issue #204)
|
||||
|
||||
- **INV-204-1 (affect_update event type)**: `affect_update` is a
|
||||
top-level SSE event `type` discriminator, sibling to `worker_phase`
|
||||
/ `tool_*` / `text` / `thinking` / `done`. Not a `worker_phase` sub-
|
||||
phase. INV-061's "BuildingPrompt is the FIRST event" property is
|
||||
scoped to `worker_phase` events only — `affect_update status="current"`
|
||||
may precede BuildingPrompt for persona-enabled agents.
|
||||
- **INV-204-2 (per-turn emission)**: For agents with persona enabled
|
||||
on non-ephemeral sessions, `stream_turn` emits `status="current"`
|
||||
before any other SSE event on a successful or failed turn, and
|
||||
`status="scheduled"` after `update_after_turn` schedules the
|
||||
appraisal task (success path only — skipped on cancel / error
|
||||
before update_after_turn was reached). See contract
|
||||
`docs/contracts/issues/204.contract.md`.
|
||||
- **INV-204-3 (emission suppression)**: Persona-disabled agents and
|
||||
ephemeral sessions emit ZERO `affect_update` events.
|
||||
- **INV-204-6 / INV-204-7 (persona_state endpoint)**: New
|
||||
`GET /agents/{agent_id}/persona_state` gated on Heimdall scope
|
||||
`persona.read`. Route ordering: auth → Tier 3 short-circuit (404
|
||||
`persona_not_configured`) → Tier 1/2 existence (404
|
||||
`agent_not_available`) → persona-enabled check (404
|
||||
`persona_not_configured`) → snapshot (200).
|
||||
- **INV-204-9 (read-only registry primitive)**: `PersonaRegistry.get_state`
|
||||
is mutex-free and never mutates `persona.emotions`. Eventual
|
||||
consistency under concurrent `_appraisal_wrapper` mutations.
|
||||
- **INV-204-14 (replay participation)**: `affect_update` events flow
|
||||
through `_publish`, so SSE resume / replay handles them with no
|
||||
special case.
|
||||
|
||||
## Amendment — AwaitingLLMFirstToken heartbeat (issue #201, INV-201-1..7)
|
||||
|
||||
Adds a periodic SSE heartbeat event during the gap between
|
||||
`BuildingPrompt` and `CallingLLM` so consumers can distinguish
|
||||
"engine is thinking" from "engine is wedged" without out-of-band
|
||||
server inspection. Filed by ratatoskr-dev; ships in v0.29.0.
|
||||
|
||||
- **INV-201-1 (new top-level event type)**: `awaiting_llm_first_token`
|
||||
is a new top-level SSE event type, sibling to `worker_phase` /
|
||||
`tool_*` / `text` / `thinking` / `debug` / `done` / `affect_update`.
|
||||
`_WORKER_PHASE_VOCAB` is NOT extended; INV-053 / INV-054 unchanged.
|
||||
Same precedent as #204's `affect_update`.
|
||||
|
||||
- **INV-201-2 (config-gated emission)**: Heartbeat emission requires
|
||||
`awaiting_llm_first_token_heartbeat_s > 0.0`. When the resolved
|
||||
value is `0.0`, the heartbeat task is never started and zero
|
||||
`awaiting_llm_first_token` events emit for the turn. When > 0.0,
|
||||
the task starts immediately after `_publish_phase("BuildingPrompt")`
|
||||
and emits an event every `interval` seconds until cancelled.
|
||||
|
||||
- **INV-201-3 (defense-in-depth cancellation)**: The heartbeat task
|
||||
is cancelled at three sites (idempotent via the `_cancel_heartbeat`
|
||||
helper): (a) immediately before `_publish_phase("CallingLLM")` on
|
||||
the engine-first-event path; (b) inside the `cancelled`/`error`
|
||||
handling that wraps `_handle_cancel` (covers stall + user-cancel
|
||||
paths); (c) in the outer `finally` block alongside
|
||||
`_clear_stall_timer`. After cancellation, no further
|
||||
`awaiting_llm_first_token` events emit.
|
||||
|
||||
- **INV-201-4 (wire shape)**: Payload is exactly `{type:
|
||||
"awaiting_llm_first_token", turn_id: <int>,
|
||||
elapsed_ms_since_building_prompt: <float>}` plus the composite `id:
|
||||
"<turn_id>:<seq>"` stamped by `_publish`. No additional fields.
|
||||
`elapsed_ms_since_building_prompt` is `(time.monotonic() -
|
||||
building_prompt_t) * 1000.0` where `building_prompt_t` is captured
|
||||
immediately before `BuildingPrompt` is published.
|
||||
|
||||
- **INV-201-5 (first-gap-only scope)**: Heartbeat is scoped to the
|
||||
FIRST `BuildingPrompt → CallingLLM` gap of the turn. Tool round-trip
|
||||
`CallingLLM` re-entries (INV-058) emit ZERO
|
||||
`awaiting_llm_first_token` events. Out-of-scope sub-phases
|
||||
(`AwaitingToolResult`, `AwaitingNextLLMCall`) would be separate
|
||||
follow-up features.
|
||||
|
||||
- **INV-201-6 (replay participation)**: Heartbeat events flow through
|
||||
`_publish → _replay_buffer + queue` per INV-060 — same replay
|
||||
semantics as worker_phase events. On `Last-Event-ID` reconnect,
|
||||
prior heartbeats replay identically.
|
||||
|
||||
- **INV-201-7 (config resolution precedence)**: Per-agent
|
||||
`agent.conversation.awaiting_llm_first_token_heartbeat_s` →
|
||||
`api_cfg.awaiting_llm_first_token_heartbeat_s` → built-in `5.0`.
|
||||
Negative values raise `ConfigurationError` at agent load; `0.0`
|
||||
is valid and means "disabled." Mirrors the `_resolve_stall_timeout_s`
|
||||
precedence pattern (INV-038).
|
||||
|
||||
### Mechanism note
|
||||
|
||||
The heartbeat task is a separate `asyncio.Task` (NOT `loop.call_later`,
|
||||
because heartbeats repeat at an interval rather than fire once at a
|
||||
timeout). An `asyncio.Queue` shared between the heartbeat task and the
|
||||
generator carries events; the generator uses
|
||||
`asyncio.wait(return_when=FIRST_COMPLETED)` to race the engine's
|
||||
`__anext__` against the heartbeat queue's `get` ONLY during the first
|
||||
iteration. After `CallingLLM` fires, the heartbeat task is cancelled
|
||||
and subsequent iterations use the original non-race pattern.
|
||||
|
||||
### Storage extension
|
||||
|
||||
The `consumer_agents` table lives in `core/heimdall/storage/sqlite.py`
|
||||
|
||||
+16
-5
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.8.2"
|
||||
version = "0.16.0"
|
||||
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"
|
||||
@@ -42,13 +48,18 @@ Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
|
||||
# Ratatoskr is built against Worldtree at this commit; the vendored
|
||||
# spec snapshot in docs/ reflects that SHA.
|
||||
[tool.ratatoskr.spec-pin]
|
||||
worldtree-spec-rev = "55101e909abcd2219833266b6f905c5bc956e0f0"
|
||||
worldtree-version = "v0.19.0"
|
||||
pinned-on = "2026-05-20"
|
||||
worldtree-spec-rev = "562001af28d752c3a60d449c7ddd09f44fa9dc9a"
|
||||
worldtree-version = "v0.29.0"
|
||||
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"]
|
||||
|
||||
@@ -18,6 +18,8 @@ import httpx
|
||||
|
||||
from ratatoskr.sessions import AgentNotFound, SessionApiFailed, create_session
|
||||
from ratatoskr.sse_client import (
|
||||
AffectUpdate,
|
||||
AwaitingLlmFirstToken,
|
||||
CancelAlreadyCompleted,
|
||||
CancelFailed,
|
||||
Cancelled,
|
||||
@@ -205,6 +207,7 @@ class CliPresenterState:
|
||||
(
|
||||
WorkerPhase, Thinking, Text, TextBoundary,
|
||||
ToolStart, ToolResult, Done, Error, Cancelled,
|
||||
AffectUpdate, AwaitingLlmFirstToken,
|
||||
),
|
||||
)
|
||||
# Thinking events accumulate into the open run.
|
||||
@@ -275,6 +278,28 @@ class CliPresenterState:
|
||||
f". text_boundary: kind={event.kind} char_offset={event.char_offset}\n"
|
||||
)
|
||||
return
|
||||
if isinstance(event, AffectUpdate):
|
||||
# Worldtree #204 / v0.28.0. CLI surface is debug telemetry —
|
||||
# one line to stderr with status + (for current) dominant_emotion.
|
||||
if event.snapshot is not None:
|
||||
dom = event.snapshot.get("dominant_emotion")
|
||||
stderr.write(
|
||||
f". affect_update: status={event.status} turn_id={event.turn_id} "
|
||||
f"dominant_emotion={dom!r}\n"
|
||||
)
|
||||
else:
|
||||
stderr.write(
|
||||
f". affect_update: status={event.status} turn_id={event.turn_id}\n"
|
||||
)
|
||||
return
|
||||
if isinstance(event, AwaitingLlmFirstToken):
|
||||
# Worldtree #201 / v0.29.0. Heartbeat during BuildingPrompt →
|
||||
# CallingLLM gap. Stderr surface, one line per heartbeat.
|
||||
secs = event.elapsed_ms_since_building_prompt / 1000.0
|
||||
stderr.write(
|
||||
f". awaiting_llm_first_token: turn_id={event.turn_id} elapsed={secs:.1f}s\n"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def _cancel_and_log(
|
||||
|
||||
@@ -89,6 +89,46 @@ class SessionApiFailed(Exception):
|
||||
self.body = body
|
||||
|
||||
|
||||
# Worldtree #204 / v0.28.0 — persona_state endpoint failure modes.
|
||||
class PersonaNotConfigured(Exception):
|
||||
"""Raised on HTTP 404 `persona_not_configured` from GET persona_state.
|
||||
|
||||
Agent exists but has no persona surface: persona-disabled Tier 1/2
|
||||
agents (e.g. `domari`, `muninn`) and all Tier 3 consumer-defined
|
||||
agents (Phase 2.0). Distinct from `AgentNotAvailable` which means the
|
||||
agent_id is unknown entirely.
|
||||
"""
|
||||
|
||||
def __init__(self, *, agent_id: str) -> None:
|
||||
super().__init__(f"persona not configured for agent_id: {agent_id!r}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
class AgentNotAvailable(Exception):
|
||||
"""Raised on HTTP 404 `agent_not_available` from GET persona_state.
|
||||
|
||||
The agent_id is unknown to the server. Distinct from
|
||||
`PersonaNotConfigured` (agent exists but has no persona).
|
||||
"""
|
||||
|
||||
def __init__(self, *, agent_id: str) -> None:
|
||||
super().__init__(f"agent not available: {agent_id!r}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
class AuthScopeDenied(Exception):
|
||||
"""Raised on HTTP 403 `auth_scope_denied` from a Heimdall-scoped endpoint.
|
||||
|
||||
The API key lacks the required scope (e.g. `persona.read` for
|
||||
GET /agents/{id}/persona_state). User-tier keys carry `persona.read`
|
||||
by default; this surfaces when a narrower key is in use.
|
||||
"""
|
||||
|
||||
def __init__(self, *, scope: str) -> None:
|
||||
super().__init__(f"auth scope denied: required={scope!r}")
|
||||
self.scope = scope
|
||||
|
||||
|
||||
async def list_sessions(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
@@ -200,3 +240,54 @@ async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
|
||||
)
|
||||
for item in body
|
||||
]
|
||||
|
||||
|
||||
async def get_persona_state(
|
||||
client: httpx.AsyncClient, agent_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""GET /agents/{agent_id}/persona_state — fetch current persona snapshot.
|
||||
|
||||
Worldtree #204 / v0.28.0. Returns the same `snapshot` dict shape as the
|
||||
`affect_update` SSE event's `status="current"` emission: pad,
|
||||
dominant_emotion, emotions_active, baseline_pad, mood_drift,
|
||||
last_updated_at. Bootstrap read for clients that want to populate a
|
||||
persona pane on session-open without waiting for turn-1's `affect_update`.
|
||||
|
||||
Auth: requires Heimdall `persona.read` scope (user-tier default).
|
||||
|
||||
Failure modes (mapped to typed exceptions per the spec error_codes):
|
||||
- 404 `persona_not_configured` → PersonaNotConfigured (persona-disabled
|
||||
agents: domari / muninn, and all Tier 3 in Phase 2.0)
|
||||
- 404 `agent_not_available` → AgentNotAvailable (unknown agent_id)
|
||||
- 403 `auth_scope_denied` → AuthScopeDenied (key lacks persona.read)
|
||||
- any other non-2xx → SessionApiFailed (preserves the broader-error
|
||||
precedent from list_agents / list_sessions / create_session)
|
||||
"""
|
||||
assert client is not None
|
||||
assert agent_id and isinstance(agent_id, str)
|
||||
|
||||
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. 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()
|
||||
except ValueError:
|
||||
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":
|
||||
raise AgentNotAvailable(agent_id=agent_id)
|
||||
if resp.status_code == 403 and error_code == "auth_scope_denied":
|
||||
raise AuthScopeDenied(scope="persona.read")
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
@@ -111,6 +111,55 @@ class Cancelled:
|
||||
partial_message_id: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AwaitingLlmFirstToken:
|
||||
"""SSE event `awaiting_llm_first_token`: heartbeat during slow first-token.
|
||||
|
||||
Fires at the configured interval (default 5s) during the gap between
|
||||
`worker_phase` phase=BuildingPrompt and phase=CallingLLM. Lets clients
|
||||
render a live "thinking for Ns…" indicator instead of a frozen line
|
||||
during legitimate-slow first-token latency. Stops the moment CallingLLM
|
||||
fires (defense-in-depth at three sites); no heartbeat after Cancelled
|
||||
or stalled terminal events. Tool round-trip re-entries do NOT re-fire
|
||||
heartbeats — INV-201-5 scopes the mechanism to the FIRST gap only.
|
||||
|
||||
`elapsed_ms_since_building_prompt` is server-authoritative
|
||||
`time.monotonic()`-based — independent of network latency or clock
|
||||
skew, monotonically increasing across the heartbeat sequence.
|
||||
|
||||
See docs/conversation-api-spec.md § awaiting_llm_first_token
|
||||
(Worldtree #201, v0.29.0).
|
||||
"""
|
||||
|
||||
sse_id: SseId
|
||||
turn_id: int
|
||||
elapsed_ms_since_building_prompt: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AffectUpdate:
|
||||
"""SSE event `affect_update`: persona-state observability snapshot.
|
||||
|
||||
Two emissions per qualifying turn (persona-enabled agent on non-
|
||||
ephemeral session): `status="current"` at turn start carrying the full
|
||||
snapshot, `status="scheduled"` after post-turn appraisal kicks off
|
||||
(lightweight — `snapshot` is None). Suppressed entirely for persona-
|
||||
disabled agents (e.g. `domari`, `muninn`), Tier 3 consumer-defined
|
||||
agents (Phase 2.0), and ephemeral sessions.
|
||||
|
||||
Bootstrap reads available via `GET /agents/{agent_id}/persona_state`
|
||||
(same `snapshot` shape, requires `persona.read` scope).
|
||||
|
||||
See docs/conversation-api-spec.md § affect_update (Worldtree #204,
|
||||
v0.28.0).
|
||||
"""
|
||||
|
||||
sse_id: SseId
|
||||
status: str # "current" | "scheduled"
|
||||
turn_id: int
|
||||
snapshot: dict[str, Any] | None # None when status="scheduled"
|
||||
|
||||
|
||||
Event = (
|
||||
WorkerPhase
|
||||
| Thinking
|
||||
@@ -121,6 +170,8 @@ Event = (
|
||||
| Done
|
||||
| Error
|
||||
| Cancelled
|
||||
| AffectUpdate
|
||||
| AwaitingLlmFirstToken
|
||||
)
|
||||
|
||||
|
||||
@@ -284,6 +335,26 @@ def _envelope_for_type(body: dict[str, Any], sse_id: SseId) -> Event:
|
||||
reason=body.get("reason"),
|
||||
partial_message_id=body.get("partial_message_id"),
|
||||
)
|
||||
if t == "awaiting_llm_first_token":
|
||||
# Worldtree #201 / v0.29.0: top-level heartbeat during BuildingPrompt
|
||||
# → CallingLLM gap. Lets clients render live elapsed-time indicators
|
||||
# instead of frozen lines on legitimate-slow first-token latency.
|
||||
return AwaitingLlmFirstToken(
|
||||
sse_id=sse_id,
|
||||
turn_id=body["turn_id"],
|
||||
elapsed_ms_since_building_prompt=body["elapsed_ms_since_building_prompt"],
|
||||
)
|
||||
if t == "affect_update":
|
||||
# Worldtree #204 / v0.28.0: persona-state observability event.
|
||||
# status="current" carries full snapshot at turn start;
|
||||
# status="scheduled" omits snapshot (lightweight post-appraisal-
|
||||
# kickoff notification).
|
||||
return AffectUpdate(
|
||||
sse_id=sse_id,
|
||||
status=body["status"],
|
||||
turn_id=body["turn_id"],
|
||||
snapshot=body.get("snapshot"),
|
||||
)
|
||||
raise ValueError(f"unknown SSE event type: {t!r}")
|
||||
|
||||
|
||||
|
||||
+654
-95
@@ -10,13 +10,15 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time as _time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime as _datetime
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
import httpx
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.theme import Theme
|
||||
from textual.widgets import (
|
||||
Footer,
|
||||
@@ -33,12 +35,18 @@ from textual.widgets import (
|
||||
from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage
|
||||
from ratatoskr.sessions import (
|
||||
AgentInfo,
|
||||
AgentNotAvailable,
|
||||
AgentNotFound,
|
||||
AuthScopeDenied,
|
||||
PersonaNotConfigured,
|
||||
SessionApiFailed,
|
||||
create_session,
|
||||
get_persona_state,
|
||||
list_agents,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
AffectUpdate,
|
||||
AwaitingLlmFirstToken,
|
||||
CancelAlreadyCompleted,
|
||||
CancelFailed,
|
||||
Cancelled,
|
||||
@@ -178,6 +186,141 @@ def _plain_label(event: Event) -> str:
|
||||
return f"[unknown_event] {type(event).__name__}"
|
||||
|
||||
|
||||
def _ts() -> str:
|
||||
"""HH:MM:SS.fff wall-clock timestamp for debug-pane log lines."""
|
||||
now = _datetime.now()
|
||||
return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}"
|
||||
|
||||
|
||||
def _format_persona_header(snapshot: dict) -> str:
|
||||
"""One-line persona summary for the sticky header widget.
|
||||
|
||||
Shape: `agent_id · dominant_emotion · pad(P, A, D) · N emotions active`.
|
||||
Built for at-a-glance scanning above the chat area — concise enough to
|
||||
fit one terminal row. Full detail lives in the Persona TabPane.
|
||||
"""
|
||||
pad = snapshot.get("pad") or {}
|
||||
emotions = snapshot.get("emotions_active") or []
|
||||
dom = snapshot.get("dominant_emotion") or "?"
|
||||
pieces = [
|
||||
f"{snapshot.get('agent_id', '?')}",
|
||||
f"{dom}",
|
||||
f"pad({pad.get('pleasure', '?')}, {pad.get('arousal', '?')}, "
|
||||
f"{pad.get('dominance', '?')})",
|
||||
]
|
||||
if emotions:
|
||||
pieces.append(f"{len(emotions)} emotion{'s' if len(emotions) != 1 else ''} active")
|
||||
return " · ".join(pieces)
|
||||
|
||||
|
||||
def _format_persona_detail(snapshot: dict) -> str:
|
||||
"""Multi-line persona detail for the Persona TabPane.
|
||||
|
||||
Renders the full v0.28.0 snapshot shape: dominant_emotion, PAD with
|
||||
baseline comparison, mood_drift deltas, active emotions list with
|
||||
intensity + decay, last_updated_at footer.
|
||||
"""
|
||||
pad = snapshot.get("pad") or {}
|
||||
baseline = snapshot.get("baseline_pad") or {}
|
||||
drift = snapshot.get("mood_drift") or {}
|
||||
emotions = snapshot.get("emotions_active") or []
|
||||
lines: list[str] = []
|
||||
agent = snapshot.get("agent_id", "?")
|
||||
lines.append(f"Persona snapshot · {agent}")
|
||||
lines.append("")
|
||||
lines.append(f"Dominant emotion: {snapshot.get('dominant_emotion', '?')}")
|
||||
lines.append("")
|
||||
lines.append("PAD")
|
||||
for axis in ("pleasure", "arousal", "dominance"):
|
||||
v = pad.get(axis, "?")
|
||||
b = baseline.get(axis, "?")
|
||||
delta = ""
|
||||
if isinstance(v, (int, float)) and isinstance(b, (int, float)):
|
||||
delta = f" (Δ {v - b:+.2f})"
|
||||
lines.append(f" {axis:<10} {v} baseline {b}{delta}")
|
||||
lines.append("")
|
||||
if drift:
|
||||
lines.append("Mood drift")
|
||||
for key in ("valence_delta", "arousal_delta"):
|
||||
if key in drift:
|
||||
v = drift[key]
|
||||
lines.append(f" {key:<16} {v:+}" if isinstance(v, (int, float))
|
||||
else f" {key:<16} {v}")
|
||||
lines.append("")
|
||||
lines.append(f"Active emotions ({len(emotions)})")
|
||||
for e in emotions:
|
||||
et = e.get("type", "?")
|
||||
ei = e.get("intensity", "?")
|
||||
decay = e.get("decay_remaining_s")
|
||||
decay_str = (
|
||||
f" decay {decay / 60:.1f}m" if isinstance(decay, (int, float)) else ""
|
||||
)
|
||||
lines.append(f" {et:<20} intensity {ei}{decay_str}")
|
||||
if not emotions:
|
||||
lines.append(" (none)")
|
||||
last = snapshot.get("last_updated_at")
|
||||
if last:
|
||||
lines.append("")
|
||||
lines.append(f"Last updated: {last}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _audit_line(event: Event) -> str:
|
||||
"""One-line wire-level audit summary for the debug pane.
|
||||
|
||||
v0.10.0: every SSE event arrival lands as one of these in the debug
|
||||
pane (Text and Thinking deltas are aggregated into the turn summary
|
||||
instead — token-rate per-delta lines would drown the pane). Shape:
|
||||
`[HH:MM:SS.fff] event_type sse_id=T:S key=val …`.
|
||||
"""
|
||||
sid = getattr(event, "sse_id", None)
|
||||
sid_str = f"{sid.turn_id}:{sid.seq}" if sid is not None else "-"
|
||||
kind = type(event).__name__.lower()
|
||||
if isinstance(event, WorkerPhase):
|
||||
detail = f"phase={event.phase} turn_id={event.turn_id}"
|
||||
elif isinstance(event, ToolStart):
|
||||
detail = f"name={event.name} args={event.arguments!r:.80}"
|
||||
elif isinstance(event, ToolResult):
|
||||
detail = f"name={event.name} duration_ms={event.duration_ms}"
|
||||
elif isinstance(event, TextBoundary):
|
||||
detail = f"kind={event.kind} char_offset={event.char_offset}"
|
||||
elif isinstance(event, Done):
|
||||
detail = (
|
||||
f"turn_id={event.sse_id.turn_id} model={event.model} "
|
||||
f"duration_ms={event.duration_ms}"
|
||||
)
|
||||
elif isinstance(event, Error):
|
||||
detail = (
|
||||
f"turn_id={event.sse_id.turn_id} code={event.error_code} "
|
||||
f"message={event.message!r:.80}"
|
||||
)
|
||||
elif isinstance(event, Cancelled):
|
||||
detail = f"turn_id={event.turn_id} reason={event.reason!r}"
|
||||
elif isinstance(event, AwaitingLlmFirstToken):
|
||||
# Worldtree #201 / v0.29.0. Compact: turn_id + elapsed in seconds
|
||||
# (the heartbeat itself fires every 5s by default; seconds-rounding
|
||||
# is the natural unit for operator scanning).
|
||||
secs = event.elapsed_ms_since_building_prompt / 1000.0
|
||||
detail = f"turn_id={event.turn_id} elapsed={secs:.1f}s"
|
||||
elif isinstance(event, AffectUpdate):
|
||||
# Worldtree #204 / v0.28.0. status="current" carries the full
|
||||
# snapshot; surface dominant_emotion + PAD inline so the operator
|
||||
# sees persona drift at a glance. status="scheduled" is
|
||||
# lightweight — no PAD, just the appraisal-kickoff marker.
|
||||
if event.snapshot is not None:
|
||||
pad = event.snapshot.get("pad") or {}
|
||||
detail = (
|
||||
f"status={event.status} turn_id={event.turn_id} "
|
||||
f"dominant_emotion={event.snapshot.get('dominant_emotion')!r} "
|
||||
f"pad=({pad.get('pleasure')},{pad.get('arousal')},{pad.get('dominance')})"
|
||||
)
|
||||
else:
|
||||
detail = f"status={event.status} turn_id={event.turn_id}"
|
||||
else: # Text / Thinking handled by counter path; fallback for safety
|
||||
detail = ""
|
||||
return f"[{_ts()}] {kind} sse_id={sid_str} {detail}".rstrip()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TuiPresenterState:
|
||||
"""Per-turn presenter state for TUI mode (issue #12).
|
||||
@@ -194,35 +337,58 @@ class TuiPresenterState:
|
||||
# only on `\n` boundaries (one written line per natural paragraph) or
|
||||
# when the run closes (any leftover tail).
|
||||
thinking_chunk_buffer: str = ""
|
||||
# v0.8.1: same pattern for Text deltas. Pre-v0.8.1 the Text deltas
|
||||
# streamed into a dedicated #current-text Static below the transcript;
|
||||
# that Static (docked-bottom, height: auto) grew during streaming and
|
||||
# visually OVERLAPPED the transcript above (Textual didn't dynamically
|
||||
# resize the 1fr transcript while the dock-bottom child expanded).
|
||||
# The Static is gone in v0.8.1 — Text deltas coalesce on `\n` and write
|
||||
# directly to `log` (transcript), the same shape thinking uses.
|
||||
# v0.9.0: Text accumulator for live Markdown rendering. Worldtree emits
|
||||
# Text deltas at token granularity; each delta appends to this buffer
|
||||
# and the current_response_widget re-renders Markdown(text_chunk_buffer)
|
||||
# in place. On terminal event the widget is finalized + reference clears.
|
||||
text_chunk_buffer: str = ""
|
||||
# v0.9.0: reference to the Static widget holding the current turn's
|
||||
# response Markdown Renderable. None between turns.
|
||||
current_response_widget: object = None
|
||||
# v0.10.0: per-turn counters for the debug-pane turn-summary line. Text
|
||||
# and Thinking events arrive at token rate; emitting per-delta debug
|
||||
# lines would drown the pane. Instead we count them and surface
|
||||
# aggregated totals when the turn closes.
|
||||
text_delta_count: int = 0
|
||||
text_byte_count: int = 0
|
||||
thinking_delta_count: int = 0
|
||||
thinking_byte_count: int = 0
|
||||
turn_start_ts: float = 0.0
|
||||
# v0.14.0: Worldtree #201 heartbeat surface. First
|
||||
# `awaiting_llm_first_token` mounts a Static; subsequent heartbeats
|
||||
# update it in place; any non-heartbeat event clears it (the gap
|
||||
# closed). awaiting_widget is the Static reference (None when
|
||||
# closed); heartbeat_count tracks emissions for the turn-summary.
|
||||
awaiting_widget: object = None
|
||||
heartbeat_count: int = 0
|
||||
|
||||
def render(
|
||||
self,
|
||||
event: Event,
|
||||
*,
|
||||
log: RichLog,
|
||||
transcript: "VerticalScroll",
|
||||
tools_log: RichLog,
|
||||
debug_log: RichLog,
|
||||
thinking_log: RichLog,
|
||||
raw: bool,
|
||||
on_persona_snapshot: object = None,
|
||||
) -> None:
|
||||
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
||||
|
||||
v0.8.1 routing:
|
||||
- `log` (transcript) = chat content: user-prompt echo (written
|
||||
outside the presenter), coalesced Text deltas, terminal labels,
|
||||
optional post-Done Markdown body.
|
||||
- `tools_log` = ToolStart + ToolResult.
|
||||
- `debug_log` = WorkerPhase + TextBoundary.
|
||||
- `thinking_log` = streaming Thinking deltas inline (coalesced on
|
||||
`\n`). Rule(start)/Rule(end) wrap each run.
|
||||
v0.9.0 routing:
|
||||
- `transcript` (VerticalScroll) = chat content: each turn mounts
|
||||
child widgets (turn-header / prompt-echo / response Markdown /
|
||||
done-label). Live Markdown rendering during Text streaming.
|
||||
- `tools_log` (RichLog) = ToolStart + ToolResult.
|
||||
- `debug_log` (RichLog) = WorkerPhase + TextBoundary.
|
||||
- `thinking_log` (RichLog) = streaming Thinking deltas inline
|
||||
(coalesced on `\n`); Rule(start)/Rule(end) wrap each run.
|
||||
|
||||
v0.13.0: optional `on_persona_snapshot` callback receives the
|
||||
snapshot dict whenever AffectUpdate(status="current") arrives.
|
||||
Lets the App surface the snapshot to the persona-header + Persona
|
||||
pane without the presenter needing direct widget access. Default
|
||||
None — presenter falls back to audit-only routing.
|
||||
|
||||
Exceptions caught at the presenter boundary (INV-009 fallback).
|
||||
"""
|
||||
@@ -230,7 +396,8 @@ class TuiPresenterState:
|
||||
event,
|
||||
(
|
||||
WorkerPhase, Thinking, Text, TextBoundary,
|
||||
ToolStart, ToolResult, Done, Error, Cancelled,
|
||||
ToolStart, ToolResult, Done, Error, Cancelled, AffectUpdate,
|
||||
AwaitingLlmFirstToken,
|
||||
),
|
||||
)
|
||||
from rich.text import Text as RichText
|
||||
@@ -240,6 +407,75 @@ class TuiPresenterState:
|
||||
return RichText(s, style=_AU_DEMOTED)
|
||||
|
||||
try:
|
||||
# v0.10.0: per-event audit log line to debug pane. Text and
|
||||
# Thinking arrive at token rate, so we count them rather than
|
||||
# emit a line per delta — totals are reported in the turn-
|
||||
# summary on Done/Error/Cancelled. Everything else gets one
|
||||
# debug-pane line per arrival with timestamp + sse_id + a short
|
||||
# event-specific summary, giving the operator a wire-level
|
||||
# timeline of what the server sent.
|
||||
if isinstance(event, Text):
|
||||
if self.text_delta_count == 0:
|
||||
if self.turn_start_ts == 0.0:
|
||||
self.turn_start_ts = _time.monotonic()
|
||||
self.text_delta_count += 1
|
||||
self.text_byte_count += len(event.content)
|
||||
elif isinstance(event, Thinking):
|
||||
if self.thinking_delta_count == 0:
|
||||
if self.turn_start_ts == 0.0:
|
||||
self.turn_start_ts = _time.monotonic()
|
||||
self.thinking_delta_count += 1
|
||||
self.thinking_byte_count += len(event.content)
|
||||
else:
|
||||
if self.turn_start_ts == 0.0:
|
||||
self.turn_start_ts = _time.monotonic()
|
||||
debug_log.write(_dim(_audit_line(event)))
|
||||
# v0.11.0 → v0.13.0: AffectUpdate gets the audit line (above)
|
||||
# plus a callback to the App so the persona-header + Persona
|
||||
# pane refresh from the snapshot. status="scheduled" carries
|
||||
# no snapshot — the callback is skipped and the next turn's
|
||||
# status="current" lands the actual update.
|
||||
if isinstance(event, AffectUpdate):
|
||||
if event.snapshot is not None and on_persona_snapshot is not None:
|
||||
try:
|
||||
on_persona_snapshot(event.snapshot)
|
||||
except Exception:
|
||||
# Persona surface failure must not break the SSE
|
||||
# stream — the audit line above already records
|
||||
# the event regardless.
|
||||
pass
|
||||
return
|
||||
# v0.14.0: AwaitingLlmFirstToken (Worldtree #201) gets the audit
|
||||
# line (above) plus a live transcript indicator. First heartbeat
|
||||
# mounts a Static; subsequent heartbeats update it in place.
|
||||
# Any non-heartbeat event below closes the gap and the indicator
|
||||
# is removed (the first text/thinking/done arrived).
|
||||
if isinstance(event, AwaitingLlmFirstToken):
|
||||
from rich.text import Text as RichText
|
||||
self.heartbeat_count += 1
|
||||
secs = event.elapsed_ms_since_building_prompt / 1000.0
|
||||
label = RichText(
|
||||
f"awaiting first token · {secs:.1f}s",
|
||||
style=_AU_DEMOTED,
|
||||
)
|
||||
try:
|
||||
if self.awaiting_widget is None:
|
||||
self.awaiting_widget = Static(label, classes="awaiting-label")
|
||||
transcript.mount(self.awaiting_widget)
|
||||
else:
|
||||
self.awaiting_widget.update(label)
|
||||
transcript.scroll_end(animate=False)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
# Any non-heartbeat event past this point means the gap closed —
|
||||
# remove the awaiting indicator if it's still mounted.
|
||||
if self.awaiting_widget is not None:
|
||||
try:
|
||||
self.awaiting_widget.remove()
|
||||
except Exception:
|
||||
pass
|
||||
self.awaiting_widget = None
|
||||
# v0.7.1: Thinking deltas coalesce by newline before flushing.
|
||||
# Worldtree emits Thinking events at token granularity; per-delta
|
||||
# RichLog writes produce one visual line per token (per-token-per-
|
||||
@@ -288,47 +524,96 @@ class TuiPresenterState:
|
||||
# coalesced on `\n`. Same pattern as Thinking (v0.7.1).
|
||||
# The pre-v0.8.1 #current-text Static is gone — its dock-
|
||||
# bottom growth was overlapping the transcript visually.
|
||||
#
|
||||
# v0.9.0: Text deltas accumulate in text_chunk_buffer and
|
||||
# the current_response_widget renders Markdown(buffer) in
|
||||
# place. First Text delta of the turn mounts a fresh Static
|
||||
# holding the Markdown Renderable; subsequent deltas update
|
||||
# the same widget. Live markdown rendering — no post-Done
|
||||
# re-render needed.
|
||||
from rich.markdown import Markdown
|
||||
|
||||
self.text_chunk_buffer += event.content
|
||||
while "\n" in self.text_chunk_buffer:
|
||||
line, _, rest = self.text_chunk_buffer.partition("\n")
|
||||
if line:
|
||||
log.write(line)
|
||||
self.text_chunk_buffer = rest
|
||||
# --raw bypasses Markdown rendering — useful for debugging
|
||||
# the raw text stream surface, and matches the pre-v0.9.0
|
||||
# --raw semantics (which dropped the post-Done Markdown re-
|
||||
# render). In raw mode the response widget holds plain str.
|
||||
rendered = (
|
||||
self.text_chunk_buffer if raw else Markdown(self.text_chunk_buffer)
|
||||
)
|
||||
if self.current_response_widget is None:
|
||||
self.current_response_widget = Static(
|
||||
rendered, classes="response-md"
|
||||
)
|
||||
transcript.mount(self.current_response_widget)
|
||||
else:
|
||||
self.current_response_widget.update(rendered)
|
||||
transcript.scroll_end(animate=False)
|
||||
return
|
||||
if isinstance(event, (Done, Error, Cancelled)):
|
||||
# Terminal event: flush any remaining text tail before the
|
||||
# label / Markdown body lands.
|
||||
if self.text_chunk_buffer:
|
||||
log.write(self.text_chunk_buffer)
|
||||
self.text_chunk_buffer = ""
|
||||
# Terminal labels tinted per outcome (Aurora green / Dawn red
|
||||
# / Dawn yellow) for at-a-glance scanning.
|
||||
# v0.10.0: emit turn-summary to debug pane before clearing
|
||||
# counters. Aggregates the per-event totals (Text + Thinking
|
||||
# deltas don't get per-event audit lines because they arrive
|
||||
# at token rate; the summary surfaces what was elided).
|
||||
elapsed_ms = (
|
||||
int((_time.monotonic() - self.turn_start_ts) * 1000)
|
||||
if self.turn_start_ts
|
||||
else 0
|
||||
)
|
||||
turn_id = (
|
||||
event.sse_id.turn_id
|
||||
if hasattr(event, "sse_id")
|
||||
else getattr(event, "turn_id", "?")
|
||||
)
|
||||
debug_log.write(_dim(
|
||||
f"[{_ts()}] turn_summary turn_id={turn_id} "
|
||||
f"text_deltas={self.text_delta_count} "
|
||||
f"text_bytes={self.text_byte_count} "
|
||||
f"thinking_deltas={self.thinking_delta_count} "
|
||||
f"thinking_bytes={self.thinking_byte_count} "
|
||||
f"heartbeats={self.heartbeat_count} "
|
||||
f"elapsed_ms={elapsed_ms}"
|
||||
))
|
||||
# Terminal event: finalize the response widget (clear ref so
|
||||
# the next turn mounts a fresh one). The accumulated text is
|
||||
# already rendered as Markdown in the widget — no post-Done
|
||||
# re-render, no double-print.
|
||||
self.text_chunk_buffer = ""
|
||||
self.current_response_widget = None
|
||||
# Terminal labels mount as styled Statics. Tinted per outcome
|
||||
# (Aurora green / Dawn red / Dawn yellow) for at-a-glance
|
||||
# scanning.
|
||||
if isinstance(event, Done):
|
||||
log.write(RichText(
|
||||
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
|
||||
f"duration={_format_duration_ms(event.duration_ms)} "
|
||||
f"usage {_format_usage(event.usage, arrow='→')}",
|
||||
style=_AU_SUCCESS,
|
||||
transcript.mount(Static(
|
||||
RichText(
|
||||
f"[done] turn_id={event.sse_id.turn_id} "
|
||||
f"model={event.model} "
|
||||
f"duration={_format_duration_ms(event.duration_ms)} "
|
||||
f"usage {_format_usage(event.usage, arrow='→')}",
|
||||
style=_AU_SUCCESS,
|
||||
),
|
||||
classes="done-label",
|
||||
))
|
||||
# v0.8.2: post-Done Markdown body re-render dropped. Pre-
|
||||
# v0.8.2 the transcript got BOTH the streamed text AND
|
||||
# the Markdown(response) re-render — same content twice,
|
||||
# operator-flagged as "double prints". The streamed text
|
||||
# IS the response now; markdown formatting (bold, lists,
|
||||
# code) renders as plain text. Matches thinking pane's
|
||||
# stream-as-content semantics (no post-close re-render).
|
||||
elif isinstance(event, Error):
|
||||
log.write(RichText(
|
||||
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
|
||||
f"message={event.message!r}",
|
||||
style=_AU_ERROR,
|
||||
transcript.mount(Static(
|
||||
RichText(
|
||||
f"[error] turn_id={event.sse_id.turn_id} "
|
||||
f"code={event.error_code} message={event.message!r}",
|
||||
style=_AU_ERROR,
|
||||
),
|
||||
classes="error-label",
|
||||
))
|
||||
else: # Cancelled
|
||||
log.write(RichText(
|
||||
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
|
||||
f"partial_message_id={event.partial_message_id}",
|
||||
style=_AU_WARNING,
|
||||
transcript.mount(Static(
|
||||
RichText(
|
||||
f"[cancelled] turn_id={event.turn_id} "
|
||||
f"reason={event.reason!r} "
|
||||
f"partial_message_id={event.partial_message_id}",
|
||||
style=_AU_WARNING,
|
||||
),
|
||||
classes="cancelled-label",
|
||||
))
|
||||
transcript.scroll_end(animate=False)
|
||||
return
|
||||
if isinstance(event, WorkerPhase):
|
||||
# v0.5.0: telemetry → Debug pane, not transcript.
|
||||
@@ -360,22 +645,24 @@ class TuiPresenterState:
|
||||
# the original event AND a render_error line with the class name only
|
||||
# (NO exception message — security clause). Volva F1 fix.
|
||||
#
|
||||
# v0.6.0 routing-under-failure preservation — fallback writes go
|
||||
# to the same destination the successful render would have used:
|
||||
# - ToolStart/ToolResult → tools_log
|
||||
# - Thinking → thinking_log
|
||||
# - WorkerPhase/TextBoundary → debug_log
|
||||
# - everything else → log
|
||||
# v0.9.0 routing-under-failure: panes (RichLog) still write Strip
|
||||
# lines; transcript (VerticalScroll) mounts a Static instead.
|
||||
if isinstance(event, (ToolStart, ToolResult)):
|
||||
target = tools_log
|
||||
tools_log.write(_plain_label(event))
|
||||
tools_log.write(f"[render_error] {type(exc).__name__}")
|
||||
elif isinstance(event, Thinking):
|
||||
target = thinking_log
|
||||
thinking_log.write(_plain_label(event))
|
||||
thinking_log.write(f"[render_error] {type(exc).__name__}")
|
||||
elif isinstance(event, (WorkerPhase, TextBoundary)):
|
||||
target = debug_log
|
||||
debug_log.write(_plain_label(event))
|
||||
debug_log.write(f"[render_error] {type(exc).__name__}")
|
||||
else:
|
||||
target = log
|
||||
target.write(_plain_label(event))
|
||||
target.write(f"[render_error] {type(exc).__name__}")
|
||||
# Transcript-bound event (Text / Done / Error / Cancelled).
|
||||
transcript.mount(Static(_plain_label(event), classes="error-label"))
|
||||
transcript.mount(
|
||||
Static(f"[render_error] {type(exc).__name__}", classes="error-label")
|
||||
)
|
||||
transcript.scroll_end(animate=False)
|
||||
|
||||
|
||||
class AgentPickerApp(App[str | None]):
|
||||
@@ -564,11 +851,48 @@ class RatatoskrApp(App[int]):
|
||||
}
|
||||
/* v0.6.5: thinking-current Static removed; thinking now streams
|
||||
directly into thinking-log so the whole pane scrolls naturally. */
|
||||
#transcript {
|
||||
/* v0.9.0: transcript is a VerticalScroll container holding dynamically
|
||||
mounted Statics + Markdown widgets per turn. Live Markdown rendering
|
||||
replaces the v0.8.x RichLog approach which couldn't render Markdown
|
||||
in-flight (only on Done as a re-render → double-print bug). */
|
||||
#transcript-scroll {
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
padding: 0 1;
|
||||
}
|
||||
/* Per-turn mounted widgets carry id-prefix conventions:
|
||||
- .turn-header "── turn N ──" (dim)
|
||||
- .prompt-echo "❯ user input" (aurora bright cyan)
|
||||
- .response-md Markdown(accumulated_text) — updated live
|
||||
- .done-label "[done] turn_id=…" (aurora green)
|
||||
- .error-label "[error] …" (dawn red)
|
||||
- .cancelled-label "[cancelled] …" (dawn yellow)
|
||||
*/
|
||||
.turn-header {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
color: $au-dark-60;
|
||||
}
|
||||
.prompt-echo {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
}
|
||||
.response-md {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
}
|
||||
.done-label, .error-label, .cancelled-label {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
}
|
||||
/* v0.14.0: Worldtree #201 — live "awaiting first token · Ns" indicator
|
||||
in the transcript during the BuildingPrompt → CallingLLM gap.
|
||||
Demoted styling so it reads as ambient progress, not content. */
|
||||
.awaiting-label {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
color: $au-dark-60;
|
||||
}
|
||||
/* v0.8.1: #current-text Static removed. Streaming text now coalesces
|
||||
on `\n` and writes directly to #transcript (same pattern as v0.7.1
|
||||
thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
|
||||
@@ -618,6 +942,20 @@ class RatatoskrApp(App[int]):
|
||||
color: $au-dark-60;
|
||||
padding: 0 1;
|
||||
}
|
||||
/* v0.13.0: sticky persona-header — one-line agent persona summary
|
||||
above the main row. Empty (height: 0) when the agent has no
|
||||
persona surface (PersonaNotConfigured) so the chat layout
|
||||
collapses cleanly. */
|
||||
#persona-header {
|
||||
dock: top;
|
||||
height: 1;
|
||||
color: $au-bright-80;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
}
|
||||
#persona-header.empty {
|
||||
display: none;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS: ClassVar[list[Binding]] = [
|
||||
@@ -628,6 +966,7 @@ class RatatoskrApp(App[int]):
|
||||
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False),
|
||||
Binding("ctrl+2", "focus_debug", "Debug tab", priority=False),
|
||||
Binding("ctrl+3", "focus_thinking", "Thinking tab", priority=False),
|
||||
Binding("ctrl+4", "focus_persona", "Persona tab", priority=False),
|
||||
]
|
||||
|
||||
HINT_IDLE = "Ctrl-C twice to exit"
|
||||
@@ -656,6 +995,11 @@ class RatatoskrApp(App[int]):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
# v0.13.0: sticky persona-header docks at the top, above main-row.
|
||||
# One-line summary refreshed on each AffectUpdate(status=current).
|
||||
# Starts in the .empty CSS class (height collapses to 0) until
|
||||
# on_mount's get_persona_state hydration succeeds.
|
||||
yield Static("", id="persona-header", classes="empty")
|
||||
# v0.6.0 layout: left column is content-only (transcript + streaming
|
||||
# text Static + prompt). Right column hosts thinking-current live
|
||||
# preview above TabbedContent cycling Tools / Debug / Thinking.
|
||||
@@ -670,17 +1014,32 @@ class RatatoskrApp(App[int]):
|
||||
# work without widget-level markup=True.
|
||||
with Horizontal(id="main-row"):
|
||||
with Vertical(id="left-column"):
|
||||
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
|
||||
# v0.9.0: transcript is a VerticalScroll holding per-turn
|
||||
# mounted widgets (turn header, prompt echo, response Markdown,
|
||||
# done label). Live Markdown rendering happens via Static
|
||||
# widgets holding `Markdown` Renderables, updated as Text
|
||||
# deltas arrive.
|
||||
yield VerticalScroll(id="transcript-scroll")
|
||||
yield Input(id="prompt", placeholder="Type a message and press Enter")
|
||||
with Vertical(id="right-column"):
|
||||
with TabbedContent(id="side-panes"):
|
||||
# v0.14.2: min_width=0 disables Textual's 78-cell floor
|
||||
# on RichLog. The right column is 1fr against the left
|
||||
# column's 2fr, so at typical terminal widths the right-
|
||||
# column panes are narrower than 78 cells — and the
|
||||
# default min_width=78 was forcing content to render at
|
||||
# 78 wide and horizontally scroll instead of wrapping at
|
||||
# the actual widget width. With min_width=0, wrap=True
|
||||
# finally takes effect on long lines.
|
||||
with TabPane("Tools", id="tools-tab"):
|
||||
yield RichLog(
|
||||
id="tools-log", wrap=True, markup=False, highlight=False
|
||||
id="tools-log", wrap=True, markup=False,
|
||||
highlight=False, min_width=0,
|
||||
)
|
||||
with TabPane("Debug", id="debug-tab"):
|
||||
yield RichLog(
|
||||
id="debug-log", wrap=True, markup=False, highlight=False
|
||||
id="debug-log", wrap=True, markup=False,
|
||||
highlight=False, min_width=0,
|
||||
)
|
||||
with TabPane("Thinking", id="thinking-tab"):
|
||||
# v0.6.5: thinking streams directly into this
|
||||
@@ -690,7 +1049,17 @@ class RatatoskrApp(App[int]):
|
||||
# as content arrives — no more "200-char tail
|
||||
# window scrolling at the bottom".
|
||||
yield RichLog(
|
||||
id="thinking-log", wrap=True, markup=False, highlight=False
|
||||
id="thinking-log", wrap=True, markup=False,
|
||||
highlight=False, min_width=0,
|
||||
)
|
||||
with TabPane("Persona", id="persona-tab"):
|
||||
# v0.13.0: full persona-snapshot detail (PAD,
|
||||
# mood drift, active emotions). Replaced (not
|
||||
# appended) on each AffectUpdate(current) — the
|
||||
# snapshot is absolute state, not incremental.
|
||||
yield RichLog(
|
||||
id="persona-log", wrap=True, markup=False,
|
||||
highlight=False, min_width=0,
|
||||
)
|
||||
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
|
||||
# pane-name widget displays current side-pane name.
|
||||
@@ -731,22 +1100,115 @@ class RatatoskrApp(App[int]):
|
||||
)
|
||||
self.state = "idle"
|
||||
self._set_hint(self.HINT_IDLE)
|
||||
# v0.10.0: startup audit so the debug pane carries a complete
|
||||
# session bootstrap line (server URL, agent, end_user_id, raw flag,
|
||||
# session tail) before the first turn fires.
|
||||
self._audit(
|
||||
f"app_mounted server={self.args.server_url} agent_id={self.agent_id!r} "
|
||||
f"session={self.session_id[-8:]} raw={self.args.raw} "
|
||||
f"end_user_id={getattr(self.args, 'end_user_id', None)!r}"
|
||||
)
|
||||
# v0.13.0: hydrate persona surface on mount via
|
||||
# GET /agents/{id}/persona_state. Spawns as a Textual worker so the
|
||||
# network call doesn't block mount. Agents without a persona
|
||||
# surface (PersonaNotConfigured) get a placeholder + empty header.
|
||||
if self.agent_id is not None:
|
||||
self.run_worker(self._hydrate_persona())
|
||||
|
||||
async def _hydrate_persona(self) -> None:
|
||||
"""Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state.
|
||||
|
||||
Failure modes are absorbed (this is best-effort observability):
|
||||
- PersonaNotConfigured: pane shows placeholder, header stays empty
|
||||
- AgentNotAvailable / AuthScopeDenied: error placeholder; header empty
|
||||
- Network error: error placeholder; header empty
|
||||
On 200: header populated, pane shows full detail, audit logged.
|
||||
"""
|
||||
assert self.client is not None and self.agent_id is not None
|
||||
from rich.text import Text as RichText
|
||||
try:
|
||||
snapshot = await get_persona_state(self.client, self.agent_id)
|
||||
self._update_persona_surfaces(snapshot)
|
||||
self._audit(
|
||||
f"persona_hydrated agent_id={self.agent_id!r} "
|
||||
f"dominant_emotion={snapshot.get('dominant_emotion')!r}"
|
||||
)
|
||||
except PersonaNotConfigured:
|
||||
self._set_persona_placeholder(
|
||||
f"(persona not configured for {self.agent_id})"
|
||||
)
|
||||
self._audit(f"persona_not_configured agent_id={self.agent_id!r}")
|
||||
except (AgentNotAvailable, AuthScopeDenied, SessionApiFailed, Exception) as exc:
|
||||
# Best-effort — never let a persona hydration failure crash the
|
||||
# TUI. Surface the error in the persona pane and audit log.
|
||||
self._set_persona_placeholder(
|
||||
f"(persona hydration failed: {type(exc).__name__})"
|
||||
)
|
||||
self._audit(
|
||||
f"persona_hydration_failed agent_id={self.agent_id!r} "
|
||||
f"err={type(exc).__name__}: {exc!s:.120}"
|
||||
)
|
||||
|
||||
def _update_persona_surfaces(self, snapshot: dict) -> None:
|
||||
"""Update sticky header + Persona pane from a fresh snapshot.
|
||||
|
||||
Called on bootstrap (on_mount) and on each AffectUpdate(current).
|
||||
Header gets the compact one-liner; pane gets the full detail.
|
||||
"""
|
||||
from rich.text import Text as RichText
|
||||
try:
|
||||
header = self.query_one("#persona-header", Static)
|
||||
header.update(RichText(_format_persona_header(snapshot)))
|
||||
header.remove_class("empty")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
log = self.query_one("#persona-log", RichLog)
|
||||
log.clear()
|
||||
log.write(_format_persona_detail(snapshot))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _set_persona_placeholder(self, text: str) -> None:
|
||||
"""Render an italic-dim placeholder in the Persona pane; keep header empty.
|
||||
|
||||
Used when persona hydration returns PersonaNotConfigured or fails —
|
||||
the pane stays usable as documentation of *why* it's empty without
|
||||
the sticky header consuming a row for nothing.
|
||||
"""
|
||||
from rich.text import Text as RichText
|
||||
try:
|
||||
log = self.query_one("#persona-log", RichLog)
|
||||
log.clear()
|
||||
log.write(RichText(text, style=f"{_AU_DEMOTED_FAINT} italic"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _write_turn_headers(self, turn_id: int) -> None:
|
||||
"""v0.6.0: Write `── turn N ──` Rule headers across every pane so
|
||||
operators can visually correlate sections during cross-pane
|
||||
debugging. Called from `_stream_turn_worker` on first event of
|
||||
each new turn (idempotent per turn via active_turn_id guard).
|
||||
"""v0.6.0: turn-ID headers across every pane for cross-pane
|
||||
correlation. v0.9.0: transcript is a VerticalScroll; mounts a
|
||||
Static with rule-style text instead of writing a Rule Renderable
|
||||
to RichLog. Other panes still use RichLog.write(Rule).
|
||||
"""
|
||||
from rich.rule import Rule
|
||||
from rich.text import Text as RichText
|
||||
|
||||
title = f"turn {turn_id}"
|
||||
rule = Rule(title=title, style=_AU_DEMOTED)
|
||||
try:
|
||||
self.query_one("#transcript", RichLog).write(rule)
|
||||
# Transcript (VerticalScroll): mount a styled Static.
|
||||
transcript = self.query_one("#transcript-scroll", VerticalScroll)
|
||||
transcript.mount(
|
||||
Static(
|
||||
RichText(f"── turn {turn_id} ──", style=_AU_DEMOTED),
|
||||
classes="turn-header",
|
||||
)
|
||||
)
|
||||
# Other panes (RichLog): write the Rule Renderable.
|
||||
self.query_one("#tools-log", RichLog).write(rule)
|
||||
self.query_one("#debug-log", RichLog).write(rule)
|
||||
self.query_one("#thinking-log", RichLog).write(rule)
|
||||
transcript.scroll_end(animate=False)
|
||||
except Exception:
|
||||
# Defensive: widget tree may be tearing down — never let a
|
||||
# turn-header write block the SSE consumer.
|
||||
@@ -761,13 +1223,51 @@ class RatatoskrApp(App[int]):
|
||||
# Widget may be gone during shutdown; ignore.
|
||||
pass
|
||||
|
||||
def _audit(self, line: str) -> None:
|
||||
"""Write a timestamped audit line to the debug pane.
|
||||
|
||||
v0.10.0: shared sink for app-level events that don't pass through
|
||||
the presenter — state transitions, worker spawn/cancel, cancel POST
|
||||
lifecycle, startup probes. The presenter's per-event audit lives at
|
||||
`_audit_line()`; this is its app-side counterpart.
|
||||
"""
|
||||
try:
|
||||
from rich.text import Text as RichText
|
||||
self.query_one("#debug-log", RichLog).write(
|
||||
RichText(f"[{_ts()}] {line}", style=_AU_DEMOTED)
|
||||
)
|
||||
except Exception:
|
||||
# Widget may not exist yet (pre-mount) or be tearing down.
|
||||
pass
|
||||
|
||||
def _transition(
|
||||
self, new_state: Literal["idle", "streaming", "cancelling"], reason: str
|
||||
) -> None:
|
||||
"""Set self.state with debug-pane audit log.
|
||||
|
||||
Every state machine transition flows through here so the debug pane
|
||||
carries a complete idle→streaming→cancelling→idle timeline with the
|
||||
triggering reason. Cheap; safe to call from any context.
|
||||
"""
|
||||
old = self.state
|
||||
self.state = new_state
|
||||
if old != new_state:
|
||||
self._audit(f"state {old} → {new_state} reason={reason}")
|
||||
|
||||
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Echo user prompt, spawn stream worker; busy notice if not idle."""
|
||||
"""Echo user prompt, spawn stream worker; busy notice if not idle.
|
||||
|
||||
v0.9.0: prompt echo mounts as a Static in the transcript VerticalScroll
|
||||
(was log.write to RichLog).
|
||||
"""
|
||||
if event.input.id != "prompt":
|
||||
return
|
||||
log = self.query_one("#transcript", RichLog)
|
||||
transcript = self.query_one("#transcript-scroll", VerticalScroll)
|
||||
if self.state != "idle":
|
||||
log.write("[busy] turn in flight; input ignored")
|
||||
transcript.mount(
|
||||
Static("[busy] turn in flight; input ignored", classes="error-label")
|
||||
)
|
||||
transcript.scroll_end(animate=False)
|
||||
event.input.value = ""
|
||||
return
|
||||
content = event.input.value.strip()
|
||||
@@ -776,54 +1276,78 @@ class RatatoskrApp(App[int]):
|
||||
# v0.4.1 retheme: operator's voice gets Australis bright cyan so it
|
||||
# stands out against the default-foreground assistant text below it.
|
||||
from rich.text import Text as RichText
|
||||
log.write(RichText(f"❯ {content}", style=_AU_USER_ECHO)) # noqa: RUF001
|
||||
transcript.mount(
|
||||
Static(
|
||||
RichText(f"❯ {content}", style=_AU_USER_ECHO), # noqa: RUF001
|
||||
classes="prompt-echo",
|
||||
)
|
||||
)
|
||||
transcript.scroll_end(animate=False)
|
||||
event.input.value = ""
|
||||
self.state = "streaming"
|
||||
self._transition("streaming", "input_submitted")
|
||||
self._audit(f"worker_spawn content_len={len(content)}")
|
||||
self._set_hint(self.HINT_STREAMING)
|
||||
self.stream_worker = self.run_worker(
|
||||
self._stream_turn_worker(content), exclusive=True
|
||||
)
|
||||
|
||||
async def _stream_turn_worker(self, content: str) -> None:
|
||||
"""Drive stream_turn, render events via TuiPresenterState (issue #12)."""
|
||||
"""Drive stream_turn, render events via TuiPresenterState.
|
||||
|
||||
v0.9.0: transcript is a VerticalScroll; the presenter's `transcript`
|
||||
argument is the container, and the presenter mounts Static / Markdown-
|
||||
backed widgets directly. Wire-error labels mount as `error-label`
|
||||
Statics into the transcript-scroll.
|
||||
"""
|
||||
assert self.state == "streaming"
|
||||
assert self.client is not None
|
||||
assert content
|
||||
log = self.query_one("#transcript", RichLog)
|
||||
transcript = self.query_one("#transcript-scroll", VerticalScroll)
|
||||
tools_log = self.query_one("#tools-log", RichLog)
|
||||
debug_log = self.query_one("#debug-log", RichLog)
|
||||
thinking_log = self.query_one("#thinking-log", RichLog)
|
||||
presenter = TuiPresenterState()
|
||||
|
||||
def _mount_wire_error(label: str) -> None:
|
||||
try:
|
||||
transcript.mount(Static(label, classes="error-label"))
|
||||
transcript.scroll_end(animate=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
async for event in stream_turn(self.client, self.session_id, content):
|
||||
if self.active_turn_id is None:
|
||||
self.active_turn_id = event.sse_id.turn_id
|
||||
# v0.6.0: turn-ID headers across all panes so the
|
||||
# operator can visually correlate sections during
|
||||
# cross-pane debugging.
|
||||
self._write_turn_headers(self.active_turn_id)
|
||||
presenter.render(
|
||||
event,
|
||||
log=log,
|
||||
transcript=transcript,
|
||||
tools_log=tools_log,
|
||||
debug_log=debug_log,
|
||||
thinking_log=thinking_log,
|
||||
raw=self.args.raw,
|
||||
on_persona_snapshot=self._update_persona_surfaces,
|
||||
)
|
||||
if isinstance(event, (Done, Error, Cancelled)):
|
||||
break
|
||||
except SseConnectFailed as exc:
|
||||
log.write(f"[sse_connect_failed] status={exc.status} body={exc.body!r}")
|
||||
self._audit(f"sse_connect_failed status={exc.status} body={exc.body!r:.120}")
|
||||
_mount_wire_error(f"[sse_connect_failed] status={exc.status} body={exc.body!r}")
|
||||
except SseConnectionDropped as exc:
|
||||
log.write(f"[connection_dropped] last_seen={exc.last_seen_sse_id}")
|
||||
self._audit(f"connection_dropped last_seen={exc.last_seen_sse_id}")
|
||||
_mount_wire_error(f"[connection_dropped] last_seen={exc.last_seen_sse_id}")
|
||||
except MalformedSseId as exc:
|
||||
log.write(f"[malformed_sse_id] raw={exc.raw!r}")
|
||||
self._audit(f"malformed_sse_id raw={exc.raw!r}")
|
||||
_mount_wire_error(f"[malformed_sse_id] raw={exc.raw!r}")
|
||||
except MalformedSseData as exc:
|
||||
log.write(f"[malformed_sse_data] raw={exc.raw!r}")
|
||||
self._audit(f"malformed_sse_data raw={exc.raw!r:.120}")
|
||||
_mount_wire_error(f"[malformed_sse_data] raw={exc.raw!r}")
|
||||
except TurnIdFlip as exc:
|
||||
log.write(f"[turn_id_flip] expected={exc.established} got={exc.got}")
|
||||
self._audit(f"turn_id_flip expected={exc.established} got={exc.got}")
|
||||
_mount_wire_error(f"[turn_id_flip] expected={exc.established} got={exc.got}")
|
||||
finally:
|
||||
self.state = "idle"
|
||||
self._transition("idle", "worker_finally")
|
||||
self.active_turn_id = None
|
||||
self._set_hint(self.HINT_IDLE)
|
||||
|
||||
@@ -835,26 +1359,35 @@ class RatatoskrApp(App[int]):
|
||||
"""Two-stage Ctrl-C state machine per INV-003."""
|
||||
assert self.state in ("idle", "streaming", "cancelling")
|
||||
if self.state == "idle":
|
||||
self._audit("ctrl_c state=idle action=exit code=0")
|
||||
self.exit(0)
|
||||
elif self.state == "streaming":
|
||||
if self.active_turn_id is None:
|
||||
self._audit("ctrl_c state=streaming active_turn_id=None action=force_exit code=3")
|
||||
if self.stream_worker is not None:
|
||||
self.stream_worker.cancel()
|
||||
self.exit(3)
|
||||
return
|
||||
self.state = "cancelling"
|
||||
self._audit(f"ctrl_c state=streaming turn_id={self.active_turn_id} action=cancel_post")
|
||||
self._transition("cancelling", "ctrl_c_cancel_post_issued")
|
||||
self._set_hint(self.HINT_CANCELLING)
|
||||
log = self.query_one("#transcript", RichLog)
|
||||
transcript = self.query_one("#transcript-scroll", VerticalScroll)
|
||||
self.run_worker(
|
||||
_cancel_via_sse(self.client, self.session_id, self.active_turn_id, log=log)
|
||||
_cancel_via_sse(
|
||||
self.client, self.session_id, self.active_turn_id,
|
||||
transcript=transcript,
|
||||
audit=self._audit,
|
||||
)
|
||||
)
|
||||
elif self.state == "cancelling":
|
||||
self._audit("ctrl_c state=cancelling action=force_exit code=3")
|
||||
if self.stream_worker is not None:
|
||||
self.stream_worker.cancel()
|
||||
self.exit(3)
|
||||
|
||||
def action_quit(self) -> None:
|
||||
"""Ctrl-D — immediate exit regardless of state."""
|
||||
self._audit(f"ctrl_d state={self.state} action=exit code=0")
|
||||
if self.stream_worker is not None and not self.stream_worker.is_finished:
|
||||
self.stream_worker.cancel()
|
||||
self.exit(0)
|
||||
@@ -880,6 +1413,11 @@ class RatatoskrApp(App[int]):
|
||||
self.query_one("#side-panes", TabbedContent).active = "thinking-tab"
|
||||
self.query_one("#pane-name", Static).update("Thinking")
|
||||
|
||||
def action_focus_persona(self) -> None:
|
||||
"""v0.13.0: Ctrl+4 activates the Persona tab. INV-016 preserves Input focus."""
|
||||
self.query_one("#side-panes", TabbedContent).active = "persona-tab"
|
||||
self.query_one("#pane-name", Static).update("Persona")
|
||||
|
||||
|
||||
def run_tui(args: ParsedArgs) -> int:
|
||||
"""Sync entry point — delegates to the async resolve-then-run flow.
|
||||
@@ -995,12 +1533,33 @@ async def _cancel_via_sse(
|
||||
session_id: str,
|
||||
turn_id: int,
|
||||
*,
|
||||
log: RichLog,
|
||||
transcript: VerticalScroll,
|
||||
audit: "Callable[[str], None] | None" = None,
|
||||
) -> None:
|
||||
"""Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009)."""
|
||||
"""Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009).
|
||||
|
||||
v0.9.0: mounts a `[cancel_failed]` Static into the transcript-scroll
|
||||
container on failure (was log.write to RichLog).
|
||||
v0.10.0: optional `audit` callback (RatatoskrApp._audit) receives one
|
||||
line on POST issue + one on POST result, so the debug pane carries the
|
||||
full cancel lifecycle. Defaults to no-op for legacy callers.
|
||||
"""
|
||||
assert client is not None
|
||||
assert isinstance(turn_id, int) and turn_id > 0
|
||||
if audit is not None:
|
||||
audit(f"cancel_post issued session_id={session_id} turn_id={turn_id}")
|
||||
try:
|
||||
await cancel_turn(client, session_id, turn_id)
|
||||
if audit is not None:
|
||||
audit(f"cancel_post ok turn_id={turn_id}")
|
||||
except (CancelFailed, CancelTurnNotFound, CancelAlreadyCompleted, httpx.RequestError) as exc:
|
||||
log.write(f"[cancel_failed] {type(exc).__name__}: {exc}")
|
||||
if audit is not None:
|
||||
audit(f"cancel_post failed turn_id={turn_id} {type(exc).__name__}: {exc!s:.120}")
|
||||
try:
|
||||
transcript.mount(Static(
|
||||
f"[cancel_failed] {type(exc).__name__}: {exc}",
|
||||
classes="error-label",
|
||||
))
|
||||
transcript.scroll_end(animate=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -0,0 +1,403 @@
|
||||
"""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 Exception:
|
||||
pass
|
||||
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
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,15 @@ import respx
|
||||
|
||||
from ratatoskr.sessions import (
|
||||
AgentInfo,
|
||||
AgentNotAvailable,
|
||||
AgentNotFound,
|
||||
AuthScopeDenied,
|
||||
InvalidCursor,
|
||||
PersonaNotConfigured,
|
||||
SessionApiFailed,
|
||||
SessionPage,
|
||||
create_session,
|
||||
get_persona_state,
|
||||
list_agents,
|
||||
list_sessions,
|
||||
)
|
||||
@@ -556,3 +560,148 @@ class TestListAgents:
|
||||
with pytest.raises(SessionApiFailed) as excinfo:
|
||||
await list_agents(client)
|
||||
assert excinfo.value.status == 401
|
||||
|
||||
|
||||
class TestGetPersonaState:
|
||||
"""Worldtree #204 / v0.28.0 — GET /agents/{agent_id}/persona_state.
|
||||
|
||||
Bootstrap read for the persona snapshot — same shape as `affect_update`'s
|
||||
`current` snapshot. Auth via `persona.read` scope (user-tier default).
|
||||
"""
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_full_snapshot(self) -> None:
|
||||
"""happy_full_snapshot [happy,tracer]: 200 → snapshot dict with pad +
|
||||
dominant_emotion + emotions_active + baseline_pad + mood_drift.
|
||||
"""
|
||||
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-25T22:30:18+00:00",
|
||||
}
|
||||
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
||||
return_value=httpx.Response(200, json=snapshot)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await get_persona_state(client, "mimir")
|
||||
assert result == snapshot
|
||||
|
||||
@respx.mock
|
||||
async def test_persona_not_configured_404(self) -> None:
|
||||
"""persona_not_configured_404 [error]: 404 with error_code
|
||||
persona_not_configured → PersonaNotConfigured. Agent exists but has
|
||||
no persona surface (e.g. domari, muninn, Tier 3).
|
||||
"""
|
||||
respx.get("https://w.example/agents/domari/persona_state").mock(
|
||||
return_value=httpx.Response(
|
||||
404, json={"error_code": "persona_not_configured", "message": "no persona"}
|
||||
)
|
||||
)
|
||||
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"
|
||||
|
||||
@respx.mock
|
||||
async def test_agent_not_available_404(self) -> None:
|
||||
"""agent_not_available_404 [error]: 404 with error_code
|
||||
agent_not_available → AgentNotAvailable. Distinct from
|
||||
persona_not_configured — the agent_id itself is unknown.
|
||||
"""
|
||||
respx.get("https://w.example/agents/bogus/persona_state").mock(
|
||||
return_value=httpx.Response(
|
||||
404, json={"error_code": "agent_not_available", "message": "unknown agent"}
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AgentNotAvailable) as exc_info:
|
||||
await get_persona_state(client, "bogus")
|
||||
assert exc_info.value.agent_id == "bogus"
|
||||
|
||||
@respx.mock
|
||||
async def test_auth_scope_denied_403(self) -> None:
|
||||
"""auth_scope_denied_403 [error]: 403 with error_code auth_scope_denied
|
||||
→ AuthScopeDenied. Key lacks `persona.read` scope.
|
||||
"""
|
||||
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
||||
return_value=httpx.Response(
|
||||
403,
|
||||
json={"error_code": "auth_scope_denied", "message": "missing 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_404_unknown_error_code_falls_through(self) -> None:
|
||||
"""404_unknown_error_code_falls_through [adversarial]: 404 without the
|
||||
two known error codes → SessionApiFailed (don't swallow novel failure
|
||||
modes as something more specific than they are).
|
||||
"""
|
||||
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
||||
return_value=httpx.Response(404, json={"error_code": "novel_404"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc_info:
|
||||
await get_persona_state(client, "mimir")
|
||||
assert exc_info.value.status == 404
|
||||
|
||||
@respx.mock
|
||||
async def test_500_unexpected_status(self) -> None:
|
||||
"""500_unexpected_status [error]: 5xx → SessionApiFailed (matches the
|
||||
list_agents / list_sessions / create_session precedent)."""
|
||||
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
||||
return_value=httpx.Response(500, content=b"boom")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
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"
|
||||
|
||||
@@ -5,6 +5,8 @@ import pytest
|
||||
import respx
|
||||
|
||||
from ratatoskr.sse_client import (
|
||||
AffectUpdate,
|
||||
AwaitingLlmFirstToken,
|
||||
CancelAlreadyCompleted,
|
||||
Cancelled,
|
||||
CancelResult,
|
||||
@@ -877,3 +879,164 @@ class TestEmptyDataSkipped:
|
||||
assert exc_info.value.raw == "x" * 200
|
||||
# Exception message also only contains the truncated form
|
||||
assert "x" * 5000 not in str(exc_info.value)
|
||||
|
||||
|
||||
class TestAffectUpdate:
|
||||
"""Worldtree #204 / v0.28.0 — persona-state observability SSE event.
|
||||
|
||||
Two emissions per qualifying turn (persona-enabled agent, non-ephemeral
|
||||
session): `status: "current"` at turn start with full snapshot, then
|
||||
`status: "scheduled"` near turn end (lightweight, no snapshot).
|
||||
|
||||
See docs/conversation-api-spec.md § affect_update.
|
||||
"""
|
||||
|
||||
@respx.mock
|
||||
async def test_current_status_parsed_with_snapshot(self) -> None:
|
||||
"""current_status_parsed_with_snapshot [tracer]: status=current carries
|
||||
the full snapshot dict; AffectUpdate.snapshot is populated with the
|
||||
nested PAD / dominant_emotion / emotions_active fields.
|
||||
"""
|
||||
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-25T22:30:18+00:00",
|
||||
}
|
||||
stream = _sse_chunk(
|
||||
"42:1",
|
||||
{
|
||||
"type": "affect_update",
|
||||
"status": "current",
|
||||
"turn_id": 42,
|
||||
"snapshot": snapshot,
|
||||
},
|
||||
) + _sse_chunk("42:2", _DONE_42_6)
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=stream
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
affect = events[0]
|
||||
assert isinstance(affect, AffectUpdate)
|
||||
assert affect.status == "current"
|
||||
assert affect.turn_id == 42
|
||||
assert affect.snapshot == snapshot
|
||||
assert affect.sse_id == SseId(42, 1)
|
||||
|
||||
@respx.mock
|
||||
async def test_scheduled_status_parsed_no_snapshot(self) -> None:
|
||||
"""scheduled_status_parsed_no_snapshot [trace]: status=scheduled carries
|
||||
no snapshot field; AffectUpdate.snapshot is None.
|
||||
"""
|
||||
stream = (
|
||||
_sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
+ _sse_chunk(
|
||||
"42:2",
|
||||
{"type": "affect_update", "status": "scheduled", "turn_id": 42},
|
||||
)
|
||||
+ _sse_chunk("42:3", _DONE_42_6)
|
||||
)
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=stream
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
affect = next(e for e in events if isinstance(e, AffectUpdate))
|
||||
assert affect.status == "scheduled"
|
||||
assert affect.turn_id == 42
|
||||
assert affect.snapshot is None
|
||||
assert affect.sse_id == SseId(42, 2)
|
||||
|
||||
|
||||
class TestAwaitingLlmFirstToken:
|
||||
"""Worldtree #201 / v0.29.0 — `awaiting_llm_first_token` SSE heartbeat.
|
||||
|
||||
Top-level event (not a worker_phase extension) fired during the
|
||||
BuildingPrompt → CallingLLM gap at the configured interval (default
|
||||
5s). Server-authoritative elapsed_ms is time.monotonic()-based and
|
||||
monotonically increasing across the heartbeat sequence.
|
||||
|
||||
See docs/conversation-api-spec.md § awaiting_llm_first_token.
|
||||
"""
|
||||
|
||||
@respx.mock
|
||||
async def test_single_heartbeat_parsed(self) -> None:
|
||||
"""single_heartbeat_parsed [tracer]: type=awaiting_llm_first_token →
|
||||
AwaitingLlmFirstToken(turn_id, elapsed_ms_since_building_prompt).
|
||||
"""
|
||||
stream = _sse_chunk(
|
||||
"42:1",
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 5012.3,
|
||||
},
|
||||
) + _sse_chunk("42:2", _DONE_42_6)
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=stream
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
beat = events[0]
|
||||
assert isinstance(beat, AwaitingLlmFirstToken)
|
||||
assert beat.turn_id == 42
|
||||
assert beat.elapsed_ms_since_building_prompt == 5012.3
|
||||
assert beat.sse_id == SseId(42, 1)
|
||||
|
||||
@respx.mock
|
||||
async def test_heartbeat_sequence_monotonic(self) -> None:
|
||||
"""heartbeat_sequence_monotonic [scenario]: three consecutive heartbeats
|
||||
in one turn — elapsed_ms_since_building_prompt monotonically increases,
|
||||
all carry the same turn_id.
|
||||
"""
|
||||
stream = (
|
||||
_sse_chunk(
|
||||
"42:1",
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 5000.0,
|
||||
},
|
||||
)
|
||||
+ _sse_chunk(
|
||||
"42:2",
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 10005.4,
|
||||
},
|
||||
)
|
||||
+ _sse_chunk(
|
||||
"42:3",
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 15011.8,
|
||||
},
|
||||
)
|
||||
+ _sse_chunk("42:4", _DONE_42_6)
|
||||
)
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=stream
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
beats = [e for e in events if isinstance(e, AwaitingLlmFirstToken)]
|
||||
assert len(beats) == 3
|
||||
elapsed = [b.elapsed_ms_since_building_prompt for b in beats]
|
||||
assert elapsed == sorted(elapsed) # monotonically increasing
|
||||
assert all(b.turn_id == 42 for b in beats)
|
||||
|
||||
+725
-148
File diff suppressed because it is too large
Load Diff
@@ -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 == []
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,757 @@
|
||||
"""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."""
|
||||
|
||||
@respx.mock
|
||||
def test_full_event_vocab(self) -> None:
|
||||
"""full_event_vocab [scenario]: a stream with one of each Event type
|
||||
→ each serialized to its fixture-shaped browser event."""
|
||||
stream = b"".join([
|
||||
_sse_chunk("42:1", {"type": "worker_phase", "phase": "BuildingPrompt", "turn_id": 42}),
|
||||
_sse_chunk("42:2", {"type": "thinking", "content": "hmm"}),
|
||||
_sse_chunk("42:3", {"type": "text", "content": "hi"}),
|
||||
_sse_chunk("42:4", {"type": "text_boundary", "kind": "sentence", "char_offset": 2, "ts": "t"}),
|
||||
_sse_chunk("42:5", {"type": "tool_start", "name": "s", "arguments": {"q": "x"}}),
|
||||
_sse_chunk("42:6", {"type": "tool_result", "name": "s", "result": {"n": 1}, "duration_ms": 3}),
|
||||
_sse_chunk("42:7", {"type": "awaiting_llm_first_token", "turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 5000.0}),
|
||||
_sse_chunk("42:8", _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)
|
||||
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())
|
||||
types = [e["event"] for e in _parse_browser_sse(raw)]
|
||||
for expected in ("worker_phase", "thinking", "text", "text_boundary",
|
||||
"tool_start", "tool_result", "awaiting_llm_first_token", "done"):
|
||||
assert expected in types, f"missing browser event {expected}"
|
||||
|
||||
|
||||
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)"
|
||||
@@ -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.8.2"
|
||||
version = "0.16.0"
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user