feat(web): in-browser debug companion — issue #16 (v0.15.0)
Browser-based debug companion to the Ratatoskr TUI, reusing the
existing wire-layer modules unchanged. Same five surfaces (transcript,
thinking, tools, debug, persona) over the same Worldtree Conversation
API SSE wire, viewable from any device on the operator's LAN.
Per docs/contracts/issues/16.contract.md (full v2.1 module contract
with 11 FN blocks + 9 invariants + Heid panel review pass merged).
Architecture:
- New module `ratatoskr.web` with `server.py` (Starlette app, ~250 LOC),
`entrypoint.py` (lazy-import gate, ~100 LOC), `static/index.html`
(single-page vanilla JS UI, ~360 LOC)
- Optional-deps group `[web]` = starlette + uvicorn[standard]; dev
pulls these in transitively
- New console script `ratatoskr-web`
- Streaming via browser-native `EventSource` GET; prompt-submit is a
separate POST (load-bearing Hulda finding from R13 panel — EventSource
is GET-only)
- Small in-memory turn registry maps (session_id, turn_id) → upstream
request handle for cancel + browser-disconnect cleanup
Endpoint surface (9 routes):
- `GET /` → static index.html
- `GET /static/*` → static assets
- `GET /version` → {"ratatoskr": "<version>"}
- `GET /api/agents` → upstream /agents + local Tier 3 merge
- `POST /api/sessions` → upstream POST /sessions
- `GET /api/agents/{id}/persona_state` → upstream persona-state
- `POST /api/turns/{sid}` → allocate turn_id, register in turn registry
- `GET /api/turns/{sid}/stream?turn_id=N` → proxy upstream SSE to browser
- `POST /api/turns/{sid}/cancel?turn_id=N` → upstream cancel
Trust model: internal LAN debug surface. Binds 0.0.0.0:8765 default;
no auth, no CORS guard (operator direction). What stays disciplined
regardless of network trust:
- Transcript HTML-escapes assistant content (INV-004 — model output
is untrusted text; adversarial HTML must not execute in browser)
- Upstream API key never reaches browser DOM (INV-003 — proxy-only)
Lifecycle:
- Browser disconnect mid-stream → upstream cancel (INV-005;
asyncio.CancelledError caught in stream handler)
- Server Ctrl-C → lifespan shutdown drains turn registry within 5s
budget (INV-006; structured-log line on timeout)
Tests (37 new, 356 total; previous 319 baseline preserved):
- tests/test_web_server.py (23 cases): endpoint contract via Starlette
TestClient + respx mocks; covers each endpoint, browser-disconnect →
upstream cancel, lifespan shutdown draining the registry
- tests/test_web_presentation_contract.py (11 cases): proxy
serialization matches tests/fixtures/presentation_contract.json
for one of each Event type — drift detection between server-side
serializer and the JS presenter without forcing a shared abstraction
- tests/test_web_packaging.py (4 cases): static asset packaging via
importlib.resources; AST-checked lazy-import discipline (no top-
level starlette/uvicorn import in entrypoint.py); missing-API-key
exit-11 path; missing-extras exit-12 path
Provenance:
- Scope v1 → Heid panel review (Gróa + Hulda, R13) → 8 load-bearing
corrections (POST→GET split, Starlette > FastAPI, lazy-import
discipline, browser-disconnect → upstream cancel, presentation-
contract fixture, error event contract, static-asset packaging,
escaped plain-text Markdown deferred) merged into scope v2
- Operator direction: internal-LAN debug surface; auth + CORS
deliberately omitted
Not yet (deferred to v0.16.x+):
- Cross-reload session resume via Last-Event-ID
- Tier 3 lifecycle UI (define/patch/delete in browser)
- Markdown rendering with vendored safe-subset renderer
- TLS + real auth (only if a non-LAN use case ever surfaces)
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
"""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.
|
||||
|
||||
Simulated by shadowing the import within main()'s try-block via
|
||||
sys.modules tampering — pre-set the offending module to None so the
|
||||
import raises ImportError.
|
||||
"""
|
||||
import sys
|
||||
|
||||
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
|
||||
|
||||
rc = main(["--port", "0"])
|
||||
assert rc == 12
|
||||
Reference in New Issue
Block a user