feat: web SPA bind — add 'combined' (:8392) both-plane option as default
The bind dropdown offered only memory/affect single-plane binds; #18's composite endpoint (:8392, both planes in one session) was never reachable from the SPA. Add 'combined' as the default-selected option, keeping memory-only / affect-only for single-plane isolation diagnostics. - endpoint_for_plane: combined -> :8392 (sessions.py) - web server: accept bifrost_plane="combined" (server.py) - dropdown: combined (:8392) default-selected, single-plane retained (index.html) - #17 contract: endpoint_for_plane FN + plane-selector spec updated to combined - tests: endpoint_for_plane combined, server combined bind -> :8392, dropdown default Suite 506 green. Live-verified on :8765 (current code).
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
contract_version: "2.1"
|
contract_version: "2.1"
|
||||||
target_module: "ratatoskr.sessions + ratatoskr.provider (+ cli/tui/web trigger surfaces)"
|
target_module: "ratatoskr.sessions + ratatoskr.provider (+ cli/tui/web trigger surfaces)"
|
||||||
scope: "Issue #17 v1 — make the canary chat client self-drive AND observe its own Bifrost provider. Two parts. (1) BIND: `create_session` gains an optional single-plane Bifrost binding (`BifrostBinding{endpoint_url, scope}`) authenticated with a DISTINCT consumer Heimdall key; Worldtree runs the handshake synchronously at POST /sessions, so handshake failure is a session-create failure (502), surfaced on the create path. A plane selector (`memory`→:8391 / `affect`→:8390) + the consumer key thread through CLI / TUI / web; bound-state is visible. (2) OBSERVE: a structured op-feed in the provider, instrumented at the DISPATCH/ASGI layer (where the JWT ctx / session_id lives — bifrost passes ctx to upsert_many but NOT to search/get/delete, so the existing store-method stdout shim cannot see session_id), emitting JSONL {session_id, plane, op, req_summary, resp_summary, status, ts}. OPERATOR DECISIONS LOCKED: single-plane-per-session for v1 (composite endpoint fronting both planes is PARKED — vNext); op-feed with session-level correlation for v1 (turn-correlated debug-pane UI is PARKED — needs turn_id, TBD). Provider store scope semantics MUST NOT change (AND-parity with bifrost's reference store is a hard constraint). Direct in-session TDD; live-smoke against personal Worldtree is the load-bearing acceptance gate."
|
scope: "Issue #17 v1 — make the canary chat client self-drive AND observe its own Bifrost provider. Two parts. (1) BIND: `create_session` gains an optional single-plane Bifrost binding (`BifrostBinding{endpoint_url, scope}`) authenticated with a DISTINCT consumer Heimdall key; Worldtree runs the handshake synchronously at POST /sessions, so handshake failure is a session-create failure (502), surfaced on the create path. A plane selector (`memory`→:8391 / `affect`→:8390; `combined`→:8392 added post-#17 — the #18 composite, the web default) + the consumer key thread through CLI / TUI / web; bound-state is visible. (2) OBSERVE: a structured op-feed in the provider, instrumented at the DISPATCH/ASGI layer (where the JWT ctx / session_id lives — bifrost passes ctx to upsert_many but NOT to search/get/delete, so the existing store-method stdout shim cannot see session_id), emitting JSONL {session_id, plane, op, req_summary, resp_summary, status, ts}. OPERATOR DECISIONS LOCKED: single-plane-per-session for v1 (composite endpoint fronting both planes was PARKED at #17 — later shipped as #18 and surfaced in the web bind as the `combined` plane); op-feed with session-level correlation for v1 (turn-correlated debug-pane UI is PARKED — needs turn_id, TBD). Provider store scope semantics MUST NOT change (AND-parity with bifrost's reference store is a hard constraint). Direct in-session TDD; live-smoke against personal Worldtree is the load-bearing acceptance gate."
|
||||||
depends_on:
|
depends_on:
|
||||||
- "httpx"
|
- "httpx"
|
||||||
- "ratatoskr.sessions"
|
- "ratatoskr.sessions"
|
||||||
@@ -92,8 +92,8 @@ async def create_session(
|
|||||||
|
|
||||||
|
|
||||||
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
||||||
"""'memory'->:8391, 'affect'->:8390 → f'http://{base_host}:{port}'. The
|
"""'memory'->:8391, 'affect'->:8390, 'combined'->:8392 (#18 composite) →
|
||||||
Worldtree-visible base URL. See FN endpoint_for_plane."""
|
f'http://{base_host}:{port}'. The Worldtree-visible base URL. See FN endpoint_for_plane."""
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -218,12 +218,12 @@ STEPS:
|
|||||||
|
|
||||||
```contract
|
```contract
|
||||||
FN endpoint_for_plane(plane: str, base_host: str) -> str
|
FN endpoint_for_plane(plane: str, base_host: str) -> str
|
||||||
BRIEF: Map a plane name to the Worldtree-visible provider base URL (memory->:8391, affect->:8390).
|
BRIEF: Map a plane name to the Worldtree-visible provider base URL (memory->:8391, affect->:8390, combined->:8392 — the #18 composite both-plane endpoint, surfaced post-#17).
|
||||||
|
|
||||||
PRE: [PRE-001 hard] plane in {"memory", "affect"} -- else ValueError
|
PRE: [PRE-001 hard] plane in {"memory", "affect", "combined"} -- else ValueError
|
||||||
POST: [POST-001 return_value] returns f"http://{base_host}:{port}", port 8391 (memory) / 8390 (affect) -- assert
|
POST: [POST-001 return_value] returns f"http://{base_host}:{port}", port 8391 (memory) / 8390 (affect) / 8392 (combined) -- assert
|
||||||
STEPS:
|
STEPS:
|
||||||
1. port = 8391 if plane == "memory" else 8390
|
1. port = {"memory": 8391, "affect": 8390, "combined": 8392}[plane]
|
||||||
2. return the Worldtree-VISIBLE base URL (not client loopback); HTTPS relaxation is allowlist-side, not a URL concern
|
2. return the Worldtree-VISIBLE base URL (not client loopback); HTTPS relaxation is allowlist-side, not a URL concern
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.18.0"
|
version = "0.18.1"
|
||||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
+12
-10
@@ -227,19 +227,21 @@ async def list_sessions(
|
|||||||
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
||||||
"""Map a provider plane name to its Worldtree-VISIBLE base URL.
|
"""Map a provider plane name to its Worldtree-VISIBLE base URL.
|
||||||
|
|
||||||
Issue #17 dev helper: `memory` → :8391, `affect` → :8390. Returns the
|
Issue #17 dev helper: `memory` → :8391, `affect` → :8390, `combined` → :8392
|
||||||
Worldtree-visible base (e.g. `http://10.100.10.50:8391`), NOT the client's
|
(the #18 composite both-plane endpoint). Returns the Worldtree-visible base
|
||||||
loopback — Worldtree must reach the provider over the network. `http://` is
|
(e.g. `http://10.100.10.50:8391`), NOT the client's loopback — Worldtree must
|
||||||
deliberate: the HTTPS relaxation is allowlist-side (Worldtree's
|
reach the provider over the network. `http://` is deliberate: the HTTPS
|
||||||
BIFROST_CLIENT_ALLOWED_HOSTS), not a URL concern. A production HTTPS endpoint
|
relaxation is allowlist-side (Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS), not a
|
||||||
is supplied directly, bypassing this helper.
|
URL concern. A production HTTPS endpoint is supplied directly, bypassing this
|
||||||
|
helper.
|
||||||
"""
|
"""
|
||||||
if plane not in ("memory", "affect"):
|
ports = {"memory": 8391, "affect": 8390, "combined": 8392}
|
||||||
|
if plane not in ports:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"unknown plane: {plane!r} (expected 'memory' or 'affect')"
|
f"unknown plane: {plane!r} "
|
||||||
|
"(expected 'memory', 'affect', or 'combined')"
|
||||||
)
|
)
|
||||||
port = 8391 if plane == "memory" else 8390
|
return f"http://{base_host}:{ports[plane]}"
|
||||||
return f"http://{base_host}:{port}"
|
|
||||||
|
|
||||||
|
|
||||||
def _bifrost_error_from(resp: httpx.Response) -> str | None:
|
def _bifrost_error_from(resp: httpx.Response) -> str | None:
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
|
|||||||
consumer_key = request.app.state.bifrost_consumer_key
|
consumer_key = request.app.state.bifrost_consumer_key
|
||||||
visible_host = request.app.state.bifrost_visible_host
|
visible_host = request.app.state.bifrost_visible_host
|
||||||
if bifrost_plane:
|
if bifrost_plane:
|
||||||
if bifrost_plane not in ("memory", "affect"):
|
if bifrost_plane not in ("memory", "affect", "combined"):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error_code": "invalid_bifrost_plane"}, status_code=400
|
{"error_code": "invalid_bifrost_plane"}, status_code=400
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -513,6 +513,7 @@ body {
|
|||||||
<label class="field-label" for="bifrost-plane">Bifrost binding (Tier-3 provider)</label>
|
<label class="field-label" for="bifrost-plane">Bifrost binding (Tier-3 provider)</label>
|
||||||
<div class="select-wrap">
|
<div class="select-wrap">
|
||||||
<select id="bifrost-plane">
|
<select id="bifrost-plane">
|
||||||
|
<option value="combined" selected>combined (:8392) — PAD + memory in one session</option>
|
||||||
<option value="">none — observe only</option>
|
<option value="">none — observe only</option>
|
||||||
<option value="memory">memory (:8391) — durable recall</option>
|
<option value="memory">memory (:8391) — durable recall</option>
|
||||||
<option value="affect">affect (:8390) — persona / PAD</option>
|
<option value="affect">affect (:8390) — persona / PAD</option>
|
||||||
|
|||||||
@@ -384,6 +384,13 @@ class TestEndpointForPlane:
|
|||||||
== "http://10.100.10.50:8390"
|
== "http://10.100.10.50:8390"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_combined_plane_maps_to_8392(self) -> None:
|
||||||
|
"""combined [#18 composite]: 'combined' → http://<host>:8392 (POST-001)."""
|
||||||
|
assert (
|
||||||
|
endpoint_for_plane("combined", "10.100.10.50")
|
||||||
|
== "http://10.100.10.50:8392"
|
||||||
|
)
|
||||||
|
|
||||||
def test_unknown_plane_raises_value_error(self) -> None:
|
def test_unknown_plane_raises_value_error(self) -> None:
|
||||||
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
|
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
|
|||||||
@@ -837,6 +837,50 @@ class TestWebBifrostBind:
|
|||||||
}
|
}
|
||||||
assert upstream.headers["Authorization"] == "Bearer server-ck"
|
assert upstream.headers["Authorization"] == "Bearer server-ck"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_combined_plane_binds_to_8392(self) -> None:
|
||||||
|
"""combined [#18 composite]: a 'combined' plane from the browser → the server
|
||||||
|
binds the :8392 both-plane endpoint; bound-state echoes plane='combined'."""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
from ratatoskr.web.server import create_app
|
||||||
|
|
||||||
|
route = respx.post("https://w.example/sessions").mock(
|
||||||
|
return_value=httpx.Response(201, json=_CREATE_OK)
|
||||||
|
)
|
||||||
|
app = create_app(
|
||||||
|
_mock_client_factory(),
|
||||||
|
bifrost_consumer_key="server-ck",
|
||||||
|
bifrost_visible_host="10.100.10.50",
|
||||||
|
)
|
||||||
|
resp = TestClient(app).post(
|
||||||
|
"/api/sessions",
|
||||||
|
json={"agent_id": "ratatoskr:sindra", "bifrost_plane": "combined"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["bifrost"] == {
|
||||||
|
"plane": "combined",
|
||||||
|
"endpoint": "http://10.100.10.50:8392",
|
||||||
|
"status": "bound",
|
||||||
|
}
|
||||||
|
upstream = route.calls[0].request
|
||||||
|
body = _json.loads(upstream.content)
|
||||||
|
assert body["bifrost"] == {
|
||||||
|
"endpoint_url": "http://10.100.10.50:8392",
|
||||||
|
"scope": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_dropdown_offers_combined_as_default(self) -> None:
|
||||||
|
"""(a)+default: the SPA plane dropdown offers a 'combined' (:8392) option,
|
||||||
|
it is the DEFAULT-selected one, and single-plane memory/affect remain."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ratatoskr.web as web_pkg
|
||||||
|
|
||||||
|
html = (Path(web_pkg.__file__).parent / "static" / "index.html").read_text()
|
||||||
|
assert '<option value="combined" selected>' in html
|
||||||
|
assert 'value="memory"' in html and 'value="affect"' in html
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_plane_without_server_config_is_400(self) -> None:
|
def test_plane_without_server_config_is_400(self) -> None:
|
||||||
"""A plane requested but no server-held key/host → bifrost_not_configured."""
|
"""A plane requested but no server-held key/host → bifrost_not_configured."""
|
||||||
|
|||||||
Reference in New Issue
Block a user