369857d3f1
Heid panel review (Gróa + Hulda, thread 01KSP5P6CSJH) on v0.15.0/
v0.15.1 surfaced one load-bearing bug + several precision items. This
pass closes them.
Load-bearing fix — cancel paths targeted the wrong turn_id:
- `_TURN_COUNTER` allocates browser-local ids (1, 2, 3…); the real
upstream Worldtree turn_id (e.g. 799) only arrives in the first SSE
event. The v0.15.x cancel/disconnect/shutdown paths posted to
/sessions/{sid}/turns/{LOCAL_ID}/cancel — wrong URL upstream.
- TurnHandle.upstream_response (dead field) → upstream_turn_id: int|None.
Captured from the first event's sse_id.turn_id in the stream
generator. All cancel paths now target it. Cancel before the upstream
stream starts (upstream_turn_id None) is a no-op
({"cancelled": false, "reason": "not_started"}).
- The old cancel tests mocked the local-id URL, so they encoded the bug;
rewritten to assert the UPSTREAM id is targeted.
Behavior change (minor-bump driver) — server-side end_user_id:
- create_app gains end_user_id kwarg; entrypoint reads
RATATOSKR_END_USER_ID and threads it in. POST /api/sessions uses
app.state.end_user_id, IGNORING any browser-supplied value (a client
can't impersonate an arbitrary end-user partition). JS no longer
sends end_user_id.
Precision fixes:
- Entrypoint missing-extras ImportError catch scoped to starlette/
uvicorn ONLY; baseline-dep / first-party import failures now
propagate as real tracebacks instead of masking as exit-12.
- Lifespan shutdown logs per-pending session_id + upstream_turn_id
(was a single aggregate count).
Tests (+18; 376 total):
- disconnect_triggers_upstream_cancel (INV-005 load-bearing — drives
the stream generator directly + cancels the consuming task; would
have caught the turn_id bug)
- cancel_targets_upstream_turn_id, cancel_before_started_is_noop,
cancel_failed_500
- server-side end_user_id: uses / ignores-body / omits-when-unset
- create_app: routes_registered / state_attached / factory_stored
- entrypoint: default_host / port_zero / happy_argv / open / no-open
- real_import_bug_propagates (precision guard)
- full_event_vocab at the stream-endpoint layer
Contract #16 amended: v0.16.0 amendment banner + INV-005/006 reworded
for upstream_turn_id + FN sketches corrected (server-side end_user_id,
upstream_response→upstream_turn_id, manual client lifecycle vs the
non-executable async-with sketch, not-started cancel branch).
395 lines
29 KiB
Markdown
395 lines
29 KiB
Markdown
---
|
|
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
|
|
```
|