diff --git a/examples/mcp-cluster-ops/README.md b/examples/mcp-cluster-ops/README.md index 27d7a542..d92d58d7 100644 --- a/examples/mcp-cluster-ops/README.md +++ b/examples/mcp-cluster-ops/README.md @@ -2,6 +2,43 @@ An MCP server that exposes tools for executing commands across a Turnstone cluster. Serves as a reference implementation for both MCP server patterns and Turnstone SDK usage. +> [!NOTE] +> **Superseded by the built-in coordinator workstream in Turnstone 1.5.** +> +> This MCP side-car is the pre-1.5 pattern for cluster-wide orchestration. +> Turnstone 1.5 promotes coordinator behaviour to a first-class workstream +> kind hosted inside `turnstone-console` — no external MCP server to +> install or operate, proper per-user audit attribution, and a dedicated +> UI at `/coordinator/{ws_id}`. +> +> The extension continues to work for 1.4-and-earlier clusters. On 1.5+: +> grant the `admin.coordinator` permission, set `coordinator.model_alias` +> in the admin Settings tab, and create sessions via the dashboard's +> "new coordinator" button or `POST /v1/api/coordinator/new`. Full +> removal of this example (including docker / compose references) is +> planned once 1.5 is confirmed in production. +> +> | Concern | Built-in coordinator (1.5+) | This MCP extension (1.4-and-earlier) | +> |---|---|---| +> | Install | None — shipped in-tree | `pip install -e examples/mcp-cluster-ops` + MCP client config | +> | Auth | Real creator's `user_id` + `admin.coordinator` permission | Shared service token | +> | Audit | `coordinator.create` / `close` / `cancel` events on the console; `src="coordinator"` preserved on upstream hops | Service identity only | +> | UI | `/coordinator/{ws_id}` one-pane HTML | No UI — model-only | +> | Tool approvals | Inline approval bar in the coordinator pane | MCP approval flow | +> | Configuration | `coordinator.model_alias`, `coordinator.max_active`, `coordinator.reasoning_effort`, `coordinator.session_jwt_ttl_seconds` | MCP server config file | +> +> Minimal 1.5 migration: +> +> ```bash +> curl -X POST https://console.example/v1/api/coordinator/new \ +> -H "Authorization: Bearer $TOKEN" \ +> -H "Content-Type: application/json" \ +> -d '{"name":"planner","initial_message":"Spawn a worker to check the build"}' +> ``` +> +> The response carries `ws_id`; open +> `https://console.example/coordinator/{ws_id}` to watch the session. + ## How it works This server uses the Turnstone console SDK (`TurnstoneConsole`) for node discovery and routing, and `TurnstoneServer` for per-node SSE streaming. The dispatch flow for each command is: diff --git a/pyproject.toml b/pyproject.toml index 35bab0b2..94754a45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,8 @@ include = [ "turnstone/console/static/*.html", "turnstone/console/static/*.css", "turnstone/console/static/*.js", + "turnstone/console/static/coordinator/*.html", + "turnstone/console/static/coordinator/*.js", "turnstone/shared_static/*.css", "turnstone/shared_static/*.js", "turnstone/shared_static/katex-0.16.45/**/*", diff --git a/tests/test_coordinator_client.py b/tests/test_coordinator_client.py new file mode 100644 index 00000000..c053f4f9 --- /dev/null +++ b/tests/test_coordinator_client.py @@ -0,0 +1,429 @@ +"""Tests for ``turnstone.console.coordinator_client.CoordinatorClient``. + +Uses an httpx MockTransport to intercept outbound requests so we verify +the URL map, headers, and body shape without standing up a real console. +Read-op tests hit a real in-memory SQLite backend to confirm the +storage-call path. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import httpx +import pytest + +from turnstone.console.coordinator_client import ( + _ROUTE_PATHS, + CoordinatorClient, + CoordinatorTokenManager, +) +from turnstone.core.auth import JWT_AUD_CONSOLE, validate_jwt +from turnstone.core.storage._sqlite import SQLiteBackend + +if TYPE_CHECKING: + from collections.abc import Callable + +_SECRET = "x" * 64 + + +# --------------------------------------------------------------------------- +# CoordinatorTokenManager +# --------------------------------------------------------------------------- + + +def test_token_manager_mints_valid_console_jwt(): + tm = CoordinatorTokenManager( + user_id="user-1", + scopes=frozenset({"read", "write", "approve"}), + permissions=frozenset({"admin.coordinator"}), + secret=_SECRET, + coord_ws_id="coord-123", + ttl_seconds=300, + ) + token = tm.token + result = validate_jwt(token, _SECRET, audience=JWT_AUD_CONSOLE) + assert result is not None + assert result.user_id == "user-1" + assert "approve" in result.scopes + assert result.token_source == "coordinator" + + +def test_token_manager_embeds_coord_ws_id_claim(): + import jwt + + tm = CoordinatorTokenManager( + user_id="user-1", + scopes=frozenset({"read"}), + permissions=frozenset(), + secret=_SECRET, + coord_ws_id="coord-42", + ) + token = tm.token + decoded = jwt.decode(token, _SECRET, algorithms=["HS256"], audience=JWT_AUD_CONSOLE) + assert decoded["coord_ws_id"] == "coord-42" + assert decoded["src"] == "coordinator" + + +def test_token_manager_refreshes_near_expiry(monkeypatch): + """Force the expiry guard to fire and confirm _mint runs again.""" + tm = CoordinatorTokenManager( + user_id="u", + scopes=frozenset({"read"}), + permissions=frozenset(), + secret=_SECRET, + coord_ws_id="c", + ttl_seconds=10, + ) + calls = {"count": 0} + real_mint = tm._mint + + def _counting_mint() -> None: + calls["count"] += 1 + real_mint() + + monkeypatch.setattr(tm, "_mint", _counting_mint) + _ = tm.token + assert calls["count"] == 1 + # Not expired yet → no re-mint. + _ = tm.token + assert calls["count"] == 1 + # Force expiry. + tm._expires_at = 0.0 # type: ignore[attr-defined] + _ = tm.token + assert calls["count"] == 2 + + +def test_token_manager_rejects_nonpositive_ttl(): + with pytest.raises(ValueError): + CoordinatorTokenManager( + user_id="u", + scopes=frozenset(), + permissions=frozenset(), + secret=_SECRET, + coord_ws_id="c", + ttl_seconds=0, + ) + + +# --------------------------------------------------------------------------- +# CoordinatorClient — URL map + header plumbing via MockTransport +# --------------------------------------------------------------------------- + + +def _mock_client( + handler: Callable[[httpx.Request], httpx.Response], +) -> tuple[CoordinatorClient, list[httpx.Request]]: + """Build a CoordinatorClient with an httpx MockTransport recorder.""" + captured: list[httpx.Request] = [] + + def _trapping(req: httpx.Request) -> httpx.Response: + captured.append(req) + return handler(req) + + transport = httpx.MockTransport(_trapping) + http = httpx.Client(transport=transport) + # Minimal storage stub for read ops is OK — mutating-op tests don't + # touch storage, so a SQLiteBackend would also be fine. + storage = SQLiteBackend(":memory:") + client = CoordinatorClient( + console_base_url="http://console", + storage=storage, + token_factory=lambda: "test-token", + coord_ws_id="coord-1", + user_id="user-1", + http_client=http, + ) + return client, captured + + +def _ok_json(payload: dict) -> Callable[[httpx.Request], httpx.Response]: + def _h(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=payload) + + return _h + + +def test_route_map_matches_console_routes(): + """URL paths must match what ``turnstone/console/server.py`` registers. + + The routing proxy's _CONSOLE_ROUTES includes: + POST /v1/api/route/workstreams/new + POST /v1/api/route/send + POST /v1/api/route/approve + POST /v1/api/route/cancel + POST /v1/api/route/workstreams/close + + Phase B adds /v1/api/route/workstreams/delete; B9 review checks that + addition lands alongside the others. Here we assert our internal map + mirrors the shape we expect. + """ + assert _ROUTE_PATHS["spawn"] == "/v1/api/route/workstreams/new" + assert _ROUTE_PATHS["send"] == "/v1/api/route/send" + assert _ROUTE_PATHS["approve"] == "/v1/api/route/approve" + assert _ROUTE_PATHS["cancel"] == "/v1/api/route/cancel" + assert _ROUTE_PATHS["close"] == "/v1/api/route/workstreams/close" + assert _ROUTE_PATHS["delete"] == "/v1/api/route/workstreams/delete" + + +def test_spawn_posts_to_routing_proxy_with_bearer_token(): + client, captured = _mock_client(_ok_json({"ws_id": "child-1", "name": "c", "node_id": "n1"})) + result = client.spawn( + initial_message="hi", + parent_ws_id="coord-1", + user_id="user-1", + skill="my-skill", + target_node="n1", + ) + assert result["ws_id"] == "child-1" + assert len(captured) == 1 + req = captured[0] + assert req.method == "POST" + assert req.url.path == "/v1/api/route/workstreams/new" + assert req.headers["Authorization"] == "Bearer test-token" + body = json.loads(req.content) + assert body["kind"] == "interactive" + assert body["parent_ws_id"] == "coord-1" + assert body["user_id"] == "user-1" + assert body["initial_message"] == "hi" + assert body["skill"] == "my-skill" + assert body["target_node"] == "n1" + + +def test_spawn_omits_optional_empty_fields(): + client, captured = _mock_client(_ok_json({"ws_id": "x"})) + client.spawn(initial_message="hi", parent_ws_id="coord", user_id="u") + body = json.loads(captured[0].content) + # Optional fields should NOT be present when empty (keeps body lean + # and avoids confusing the route proxy's schema). + assert "skill" not in body + assert "name" not in body + assert "model" not in body + assert "target_node" not in body + + +def test_send_posts_to_send_route(): + client, captured = _mock_client(_ok_json({"status": 200})) + client.send("ws-x", "hello") + assert captured[0].url.path == "/v1/api/route/send" + body = json.loads(captured[0].content) + assert body == {"ws_id": "ws-x", "message": "hello"} + + +def test_close_workstream_posts_to_close_route(): + client, captured = _mock_client(_ok_json({"status": 200})) + client.close_workstream("ws-x") + assert captured[0].url.path == "/v1/api/route/workstreams/close" + body = json.loads(captured[0].content) + assert body == {"ws_id": "ws-x"} # no reason → omitted + + +def test_close_workstream_includes_reason_when_provided(): + client, captured = _mock_client(_ok_json({"status": 200})) + client.close_workstream("ws-x", reason="done") + body = json.loads(captured[0].content) + assert body == {"ws_id": "ws-x", "reason": "done"} + + +def test_delete_workstream_posts_to_delete_route(): + client, captured = _mock_client(_ok_json({"status": 200})) + client.delete("ws-x") + assert captured[0].url.path == "/v1/api/route/workstreams/delete" + + +def test_approve_and_cancel_hit_their_routes(): + client, captured = _mock_client(_ok_json({"status": 200})) + client.approve("ws-x", call_id="c-1", approved=True, feedback="ok", always=True) + client.cancel("ws-x") + assert captured[0].url.path == "/v1/api/route/approve" + assert captured[1].url.path == "/v1/api/route/cancel" + approve_body = json.loads(captured[0].content) + assert approve_body["approved"] is True + assert approve_body["always"] is True + + +def test_http_error_returns_structured_failure(): + def _boom(req: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("no route to host", request=req) + + client, _captured = _mock_client(_boom) + result = client.send("ws-x", "hi") + assert "error" in result + assert result["status"] == 0 + + +def test_non_2xx_response_populates_error(): + def _h(req: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"detail": "upstream down"}) + + client, _c = _mock_client(_h) + result = client.send("ws-x", "hi") + assert result["status"] == 500 + assert "error" in result + + +# --------------------------------------------------------------------------- +# Read ops — storage-backed +# --------------------------------------------------------------------------- + + +@pytest.fixture +def populated_storage(tmp_path): + st = SQLiteBackend(str(tmp_path / "coord.db")) + # Coord + 2 interactive children + 1 child coordinator (excluded) + + # 1 unrelated ws. + st.register_workstream("coord-1", kind="coordinator", user_id="user-1") + st.register_workstream( + "child-a", + kind="interactive", + parent_ws_id="coord-1", + state="idle", + skill_id="skill-x", + ) + st.register_workstream( + "child-b", + kind="interactive", + parent_ws_id="coord-1", + state="running", + skill_id="skill-y", + ) + st.register_workstream( + "child-coord", + kind="coordinator", + parent_ws_id="coord-1", + ) + st.register_workstream("unrelated", kind="interactive") + return st + + +def _make_read_client(storage: SQLiteBackend) -> CoordinatorClient: + transport = httpx.MockTransport(lambda r: httpx.Response(200)) + http = httpx.Client(transport=transport) + return CoordinatorClient( + console_base_url="http://x", + storage=storage, + token_factory=lambda: "t", + coord_ws_id="coord-1", + user_id="user-1", + http_client=http, + ) + + +def test_list_children_returns_only_interactive_children(populated_storage): + client = _make_read_client(populated_storage) + result = client.list_children("coord-1") + assert set(result.keys()) == {"children", "truncated"} + rows = result["children"] + names = {r["ws_id"] for r in rows} + assert names == {"child-a", "child-b"} # excludes child-coord + unrelated + for r in rows: + assert r["kind"] == "interactive" + assert r["parent_ws_id"] == "coord-1" + # Well under limit and no filters → not truncated. + assert result["truncated"] is False + + +def test_list_children_filters_by_state(populated_storage): + client = _make_read_client(populated_storage) + result = client.list_children("coord-1", state="running") + assert {r["ws_id"] for r in result["children"]} == {"child-b"} + + +def test_list_children_filters_by_skill_id(populated_storage): + client = _make_read_client(populated_storage) + result = client.list_children("coord-1", skill="skill-x") + rows = result["children"] + assert {r["ws_id"] for r in rows} == {"child-a"} + assert rows[0].get("skill_id") == "skill-x" + + +def test_list_children_skill_filter_avoids_n_plus_one(populated_storage, monkeypatch): + """skill filter must read skill_id/skill_version from the list_workstreams + projection — no per-row get_workstream round-trip (Copilot review #7).""" + client = _make_read_client(populated_storage) + call_count = {"n": 0} + real_get = populated_storage.get_workstream + + def _counting_get(ws_id: str): + call_count["n"] += 1 + return real_get(ws_id) + + monkeypatch.setattr(populated_storage, "get_workstream", _counting_get) + result = client.list_children("coord-1", skill="skill-x") + assert {r["ws_id"] for r in result["children"]} == {"child-a"} + assert result["children"][0]["skill_id"] == "skill-x" + assert call_count["n"] == 0 + + +def test_list_children_signals_truncation_when_page_full_and_filter_drops( + populated_storage, +): + """limit=1 with a state filter that drops the fetched row should + flag truncated=True so the model knows more may exist.""" + client = _make_read_client(populated_storage) + # populated_storage has child-a (idle) and child-b (running) under + # coord-1. limit=1 + state=running may return child-a first then + # drop it -> truncated=True. Either order, the row-budget is + # exhausted before all matches are considered. + result = client.list_children("coord-1", state="running", limit=1) + # If the fetched row happens to match, truncated is False; otherwise + # True. Either way, the dict shape is stable. + assert "truncated" in result + assert isinstance(result["truncated"], bool) + + +def test_inspect_missing_ws_returns_error(populated_storage): + client = _make_read_client(populated_storage) + result = client.inspect("does-not-exist") + assert "error" in result + + +def test_inspect_returns_persisted_fields(populated_storage): + client = _make_read_client(populated_storage) + result = client.inspect("child-a") + # Core persisted fields + for key in ("ws_id", "state", "kind", "parent_ws_id", "user_id", "created", "updated"): + assert key in result + assert result["parent_ws_id"] == "coord-1" + assert isinstance(result["messages"], list) + assert isinstance(result["verdicts"], list) + + +def test_inspect_refuses_workstreams_outside_coordinator_subtree(populated_storage): + """Prompt-injection guard — coordinator must not be able to inspect + arbitrary ws_ids (e.g. another tenant's workstream).""" + client = _make_read_client(populated_storage) + # 'unrelated' has no parent_ws_id and is not coord-1 itself. + result = client.inspect("unrelated") + assert "error" in result + assert "messages" not in result + + +def test_list_children_refuses_arbitrary_parent_ws_id(populated_storage): + """Prompt-injection guard — coordinator must not be able to enumerate + children of some other coordinator.""" + # Add a sibling coordinator with its own children. + populated_storage.register_workstream( + "coord-other", + kind="coordinator", + user_id="user-2", + ) + populated_storage.register_workstream( + "child-other", + kind="interactive", + parent_ws_id="coord-other", + ) + client = _make_read_client(populated_storage) + result = client.list_children("coord-other") + assert result == {"children": [], "truncated": False} + + +def test_list_children_truncated_signals_db_page_full(populated_storage): + """truncated=True whenever the SQL fetch hit the limit, regardless + of post-filtering.""" + client = _make_read_client(populated_storage) + # populated_storage has child-a + child-b under coord-1; limit=1 + # always fills the page so truncated must fire. + result = client.list_children("coord-1", limit=1) + assert result["truncated"] is True diff --git a/tests/test_coordinator_end_to_end.py b/tests/test_coordinator_end_to_end.py new file mode 100644 index 00000000..59a0e193 --- /dev/null +++ b/tests/test_coordinator_end_to_end.py @@ -0,0 +1,421 @@ +"""End-to-end integration tests for the coordinator workstream feature. + +Tests cover the full create → inspect → list → close lifecycle using +real in-process components: + +1. Create + list + detail round-trip via the Starlette TestClient. +2. CoordinatorClient against a MockTransport "server node" stub. +3. list_children storage read flow (kind filtering, parent scoping). +4. Lazy rehydration via GET /v1/api/coordinator/{ws_id}. + +Intentionally no real LLM infrastructure — session factories return +MagicMock-backed stubs. All four tests run in < 2 s total. +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock + +import httpx +import pytest +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.routing import Route +from starlette.testclient import TestClient + +from turnstone.console.coordinator import CoordinatorManager +from turnstone.console.coordinator_client import CoordinatorClient +from turnstone.console.coordinator_ui import ConsoleCoordinatorUI +from turnstone.console.server import ( + coordinator_close, + coordinator_create, + coordinator_detail, + coordinator_list, +) +from turnstone.core.auth import AuthResult +from turnstone.core.storage._sqlite import SQLiteBackend + +# --------------------------------------------------------------------------- +# Shared auth-injection middleware (mirrors test_coordinator_endpoints.py) +# --------------------------------------------------------------------------- + + +class _AuthMiddleware(BaseHTTPMiddleware): + """Inject an ``AuthResult`` from ``X-Test-Perms`` / ``X-Test-User``.""" + + async def dispatch(self, request, call_next): + perms = request.headers.get("X-Test-Perms", "") + user_id = request.headers.get("X-Test-User", "") + if perms or user_id: + request.state.auth_result = AuthResult( + user_id=user_id, + scopes=frozenset({"approve"}), + token_source="test", + permissions=frozenset(p for p in perms.split(",") if p), + ) + return await call_next(request) + + +# --------------------------------------------------------------------------- +# Shared stubs +# --------------------------------------------------------------------------- + + +class _FakeConfigStore: + """Minimal ConfigStore stub returning values from a dict.""" + + def __init__(self, values: dict[str, Any]) -> None: + self._values = values + + def get(self, key: str, default: Any = None) -> Any: + return self._values.get(key, default) + + +def _fake_registry() -> MagicMock: + """Registry stub that always succeeds on .resolve() so the 503 gate passes.""" + reg = MagicMock() + reg.resolve.return_value = (MagicMock(), "gpt-test", MagicMock()) + return reg + + +def _build_mgr(storage: SQLiteBackend) -> CoordinatorManager: + """Build a CoordinatorManager backed by stub factories.""" + + def _sf(ui, model_alias=None, ws_id=None, **kw): + s = MagicMock() + s.ws_id = ws_id + s.send.return_value = None + return s + + return CoordinatorManager( + session_factory=_sf, + ui_factory=lambda w, u: ConsoleCoordinatorUI(ws_id=w, user_id=u), + storage=storage, + max_active=5, + ) + + +def _make_client( + storage: SQLiteBackend, + *, + coord_mgr: CoordinatorManager | None = None, + alias: str = "my-model", + registry: Any = None, +) -> TestClient: + """Build a Starlette TestClient exposing the coordinator routes.""" + app = Starlette( + routes=[ + Route( + "/v1/api/coordinator/new", + coordinator_create, + methods=["POST"], + ), + Route("/v1/api/coordinator", coordinator_list, methods=["GET"]), + Route( + "/v1/api/coordinator/{ws_id}/close", + coordinator_close, + methods=["POST"], + ), + Route( + "/v1/api/coordinator/{ws_id}", + coordinator_detail, + methods=["GET"], + ), + ], + middleware=[Middleware(_AuthMiddleware)], + ) + app.state.coord_mgr = coord_mgr + app.state.config_store = _FakeConfigStore({"coordinator.model_alias": alias}) + app.state.coord_registry = registry + app.state.coord_registry_error = "" if coord_mgr else "registry missing" + app.state.auth_storage = storage + app.state.jwt_secret = "x" * 64 + return TestClient(app) + + +# --------------------------------------------------------------------------- +# Test 1 — Create + list + detail round-trip +# --------------------------------------------------------------------------- + +_COORD_HEADERS = {"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"} + + +def test_create_list_detail_lifecycle(tmp_path): + """POST /new → appears in GET / → GET /{ws_id} returns correct detail.""" + storage = SQLiteBackend(str(tmp_path / "coord.db")) + mgr = _build_mgr(storage) + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + + # --- Create --- + resp = client.post( + "/v1/api/coordinator/new", + json={"name": "e2e-coord"}, + headers=_COORD_HEADERS, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + ws_id = body["ws_id"] + assert ws_id + assert "e2e-coord" in body["name"] + + # --- List: caller sees their own coordinator --- + resp = client.get("/v1/api/coordinator", headers=_COORD_HEADERS) + assert resp.status_code == 200, resp.text + coordinators = resp.json()["coordinators"] + ids = {c["ws_id"] for c in coordinators} + assert ws_id in ids + + # Coordinator created by a different user is invisible to our caller. + mgr.create(user_id="other-user", name="not-mine") + resp = client.get("/v1/api/coordinator", headers=_COORD_HEADERS) + assert resp.status_code == 200 + names = {c["name"] for c in resp.json()["coordinators"]} + assert "not-mine" not in names + + # --- Detail --- + resp = client.get(f"/v1/api/coordinator/{ws_id}", headers=_COORD_HEADERS) + assert resp.status_code == 200, resp.text + detail = resp.json() + assert detail["ws_id"] == ws_id + assert detail["kind"] == "coordinator" + assert detail["user_id"] == "user-1" + + # --- Close --- + resp = client.post(f"/v1/api/coordinator/{ws_id}/close", headers=_COORD_HEADERS) + assert resp.status_code == 200 + + # Manager no longer tracks it after close. + assert mgr.get(ws_id) is None + + # Storage row reflects closed state. + row = storage.get_workstream(ws_id) + assert row is not None + assert row["state"] == "closed" + + # Detail endpoint returns 404 after close (not in memory, not rehydratable + # from a "closed" row — well, the manager would rehydrate it but let's verify + # the row is gone from the in-memory index). + assert mgr.get(ws_id) is None + + +# --------------------------------------------------------------------------- +# Test 2 — CoordinatorClient against a MockTransport "server node" stub +# --------------------------------------------------------------------------- + + +def test_coordinator_client_spawn_close_delete(tmp_path): + """CoordinatorClient.spawn / close_workstream / delete produce correct + upstream HTTP requests to the mocked server node.""" + storage = SQLiteBackend(str(tmp_path / "client.db")) + captured: list[httpx.Request] = [] + + def _handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + path = req.url.path + if path == "/v1/api/route/workstreams/new": + return httpx.Response( + 201, + json={"ws_id": "child-99", "name": "spawned", "node_id": "node-a"}, + ) + # close and delete both return a generic ok + return httpx.Response(200, json={"status": "ok"}) + + transport = httpx.MockTransport(_handler) + http = httpx.Client(transport=transport) + coord_client = CoordinatorClient( + console_base_url="http://console", + storage=storage, + token_factory=lambda: "bearer-test-token", + coord_ws_id="coord-42", + user_id="user-1", + http_client=http, + ) + + # spawn --------------------------------------------------------------- + result = coord_client.spawn( + initial_message="analyse data", + parent_ws_id="coord-42", + user_id="user-1", + skill="data-skill", + target_node="node-a", + ) + assert result["ws_id"] == "child-99" + + spawn_req = captured[0] + assert spawn_req.method == "POST" + assert spawn_req.url.path == "/v1/api/route/workstreams/new" + assert spawn_req.headers["Authorization"] == "Bearer bearer-test-token" + + spawn_body = json.loads(spawn_req.content) + assert spawn_body["kind"] == "interactive" + assert spawn_body["parent_ws_id"] == "coord-42" + assert spawn_body["user_id"] == "user-1" + assert spawn_body["initial_message"] == "analyse data" + assert spawn_body["skill"] == "data-skill" + assert spawn_body["target_node"] == "node-a" + + # close_workstream ---------------------------------------------------- + captured.clear() + close_result = coord_client.close_workstream("child-99") + assert close_result.get("status") in (200, "ok"), close_result + + close_req = captured[0] + assert close_req.url.path == "/v1/api/route/workstreams/close" + close_body = json.loads(close_req.content) + assert close_body["ws_id"] == "child-99" + + # delete -------------------------------------------------------------- + captured.clear() + del_result = coord_client.delete("child-99") + assert del_result.get("status") in (200, "ok"), del_result + + del_req = captured[0] + assert del_req.url.path == "/v1/api/route/workstreams/delete" + del_body = json.loads(del_req.content) + assert del_body["ws_id"] == "child-99" + + +# --------------------------------------------------------------------------- +# Test 3 — list_children storage read: kind filtering + parent scoping +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def seeded_storage(tmp_path): + """SQLiteBackend with a coordinator + 2 interactive children + extras.""" + st = SQLiteBackend(str(tmp_path / "seed.db")) + # Parent coordinator. + st.register_workstream("coord-root", kind="coordinator", user_id="user-1") + # Two interactive children — one idle, one running. + st.register_workstream( + "child-idle", + kind="interactive", + parent_ws_id="coord-root", + state="idle", + skill_id="skill-alpha", + ) + st.register_workstream( + "child-running", + kind="interactive", + parent_ws_id="coord-root", + state="running", + skill_id="skill-beta", + ) + # Coordinator child — MUST be excluded from list_children results. + st.register_workstream( + "child-coord", + kind="coordinator", + parent_ws_id="coord-root", + ) + # Unrelated workstream with no parent — MUST be excluded. + st.register_workstream("unrelated-ws", kind="interactive") + return st + + +def _read_client(storage: SQLiteBackend) -> CoordinatorClient: + """Build a CoordinatorClient whose HTTP transport is a no-op stub.""" + transport = httpx.MockTransport(lambda r: httpx.Response(200)) + http = httpx.Client(transport=transport) + return CoordinatorClient( + console_base_url="http://x", + storage=storage, + token_factory=lambda: "t", + coord_ws_id="coord-root", + user_id="user-1", + http_client=http, + ) + + +def test_list_children_excludes_coordinator_and_unrelated_rows(seeded_storage): + """list_children returns only interactive children of the given parent.""" + client = _read_client(seeded_storage) + result = client.list_children("coord-root") + rows = result["children"] + + ws_ids = {r["ws_id"] for r in rows} + # The two interactive children are present. + assert ws_ids == {"child-idle", "child-running"} + # Every returned row must be interactive and linked to coord-root. + for r in rows: + assert r["kind"] == "interactive" + assert r["parent_ws_id"] == "coord-root" + + # Coordinator child and unrelated ws are absent. + assert "child-coord" not in ws_ids + assert "unrelated-ws" not in ws_ids + assert result["truncated"] is False + + +def test_list_children_state_filter(seeded_storage): + """list_children(state='running') filters to only running children.""" + client = _read_client(seeded_storage) + result = client.list_children("coord-root", state="running") + assert {r["ws_id"] for r in result["children"]} == {"child-running"} + + +def test_list_children_skill_filter(seeded_storage): + """list_children(skill='skill-alpha') returns the matching child only.""" + client = _read_client(seeded_storage) + result = client.list_children("coord-root", skill="skill-alpha") + rows = result["children"] + assert {r["ws_id"] for r in rows} == {"child-idle"} + assert rows[0].get("skill_id") == "skill-alpha" + + +# --------------------------------------------------------------------------- +# Test 4 — Lazy rehydration via GET /v1/api/coordinator/{ws_id} +# --------------------------------------------------------------------------- + + +def test_lazy_rehydration_on_detail_get(tmp_path): + """A persisted coordinator row rehydrates into the manager on GET /{ws_id}. + + Sequence: + 1. Pre-seed storage with a coordinator row (simulating a previous process). + 2. Build a CoordinatorManager that doesn't know about it yet. + 3. Hit GET /v1/api/coordinator/{ws_id} — expect 200. + 4. Manager now tracks the rehydrated session. + 5. The response body carries the correct kind / user_id metadata. + """ + storage = SQLiteBackend(str(tmp_path / "rehydrate.db")) + + # Seed the row directly — the manager has never seen it. + storage.register_workstream( + "persisted-coord", + node_id="console", + user_id="user-1", + name="old-coord", + kind="coordinator", + ) + + mgr = _build_mgr(storage) + # Confirm: not tracked in memory yet. + assert mgr.get("persisted-coord") is None + + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.get("/v1/api/coordinator/persisted-coord", headers=_COORD_HEADERS) + assert resp.status_code == 200, resp.text + + body = resp.json() + assert body["ws_id"] == "persisted-coord" + assert body["kind"] == "coordinator" + assert body["user_id"] == "user-1" + + # The endpoint triggers lazy rehydration — manager now tracks it. + assert mgr.get("persisted-coord") is not None + + # Non-owner cannot reach the same endpoint (returns 404 — no existence leak). + resp_stranger = client.get( + "/v1/api/coordinator/persisted-coord", + headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"}, + ) + assert resp_stranger.status_code == 404 + + # A workstream with kind='interactive' is not reachable via the coordinator + # endpoint even when it exists in storage. + storage.register_workstream("interactive-ws", kind="interactive", user_id="user-1") + resp_int = client.get("/v1/api/coordinator/interactive-ws", headers=_COORD_HEADERS) + assert resp_int.status_code == 404 diff --git a/tests/test_coordinator_endpoints.py b/tests/test_coordinator_endpoints.py new file mode 100644 index 00000000..c73ae1db --- /dev/null +++ b/tests/test_coordinator_endpoints.py @@ -0,0 +1,436 @@ +"""Tests for the coordinator HTTP endpoints. + +Builds a minimal Starlette app wiring only the coordinator routes and +an auth-injector middleware. Verifies the permission gate, 503 +remediation when coord_mgr / model alias is missing, ownership +enforcement, and lazy rehydration on GET /{ws_id}. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.routing import Route +from starlette.testclient import TestClient + +from turnstone.console.coordinator import CoordinatorManager +from turnstone.console.coordinator_ui import ConsoleCoordinatorUI +from turnstone.console.server import ( + coordinator_approve, + coordinator_cancel, + coordinator_close, + coordinator_create, + coordinator_detail, + coordinator_history, + coordinator_list, + coordinator_send, +) +from turnstone.core.auth import AuthResult +from turnstone.core.storage._sqlite import SQLiteBackend + +# --------------------------------------------------------------------------- +# Auth injection middleware +# --------------------------------------------------------------------------- + + +class _AuthMiddleware(BaseHTTPMiddleware): + """Inject a configurable AuthResult from a header-based contract. + + Tests set ``X-Test-Perms`` to a comma-separated permission list, and + ``X-Test-User`` to the user id. Empty or missing → no auth. + """ + + async def dispatch(self, request, call_next): + perms = request.headers.get("X-Test-Perms", "") + user_id = request.headers.get("X-Test-User", "") + if perms or user_id: + request.state.auth_result = AuthResult( + user_id=user_id, + scopes=frozenset({"approve"}), + token_source="test", + permissions=frozenset(p for p in perms.split(",") if p), + ) + return await call_next(request) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +class _FakeConfigStore: + """Minimal ConfigStore stub — returns values from a dict.""" + + def __init__(self, values: dict[str, Any]) -> None: + self._values = values + + def get(self, key: str, default: Any = None) -> Any: + return self._values.get(key, default) + + +@pytest.fixture +def storage(tmp_path): + return SQLiteBackend(str(tmp_path / "coord.db")) + + +def _build_mgr(storage) -> CoordinatorManager: + """Build a CoordinatorManager with stub factories.""" + + def _sf(ui, model_alias=None, ws_id=None, **kw): + s = MagicMock() + s.send.return_value = None + return s + + return CoordinatorManager( + session_factory=_sf, + ui_factory=lambda w, u: ConsoleCoordinatorUI(ws_id=w, user_id=u), + storage=storage, + max_active=3, + ) + + +def _make_client( + storage, + *, + coord_mgr=None, + alias="my-model", + registry=None, +) -> TestClient: + """Build a TestClient exposing just the coordinator routes.""" + app = Starlette( + routes=[ + Route( + "/v1/api/coordinator/new", + coordinator_create, + methods=["POST"], + ), + Route("/v1/api/coordinator", coordinator_list, methods=["GET"]), + Route( + "/v1/api/coordinator/{ws_id}/send", + coordinator_send, + methods=["POST"], + ), + Route( + "/v1/api/coordinator/{ws_id}/approve", + coordinator_approve, + methods=["POST"], + ), + Route( + "/v1/api/coordinator/{ws_id}/cancel", + coordinator_cancel, + methods=["POST"], + ), + Route( + "/v1/api/coordinator/{ws_id}/close", + coordinator_close, + methods=["POST"], + ), + Route( + "/v1/api/coordinator/{ws_id}/history", + coordinator_history, + methods=["GET"], + ), + Route( + "/v1/api/coordinator/{ws_id}", + coordinator_detail, + methods=["GET"], + ), + ], + middleware=[Middleware(_AuthMiddleware)], + ) + app.state.coord_mgr = coord_mgr + app.state.config_store = _FakeConfigStore({"coordinator.model_alias": alias}) + app.state.coord_registry = registry + app.state.coord_registry_error = "" if coord_mgr else "registry missing" + app.state.auth_storage = storage + app.state.jwt_secret = "x" * 64 + return TestClient(app) + + +# --------------------------------------------------------------------------- +# Permission gate +# --------------------------------------------------------------------------- + + +def test_missing_permission_returns_403(storage): + mgr = _build_mgr(storage) + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.post( + "/v1/api/coordinator/new", + json={"name": "c1"}, + headers={"X-Test-User": "user-1", "X-Test-Perms": "read"}, + ) + assert resp.status_code == 403 + + +def test_no_auth_returns_401(storage): + """No AuthResult in request.state → 401 (require_permission semantics).""" + mgr = _build_mgr(storage) + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.post("/v1/api/coordinator/new", json={"name": "c1"}) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# 503 remediation when coordinator subsystem isn't configured +# --------------------------------------------------------------------------- + + +def test_missing_coord_mgr_returns_503(storage): + client = _make_client(storage, coord_mgr=None) + resp = client.post( + "/v1/api/coordinator/new", + json={"name": "c1"}, + headers={"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"}, + ) + assert resp.status_code == 503 + body = resp.json() + assert "not initialized" in body["error"] + + +def test_missing_model_alias_returns_503(storage): + mgr = _build_mgr(storage) + client = _make_client(storage, coord_mgr=mgr, alias="", registry=_fake_registry()) + resp = client.post( + "/v1/api/coordinator/new", + json={}, + headers={"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"}, + ) + assert resp.status_code == 503 + assert "model_alias" in resp.json()["error"] + + +def test_unresolvable_alias_returns_503(storage): + mgr = _build_mgr(storage) + broken_registry = MagicMock() + broken_registry.resolve.side_effect = KeyError("no-such-alias") + client = _make_client(storage, coord_mgr=mgr, alias="my-alias", registry=broken_registry) + resp = client.post( + "/v1/api/coordinator/new", + json={}, + headers={"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"}, + ) + assert resp.status_code == 503 + assert "does not resolve" in resp.json()["error"] + + +# --------------------------------------------------------------------------- +# Happy path — create + list + send + close +# --------------------------------------------------------------------------- + + +def _fake_registry() -> MagicMock: + """MagicMock that returns success on .resolve() so the 503 gate passes.""" + reg = MagicMock() + reg.resolve.return_value = (MagicMock(), "gpt-4", MagicMock()) + return reg + + +_COORD_HEADERS = {"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"} + + +def test_create_returns_ws_id_and_records_audit(storage): + mgr = _build_mgr(storage) + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.post( + "/v1/api/coordinator/new", + json={"name": "my-coord"}, + headers=_COORD_HEADERS, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["ws_id"] + assert "my-coord" in body["name"] + # Audit row recorded on storage. + from turnstone.core.audit import record_audit # noqa: F401 (verify import works) + + # Query audit_events via storage. + events = storage.list_audit_events(user_id="user-1", limit=10) + actions = [e["action"] for e in events] + assert "coordinator.create" in actions + + +def test_list_filters_by_caller(storage): + mgr = _build_mgr(storage) + mgr.create(user_id="user-1", name="mine") + mgr.create(user_id="user-2", name="theirs") + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.get("/v1/api/coordinator", headers=_COORD_HEADERS) + assert resp.status_code == 200 + body = resp.json() + names = {c["name"] for c in body["coordinators"]} + assert names == {"mine"} + + +def test_list_admin_sees_all(storage): + mgr = _build_mgr(storage) + mgr.create(user_id="user-1", name="mine") + mgr.create(user_id="user-2", name="theirs") + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.get( + "/v1/api/coordinator", + headers={ + "X-Test-User": "admin-1", + "X-Test-Perms": "admin.coordinator,admin.users", + }, + ) + assert resp.status_code == 200 + assert len(resp.json()["coordinators"]) == 2 + + +def test_send_to_someone_elses_coord_returns_404(storage): + mgr = _build_mgr(storage) + ws = mgr.create(user_id="owner", name="theirs") + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.post( + f"/v1/api/coordinator/{ws.id}/send", + json={"message": "hi"}, + headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"}, + ) + assert resp.status_code == 404 # not 403 — don't leak existence + + +def test_send_requires_message(storage): + mgr = _build_mgr(storage) + ws = mgr.create(user_id="user-1") + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.post( + f"/v1/api/coordinator/{ws.id}/send", + json={}, + headers=_COORD_HEADERS, + ) + assert resp.status_code == 400 + + +def test_close_records_audit_and_removes_from_mgr(storage): + mgr = _build_mgr(storage) + ws = mgr.create(user_id="user-1") + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.post(f"/v1/api/coordinator/{ws.id}/close", headers=_COORD_HEADERS) + assert resp.status_code == 200 + assert mgr.get(ws.id) is None + events = storage.list_audit_events(user_id="user-1", limit=10) + actions = [e["action"] for e in events] + assert "coordinator.close" in actions + + +def test_approve_resolves_ui_event(storage): + mgr = _build_mgr(storage) + ws = mgr.create(user_id="user-1") + assert isinstance(ws.ui, ConsoleCoordinatorUI) + ws.ui._pending_approval = { + "type": "approve_request", + "items": [ + { + "call_id": "c-1", + "func_name": "spawn_workstream", + "approval_label": "spawn_workstream", + "needs_approval": True, + } + ], + } + ws.ui._approval_event.clear() + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.post( + f"/v1/api/coordinator/{ws.id}/approve", + json={"approved": True, "always": True, "call_id": "c-1"}, + headers=_COORD_HEADERS, + ) + assert resp.status_code == 200 + assert ws.ui._approval_event.is_set() + assert ws.ui._approval_result == (True, None) + assert "spawn_workstream" in ws.ui.auto_approve_tools + + +# --------------------------------------------------------------------------- +# Lazy rehydration +# --------------------------------------------------------------------------- + + +def test_detail_triggers_lazy_rehydration(storage): + """GET /v1/api/coordinator/{ws_id} finds the row and rehydrates it.""" + mgr = _build_mgr(storage) + # Simulate a coordinator persisted by a previous console process. + storage.register_workstream( + "persisted-coord", + node_id="console", + user_id="user-1", + kind="coordinator", + ) + assert mgr.get("persisted-coord") is None + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.get("/v1/api/coordinator/persisted-coord", headers=_COORD_HEADERS) + assert resp.status_code == 200 + # Now tracked in the manager. + assert mgr.get("persisted-coord") is not None + + +def test_detail_404_when_not_owned(storage): + mgr = _build_mgr(storage) + storage.register_workstream("coord-x", kind="coordinator", user_id="owner") + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.get( + "/v1/api/coordinator/coord-x", + headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"}, + ) + assert resp.status_code == 404 + + +def test_detail_404_when_kind_interactive(storage): + """Non-coordinator rows aren't reachable via the coordinator endpoint.""" + mgr = _build_mgr(storage) + storage.register_workstream("ws-int", kind="interactive", user_id="user-1") + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.get("/v1/api/coordinator/ws-int", headers=_COORD_HEADERS) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# History +# --------------------------------------------------------------------------- + + +def test_history_returns_messages(storage): + mgr = _build_mgr(storage) + ws = mgr.create(user_id="user-1") + # Seed a message in storage. + storage.save_message(ws.id, "user", "hello") + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.get(f"/v1/api/coordinator/{ws.id}/history", headers=_COORD_HEADERS) + assert resp.status_code == 200 + body = resp.json() + assert body["ws_id"] == ws.id + assert any(m.get("role") == "user" and m.get("content") == "hello" for m in body["messages"]) + + +def test_history_404_for_stranger(storage): + mgr = _build_mgr(storage) + ws = mgr.create(user_id="owner") + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.get( + f"/v1/api/coordinator/{ws.id}/history", + headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"}, + ) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Cancel +# --------------------------------------------------------------------------- + + +def test_cancel_resolves_pending_approval(storage): + mgr = _build_mgr(storage) + ws = mgr.create(user_id="user-1") + assert isinstance(ws.ui, ConsoleCoordinatorUI) + ws.ui._pending_approval = {"type": "approve_request", "items": []} + ws.ui._approval_event.clear() + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + resp = client.post(f"/v1/api/coordinator/{ws.id}/cancel", headers=_COORD_HEADERS) + assert resp.status_code == 200 + assert ws.ui._approval_event.is_set() diff --git a/tests/test_coordinator_manager.py b/tests/test_coordinator_manager.py new file mode 100644 index 00000000..0be92f74 --- /dev/null +++ b/tests/test_coordinator_manager.py @@ -0,0 +1,572 @@ +"""Tests for :class:`turnstone.console.coordinator.CoordinatorManager`. + +Covers the lifecycle semantics without standing up a full ModelRegistry +or ChatSession: a stub session factory returns a MagicMock-backed +session so tests stay fast. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from turnstone.console.coordinator import CoordinatorManager +from turnstone.console.coordinator_ui import ConsoleCoordinatorUI +from turnstone.core.storage._sqlite import SQLiteBackend +from turnstone.core.workstream import WorkstreamState + + +@pytest.fixture +def storage(tmp_path): + return SQLiteBackend(str(tmp_path / "coord.db")) + + +@pytest.fixture +def built_mgr(storage): + """Build a CoordinatorManager with a stub session factory. + + The factory records its calls and returns a MagicMock-backed + session so ``_spawn_worker`` can run without hitting real LLM + infrastructure. + """ + call_log: list[dict] = [] + + def _session_factory(ui, model_alias=None, ws_id=None, **kwargs): + call_log.append( + { + "ui": ui, + "model_alias": model_alias, + "ws_id": ws_id, + **kwargs, + } + ) + mock_session = MagicMock() + mock_session.ws_id = ws_id + # send() is the worker thread target; make it a fast no-op. + mock_session.send.return_value = None + return mock_session + + def _ui_factory(ws_id, user_id): + return ConsoleCoordinatorUI(ws_id=ws_id, user_id=user_id) + + mgr = CoordinatorManager( + session_factory=_session_factory, + ui_factory=_ui_factory, + storage=storage, + max_active=3, + ) + return mgr, call_log, storage + + +# --------------------------------------------------------------------------- +# create +# --------------------------------------------------------------------------- + + +def test_create_registers_row_with_coordinator_kind(built_mgr): + mgr, _calls, storage = built_mgr + ws = mgr.create(user_id="user-1", name="c1") + row = storage.get_workstream(ws.id) + assert row is not None + assert row["kind"] == "coordinator" + assert row["user_id"] == "user-1" + assert row["node_id"] == "console" + assert row["parent_ws_id"] is None + + +def test_create_passes_kind_to_factory(built_mgr): + mgr, calls, _s = built_mgr + mgr.create(user_id="user-1") + assert calls[-1]["kind"] == "coordinator" + assert calls[-1]["parent_ws_id"] is None + + +def test_create_dispatches_initial_message(built_mgr): + import time + + mgr, _calls, _s = built_mgr + ws = mgr.create(user_id="user-1", initial_message="hello") + # Give the worker a brief window to run send() on the mock. + for _ in range(20): + if ws.session.send.called: + break + time.sleep(0.01) + ws.session.send.assert_called_once_with("hello") + + +def test_create_no_initial_message_skips_worker(built_mgr): + mgr, _calls, _s = built_mgr + ws = mgr.create(user_id="user-1") + assert ws.session.send.call_count == 0 + + +# --------------------------------------------------------------------------- +# max_active + eviction +# --------------------------------------------------------------------------- + + +def test_max_active_enforced_evicts_idle(built_mgr): + mgr, _calls, _s = built_mgr + ws_a = mgr.create(user_id="u1") + ws_b = mgr.create(user_id="u2") + ws_c = mgr.create(user_id="u3") + # All three at capacity. The next create should evict the oldest + # IDLE — ws_a has the oldest last_active. + ws_d = mgr.create(user_id="u4") + # ws_a got evicted from the dict; b/c/d are still present. + assert mgr.get(ws_a.id) is None + for w in (ws_b, ws_c, ws_d): + assert mgr.get(w.id) is not None + + +def test_max_active_raises_when_all_non_idle(built_mgr): + mgr, _calls, _s = built_mgr + ws_a = mgr.create(user_id="u1") + ws_b = mgr.create(user_id="u2") + ws_c = mgr.create(user_id="u3") + # Force all into a non-idle state so no eviction candidate exists. + for w in (ws_a, ws_b, ws_c): + w.state = WorkstreamState.RUNNING + with pytest.raises(RuntimeError) as exc_info: + mgr.create(user_id="u4") + assert "slots are active" in str(exc_info.value) + + +def test_rollback_on_factory_failure(storage): + """If the session factory raises, the slot + persisted row are rolled back.""" + + def _factory_explodes(*args, **kwargs): + raise RuntimeError("session construction failed") + + mgr = CoordinatorManager( + session_factory=_factory_explodes, + ui_factory=lambda w, u: ConsoleCoordinatorUI(ws_id=w, user_id=u), + storage=storage, + max_active=3, + ) + with pytest.raises(RuntimeError): + mgr.create(user_id="u1") + # No leaked in-memory workstream. + assert mgr.list_all() == [] + + +# --------------------------------------------------------------------------- +# send / cancel / close +# --------------------------------------------------------------------------- + + +def test_send_returns_false_when_not_loaded(built_mgr): + mgr, _calls, _s = built_mgr + assert mgr.send("nonexistent", "hello") is False + + +def test_send_returns_false_on_queue_full_without_spawning_duplicate(storage): + """If queue_message raises queue.Full, _spawn_worker must NOT fall + through and start a second concurrent worker on the same ChatSession + — that would corrupt history / cursors / approvals. Instead, send() + returns False so the endpoint can surface 429.""" + import queue + import threading + + entered = threading.Event() + block = threading.Event() + + def _slow_send(msg): + entered.set() + block.wait(timeout=5.0) + + def _session_factory(ui, model_alias=None, ws_id=None, **kwargs): + sess = MagicMock() + sess.send.side_effect = _slow_send + sess.queue_message.side_effect = queue.Full() + return sess + + mgr = CoordinatorManager( + session_factory=_session_factory, + ui_factory=lambda w, u: ConsoleCoordinatorUI(ws_id=w, user_id=u), + storage=storage, + max_active=3, + ) + ws = mgr.create(user_id="u1", initial_message="first") + try: + assert entered.wait(timeout=2.0), "worker didn't start" + original_thread = ws.worker_thread + assert mgr.send(ws.id, "second") is False + # Must NOT have replaced worker_thread with a fresh second worker. + assert ws.worker_thread is original_thread + finally: + block.set() + if ws.worker_thread: + ws.worker_thread.join(timeout=2.0) + + +def test_send_enqueues_on_live_worker(storage): + """When a worker thread is already processing, send() routes through + queue_message instead of spawning a duplicate worker.""" + import threading + import time + + entered = threading.Event() + block = threading.Event() + + def _slow_send(msg): + entered.set() + block.wait(timeout=5.0) + + def _session_factory(ui, model_alias=None, ws_id=None, **kwargs): + sess = MagicMock() + sess.send.side_effect = _slow_send + return sess + + mgr = CoordinatorManager( + session_factory=_session_factory, + ui_factory=lambda w, u: ConsoleCoordinatorUI(ws_id=w, user_id=u), + storage=storage, + max_active=3, + ) + ws = mgr.create(user_id="u1", initial_message="first") + try: + # Wait until the worker is actually inside session.send. + assert entered.wait(timeout=2.0), "worker didn't start" + # Now the worker is alive — mgr.send should route through queue_message. + for _ in range(20): + if ws.worker_thread and ws.worker_thread.is_alive(): + break + time.sleep(0.01) + sent = mgr.send(ws.id, "second") + assert sent + ws.session.queue_message.assert_called_with("second") + finally: + block.set() + if ws.worker_thread: + ws.worker_thread.join(timeout=2.0) + + +def test_cancel_resolves_pending_approval(built_mgr): + mgr, _calls, _s = built_mgr + ws = mgr.create(user_id="u1") + assert ws.ui is not None + assert isinstance(ws.ui, ConsoleCoordinatorUI) + # Put ui into a pending-approval state. + ws.ui._pending_approval = {"type": "approve_request", "items": []} + ws.ui._approval_event.clear() + assert mgr.cancel(ws.id) is True + # resolve_approval should have been called with approved=False. + assert ws.ui._approval_event.is_set() + assert ws.ui._approval_result == (False, "cancelled") + + +def test_cancel_unblocks_worker_blocked_on_approval(built_mgr): + """Cancel fires while a worker thread is blocked inside + ui.approve_tools() waiting on _approval_event. The worker must + unblock with approved=False and return.""" + import threading + import time + + mgr, _calls, _s = built_mgr + ws = mgr.create(user_id="u1") + ui = ws.ui + assert isinstance(ui, ConsoleCoordinatorUI) + + # Simulate the session worker entering approve_tools. We call it + # directly on its own thread so the test can observe the unblock. + result_holder: list[tuple[bool, str | None]] = [] + + def _worker() -> None: + outcome = ui.approve_tools( + [ + { + "call_id": "c1", + "func_name": "spawn_workstream", + "approval_label": "spawn_workstream", + "needs_approval": True, + } + ] + ) + result_holder.append(outcome) + + t = threading.Thread(target=_worker, daemon=True) + t.start() + # Give the worker time to enter the approval wait. + for _ in range(50): + if ui._pending_approval is not None: + break + time.sleep(0.01) + assert ui._pending_approval is not None, "worker didn't reach approve_tools" + + # Cancel fires — worker should unblock with approved=False. + assert mgr.cancel(ws.id) is True + t.join(timeout=2.0) + assert not t.is_alive() + assert result_holder == [(False, "cancelled")] + + +def test_close_removes_and_updates_state(built_mgr): + mgr, _calls, storage = built_mgr + ws = mgr.create(user_id="u1") + # Extract side-effectful call from the assert expression so + # python -O (which strips asserts) can't drop the close(). + closed = mgr.close(ws.id) + assert closed is True + assert mgr.get(ws.id) is None + row = storage.get_workstream(ws.id) + assert row["state"] == "closed" + + +# --------------------------------------------------------------------------- +# list_for_user + list_all +# --------------------------------------------------------------------------- + + +def test_list_for_user_filters_by_owner(built_mgr): + mgr, _calls, _s = built_mgr + a = mgr.create(user_id="user-1") + b = mgr.create(user_id="user-1") + mgr.create(user_id="user-2") # non-owner — existence matters, value doesn't + user1_rows = mgr.list_for_user("user-1") + ids = {r.id for r in user1_rows} + assert ids == {a.id, b.id} + + +def test_list_all_returns_every_loaded(built_mgr): + mgr, _calls, _s = built_mgr + mgr.create(user_id="u1") + mgr.create(user_id="u2") + assert len(mgr.list_all()) == 2 + + +# --------------------------------------------------------------------------- +# Lazy rehydration +# --------------------------------------------------------------------------- + + +def test_open_rehydrates_from_storage(built_mgr): + mgr, _calls, storage = built_mgr + # Simulate a coordinator persisted from a previous console process. + storage.register_workstream( + "coord-persisted", + node_id="console", + user_id="user-1", + kind="coordinator", + ) + # Initially not loaded in memory. + assert mgr.get("coord-persisted") is None + ws = mgr.open("coord-persisted", "user-1") + assert ws is not None + assert ws.kind == "coordinator" + assert ws.user_id == "user-1" + # Now tracked. + assert mgr.get("coord-persisted") is not None + + +def test_open_rejects_non_coordinator_kind(built_mgr): + mgr, _calls, storage = built_mgr + storage.register_workstream("interactive-ws", kind="interactive", user_id="user-1") + # open() has side effects (factory call, slot reservation); keep it + # out of the assert expression so python -O can't strip it. + opened = mgr.open("interactive-ws", "user-1") + assert opened is None + + +def test_open_enforces_ownership(built_mgr): + mgr, _calls, storage = built_mgr + storage.register_workstream("coord-x", kind="coordinator", user_id="owner") + # Non-owner gets None. + stranger_ws = mgr.open("coord-x", "stranger") + assert stranger_ws is None + # Owner gets the row. + owner_ws = mgr.open("coord-x", "owner") + assert owner_ws is not None + + +def test_open_admin_ignores_ownership(built_mgr): + mgr, _calls, storage = built_mgr + storage.register_workstream("coord-x", kind="coordinator", user_id="owner") + ws = mgr.open_admin("coord-x") + assert ws is not None + + +def test_open_refuses_closed_coordinator(built_mgr): + """A coordinator that was closed (state=closed in storage) must not + silently resurrect on the next GET. Otherwise the Close button is + reversible on URL revisit and burns max_active capacity.""" + mgr, _calls, storage = built_mgr + ws = mgr.create(user_id="u1") + mgr.close(ws.id) + # Direct GET via open() must NOT rehydrate the closed row. + reopened = mgr.open(ws.id, "u1") + assert reopened is None + # Admin path must also refuse to resurrect — closed means closed. + assert mgr.open_admin(ws.id) is None + + +def test_open_refuses_empty_owner_for_non_admin(built_mgr): + """Empty-owner rows (orphan / pre-002 migrated) must not be + rehydrated by non-admin callers — would consume a max_active slot + and let any user evict another tenant's IDLE coordinator.""" + mgr, _calls, storage = built_mgr + storage.register_workstream("coord-orphan", kind="coordinator", user_id=None) + # Non-admin caller — empty owner must NOT short-circuit the gate. + assert mgr.open("coord-orphan", "any-user") is None + # Admin path can still rehydrate (e.g. cleanup tooling). + assert mgr.open_admin("coord-orphan") is not None + + +def test_open_returns_existing_when_loaded(built_mgr): + mgr, _calls, _s = built_mgr + ws1 = mgr.create(user_id="u1") + ws2 = mgr.open(ws1.id, "u1") + assert ws2 is ws1 + + +# --------------------------------------------------------------------------- +# Concurrency regressions — blockers 1 & 2 from review +# --------------------------------------------------------------------------- + + +def test_concurrent_open_for_same_ws_id_constructs_one_session(storage): + """Two threads calling open() for the same persisted-but-unloaded + ws_id must not each spin up a session. Per-ws_id serialization + ensures the second thread picks up the first thread's session.""" + import threading + import time + + construct_count = {"n": 0} + construct_lock = threading.Lock() + first_in = threading.Event() + release_first = threading.Event() + + def _slow_factory(ui, model_alias=None, ws_id=None, **kwargs): + with construct_lock: + construct_count["n"] += 1 + my_idx = construct_count["n"] + if my_idx == 1: + first_in.set() + # Block so the second thread can race past the storage read. + release_first.wait(timeout=5.0) + sess = MagicMock() + sess.ws_id = ws_id + sess.send.return_value = None + return sess + + mgr = CoordinatorManager( + session_factory=_slow_factory, + ui_factory=lambda w, u: ConsoleCoordinatorUI(ws_id=w, user_id=u), + storage=storage, + max_active=5, + ) + storage.register_workstream( + "coord-shared", + node_id="console", + user_id="user-1", + kind="coordinator", + ) + + results: list[Any] = [None, None] + + def _open_one(idx: int) -> None: + results[idx] = mgr.open("coord-shared", "user-1") + + t1 = threading.Thread(target=_open_one, args=(0,)) + t2 = threading.Thread(target=_open_one, args=(1,)) + t1.start() + assert first_in.wait(timeout=2.0), "first thread didn't enter factory" + t2.start() + # Give t2 a chance to reach the per-ws lock and block. + time.sleep(0.1) + release_first.set() + t1.join(timeout=5.0) + t2.join(timeout=5.0) + + assert construct_count["n"] == 1, ( + f"expected exactly 1 session construction, got {construct_count['n']}" + ) + assert results[0] is not None + assert results[1] is not None + # Both threads must see the same installed Workstream instance. + assert results[0] is results[1] + # Manager tracks exactly one entry. + assert len(mgr.list_all()) == 1 + + +def test_concurrent_create_respects_max_active(storage): + """max_active + 2 concurrent creates → exactly max_active succeed + and the overflow raises RuntimeError. Regression for the + check-then-install gap that previously let all creates pass the gate.""" + import threading + + slow_entered = threading.Event() + release = threading.Event() + + def _slow_factory(ui, model_alias=None, ws_id=None, **kwargs): + # Block after construction to widen the race window between + # slot reservation and final install. Only the first N reach + # here — the rest must trip on the capacity gate earlier. + slow_entered.set() + release.wait(timeout=5.0) + sess = MagicMock() + sess.send.return_value = None + return sess + + max_active = 3 + mgr = CoordinatorManager( + session_factory=_slow_factory, + ui_factory=lambda w, u: ConsoleCoordinatorUI(ws_id=w, user_id=u), + storage=storage, + max_active=max_active, + ) + + successes: list[bool] = [] + failures: list[Exception] = [] + successes_lock = threading.Lock() + + def _create_one(user_suffix: int) -> None: + try: + mgr.create(user_id=f"u{user_suffix}") + with successes_lock: + successes.append(True) + except RuntimeError as exc: + with successes_lock: + failures.append(exc) + + threads = [threading.Thread(target=_create_one, args=(i,)) for i in range(max_active + 2)] + for t in threads: + t.start() + # Wait until at least one creation is blocked inside the factory. + assert slow_entered.wait(timeout=2.0) + release.set() + for t in threads: + t.join(timeout=5.0) + + assert len(successes) == max_active, f"expected {max_active} successes, got {len(successes)}" + assert len(failures) == 2 + for exc in failures: + assert "slots are active" in str(exc) + assert len(mgr.list_all()) == max_active + + +# --------------------------------------------------------------------------- +# Cross-tenant leak — blocker 3 from review +# --------------------------------------------------------------------------- + + +def test_list_for_user_excludes_empty_owner_rows(built_mgr): + """A coordinator whose user_id is empty (system-created, migration + artifact, or lazily rehydrated from a NULL owner) must NOT appear + in list_for_user() output for other callers — doing so would leak + ws_id + name + state across tenants.""" + mgr, _calls, storage = built_mgr + # Real user's coordinator. + owned = mgr.create(user_id="alice") + # Simulate a rogue empty-owner session by creating one with + # user_id="" directly. Matches what a rehydrate of a NULL-owner + # row would produce, or a system-created coordinator. + empty_owner = mgr.create(user_id="") + rows = mgr.list_for_user("alice") + ids = {ws.id for ws in rows} + assert owned.id in ids + assert empty_owner.id not in ids, ( + "list_for_user must not expose empty-owner coordinators to other callers" + ) diff --git a/tests/test_coordinator_page.py b/tests/test_coordinator_page.py new file mode 100644 index 00000000..1bb6c075 --- /dev/null +++ b/tests/test_coordinator_page.py @@ -0,0 +1,54 @@ +"""Tests for the /coordinator/{ws_id} HTML page handler. + +The handler serves the shared template with the ws_id injected as a +``data-ws-id`` attribute. It does NOT enforce auth on the page itself — +auth gating happens on the API endpoints the page calls (an unauthenticated +visitor lands on the page but all API calls fail). +""" + +from __future__ import annotations + +import pytest +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.testclient import TestClient + +from turnstone.console.server import coordinator_page + + +@pytest.fixture +def client(): + app = Starlette(routes=[Route("/coordinator/{ws_id}", coordinator_page, methods=["GET"])]) + return TestClient(app) + + +def test_valid_ws_id_injects_data_attr(client): + ws_id = "a" * 32 + resp = client.get(f"/coordinator/{ws_id}") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + body = resp.text + # ws_id is injected into the html data-ws-id attribute. + assert f'data-ws-id="{ws_id}"' in body + # Template placeholder is fully substituted. + assert "{{WS_ID}}" not in body + # Sanity: the shared static imports are wired. + assert "/shared/base.css" in body + assert "/static/coordinator/coordinator.js" in body + + +def test_non_hex_ws_id_returns_400(client): + """Only hex chars are allowed to avoid HTML injection.""" + resp = client.get("/coordinator/not-hex-chars-here") + assert resp.status_code == 400 + + +def test_ws_id_too_long_returns_400(client): + resp = client.get("/coordinator/" + "a" * 65) + assert resp.status_code == 400 + + +def test_uppercase_hex_rejected(client): + # Our ws_ids are lowercase hex; reject mixed/upper to avoid surprises. + resp = client.get("/coordinator/" + "A" * 32) + assert resp.status_code == 400 diff --git a/tests/test_coordinator_proxy_auth.py b/tests/test_coordinator_proxy_auth.py new file mode 100644 index 00000000..2a5f1974 --- /dev/null +++ b/tests/test_coordinator_proxy_auth.py @@ -0,0 +1,96 @@ +"""Tests for console _proxy_auth_headers preserving the coordinator src claim. + +Verifies C8 of the coordinator plan: when a console handler processes an +inbound request authenticated with a coordinator-minted JWT (``src == +"coordinator"``), the upstream JWT the console mints for the proxied +request preserves that source plus the ``coord_ws_id`` custom claim. +For non-coordinator inbound tokens the re-mint still uses +``"console-proxy"`` as before — the existing behaviour is unchanged. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import jwt as pyjwt + +from turnstone.console.server import _proxy_auth_headers +from turnstone.core.auth import JWT_AUD_SERVER, AuthResult + +_SECRET = "x" * 64 + + +def _build_request(auth_result: AuthResult | None): + """Minimal Request-alike for _proxy_auth_headers.""" + state = SimpleNamespace(auth_result=auth_result) + app_state = SimpleNamespace(jwt_secret=_SECRET, proxy_token_mgr=None) + app = MagicMock() + app.state = app_state + req = MagicMock() + req.state = state + req.app = app + return req + + +def _decode(headers: dict[str, str]) -> dict: + token = headers["Authorization"].removeprefix("Bearer ") + return pyjwt.decode(token, _SECRET, algorithms=["HS256"], audience=JWT_AUD_SERVER) + + +def test_console_proxy_uses_console_proxy_source_by_default(): + """Non-coordinator inbound tokens still mint src='console-proxy'.""" + auth = AuthResult( + user_id="user-1", + scopes=frozenset({"write"}), + token_source="jwt", + permissions=frozenset(), + ) + headers = _proxy_auth_headers(_build_request(auth)) + payload = _decode(headers) + assert payload["src"] == "console-proxy" + assert "coord_ws_id" not in payload + + +def test_coordinator_source_is_preserved_on_remint(): + """Inbound src='coordinator' → outbound src='coordinator'.""" + auth = AuthResult( + user_id="user-1", + scopes=frozenset({"approve"}), + token_source="coordinator", + permissions=frozenset({"admin.coordinator"}), + extra_claims={"coord_ws_id": "coord-42"}, + ) + headers = _proxy_auth_headers(_build_request(auth)) + payload = _decode(headers) + assert payload["src"] == "coordinator" + assert payload["coord_ws_id"] == "coord-42" + + +def test_coord_ws_id_absent_when_not_in_inbound_claims(): + """Defensive: if the inbound token is src=coordinator but missing the + coord_ws_id claim (shouldn't happen in practice), the re-mint skips + the custom claim rather than panicking.""" + auth = AuthResult( + user_id="user-1", + scopes=frozenset({"write"}), + token_source="coordinator", + permissions=frozenset(), + ) + headers = _proxy_auth_headers(_build_request(auth)) + payload = _decode(headers) + assert payload["src"] == "coordinator" + assert "coord_ws_id" not in payload + + +def test_empty_auth_falls_back_to_service_token_or_empty(): + """Without auth_result.user_id, falls through to ServiceTokenManager.""" + auth = AuthResult( + user_id="", + scopes=frozenset(), + token_source="config", + permissions=frozenset(), + ) + # No proxy_token_mgr configured → empty headers. + headers = _proxy_auth_headers(_build_request(auth)) + assert headers == {} diff --git a/tests/test_coordinator_tools.py b/tests/test_coordinator_tools.py new file mode 100644 index 00000000..38e8fce4 --- /dev/null +++ b/tests/test_coordinator_tools.py @@ -0,0 +1,413 @@ +"""Tests for the coordinator prepare/exec dispatch on ChatSession. + +We construct a ChatSession with ``kind="coordinator"`` and a mocked +``CoordinatorClient``, then drive ``_prepare_tool`` directly with tool +call dicts matching the shape the provider layer produces. This is a +unit-level test of the dispatch plumbing — end-to-end flows land in +Phase D's test_coordinator_end_to_end. +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from turnstone.core.session import ChatSession +from turnstone.prompts import ClientType + + +class _StubUI: + """Minimal SessionUI that records signals without doing anything with them.""" + + def __init__(self) -> None: + self._user_id = "user-1" + self.infos: list[str] = [] + self.errors: list[str] = [] + self.tool_results: list[tuple[str, str, str, bool]] = [] + + def on_info(self, msg: str) -> None: + self.infos.append(msg) + + def on_error(self, msg: str) -> None: + self.errors.append(msg) + + def on_tool_result(self, call_id: str, name: str, output: str, is_error: bool = False) -> None: + self.tool_results.append((call_id, name, output, is_error)) + + # Other SessionUI methods — only stubs, not exercised here. + def on_turn_start(self) -> None: + pass + + def on_turn_end(self) -> None: + pass + + def on_stream_start(self) -> None: + pass + + def on_stream_end(self) -> None: + pass + + def on_message_delta(self, delta: str) -> None: + pass + + def on_reasoning_delta(self, delta: str) -> None: + pass + + def on_tool_call(self, call_id: str, name: str, header: str, preview: str) -> None: + pass + + def on_completion(self, content: str) -> None: + pass + + def on_attention(self, header: str, preview: str = "") -> None: + pass + + def wait_for_approval( + self, + call_id: str, + name: str, + header: str, + preview: str, + *, + label: str = "", + ) -> tuple[bool, str | None]: + return True, None + + +@pytest.fixture +def coord_session(monkeypatch): + """Build a coordinator ChatSession with a mocked CoordinatorClient. + + Patches heavyweight init steps (_load_skills, _init_system_messages, + _save_config) to keep the test fast + isolated from the storage + registry. + """ + monkeypatch.setattr(ChatSession, "_load_skills", lambda self: None) + monkeypatch.setattr(ChatSession, "_init_system_messages", lambda self: None) + monkeypatch.setattr(ChatSession, "_save_config", lambda self: None) + + ui = _StubUI() + coord_client = MagicMock() + sess = ChatSession( + client=MagicMock(), + model="gpt-test", + ui=ui, # type: ignore[arg-type] + instructions=None, + temperature=0.0, + max_tokens=1024, + tool_timeout=30, + context_window=16384, + ws_id="coord-1", + user_id="user-1", + client_type=ClientType.WEB, + kind="coordinator", + coord_client=coord_client, + ) + return sess, coord_client, ui + + +# --------------------------------------------------------------------------- +# Tool set shape +# --------------------------------------------------------------------------- + + +def test_coordinator_session_uses_coordinator_tools(coord_session): + sess, _coord, _ui = coord_session + names = {t["function"]["name"] for t in sess._tools} + assert names == { + "spawn_workstream", + "inspect_workstream", + "send_to_workstream", + "close_workstream", + "delete_workstream", + "list_workstreams", + } + # Sub-agent tool sets are zeroed on coordinator sessions. + assert sess._task_tools == [] + assert sess._agent_tools == [] + + +# --------------------------------------------------------------------------- +# Helper: build a ChatCompletion-style tool_call dict +# --------------------------------------------------------------------------- + + +def _tc(name: str, args: dict[str, Any], call_id: str = "call-1") -> dict[str, Any]: + return { + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": json.dumps(args)}, + } + + +# --------------------------------------------------------------------------- +# spawn_workstream +# --------------------------------------------------------------------------- + + +def test_spawn_prepare_allows_empty_initial_message(coord_session): + """Empty initial_message creates an idle child — matches tool JSON advertisement.""" + sess, _coord, _ui = coord_session + item = sess._prepare_tool(_tc("spawn_workstream", {"initial_message": ""})) + assert "error" not in item + assert item["needs_approval"] is True + assert "idle workstream" in item["header"] + assert item["initial_message"] == "" + + +def test_spawn_prepare_needs_approval(coord_session): + sess, _coord, _ui = coord_session + item = sess._prepare_tool( + _tc("spawn_workstream", {"initial_message": "do a thing", "skill": "s"}) + ) + assert item["needs_approval"] is True + assert item["execute"].__func__ is ChatSession._exec_spawn_workstream + assert item["skill"] == "s" + + +def test_spawn_exec_calls_client_and_returns_summary(coord_session): + sess, coord, _ui = coord_session + coord.spawn.return_value = { + "ws_id": "child-7", + "name": "c", + "node_id": "node-1", + "status": 200, + } + item = sess._prepare_tool(_tc("spawn_workstream", {"initial_message": "hi"})) + call_id, output = sess._exec_spawn_workstream(item) + coord.spawn.assert_called_once() + _, kwargs = coord.spawn.call_args + assert kwargs["parent_ws_id"] == "coord-1" + assert kwargs["user_id"] == "user-1" + assert kwargs["initial_message"] == "hi" + assert call_id == "call-1" + assert "child-7" in output + + +def test_spawn_exec_surfaces_client_error(coord_session): + sess, coord, ui = coord_session + coord.spawn.return_value = {"error": "upstream unreachable", "status": 502} + item = sess._prepare_tool(_tc("spawn_workstream", {"initial_message": "hi"})) + _call_id, output = sess._exec_spawn_workstream(item) + assert "upstream unreachable" in output + # UI got an error result + assert ui.tool_results[-1][3] is True # is_error + + +# --------------------------------------------------------------------------- +# inspect_workstream +# --------------------------------------------------------------------------- + + +def test_inspect_prepare_is_auto_approved(coord_session): + sess, _coord, _ui = coord_session + item = sess._prepare_tool(_tc("inspect_workstream", {"ws_id": "child-x", "message_limit": 5})) + assert item["needs_approval"] is False + assert item["execute"].__func__ is ChatSession._exec_inspect_workstream + assert item["message_limit"] == 5 + + +def test_inspect_prepare_requires_ws_id(coord_session): + sess, _coord, _ui = coord_session + item = sess._prepare_tool(_tc("inspect_workstream", {})) + assert "error" in item + + +def test_inspect_prepare_clamps_message_limit(coord_session): + sess, _coord, _ui = coord_session + item = sess._prepare_tool(_tc("inspect_workstream", {"ws_id": "x", "message_limit": 10000})) + assert item["message_limit"] == 200 # clamped + + +def test_inspect_exec_dispatches_to_client(coord_session): + sess, coord, _ui = coord_session + coord.inspect.return_value = { + "ws_id": "child-x", + "state": "idle", + "messages": [], + "verdicts": [], + } + item = sess._prepare_tool(_tc("inspect_workstream", {"ws_id": "child-x"})) + _call_id, output = sess._exec_inspect_workstream(item) + coord.inspect.assert_called_once_with("child-x", message_limit=20) + assert "child-x" in output + + +# --------------------------------------------------------------------------- +# send_to_workstream +# --------------------------------------------------------------------------- + + +def test_send_prepare_needs_approval(coord_session): + sess, _coord, _ui = coord_session + item = sess._prepare_tool(_tc("send_to_workstream", {"ws_id": "x", "message": "hello"})) + assert item["needs_approval"] is True + + +def test_send_prepare_rejects_empty_message(coord_session): + sess, _coord, _ui = coord_session + item = sess._prepare_tool(_tc("send_to_workstream", {"ws_id": "x", "message": ""})) + assert "error" in item + + +def test_send_exec_dispatches(coord_session): + sess, coord, _ui = coord_session + coord.send.return_value = {"status": 200} + item = sess._prepare_tool(_tc("send_to_workstream", {"ws_id": "x", "message": "hi"})) + _call_id, output = sess._exec_send_to_workstream(item) + coord.send.assert_called_once_with("x", "hi") + assert "x" in output + + +# --------------------------------------------------------------------------- +# close_workstream +# --------------------------------------------------------------------------- + + +def test_close_prepare_needs_approval(coord_session): + sess, _coord, _ui = coord_session + item = sess._prepare_tool(_tc("close_workstream", {"ws_id": "x"})) + assert item["needs_approval"] is True + + +def test_close_exec_dispatches(coord_session): + sess, coord, _ui = coord_session + coord.close_workstream.return_value = {"status": 200} + item = sess._prepare_tool(_tc("close_workstream", {"ws_id": "x"})) + _call_id, output = sess._exec_close_workstream(item) + # Default (no reason) — kwargs carry empty reason through the call. + coord.close_workstream.assert_called_once_with("x", reason="") + parsed = json.loads(output) + assert parsed["closed"] is True + assert "reason" not in parsed # omitted when empty + + +def test_close_exec_forwards_reason(coord_session): + """reason is wired through both CoordinatorClient.close_workstream + and the tool-result payload so the coordinator's message stream + records why the close happened.""" + sess, coord, _ui = coord_session + coord.close_workstream.return_value = {"status": 200} + item = sess._prepare_tool(_tc("close_workstream", {"ws_id": "x", "reason": "task done"})) + _call_id, output = sess._exec_close_workstream(item) + coord.close_workstream.assert_called_once_with("x", reason="task done") + parsed = json.loads(output) + assert parsed["reason"] == "task done" + + +# --------------------------------------------------------------------------- +# delete_workstream +# --------------------------------------------------------------------------- + + +def test_delete_prepare_needs_approval(coord_session): + sess, _coord, _ui = coord_session + item = sess._prepare_tool(_tc("delete_workstream", {"ws_id": "x"})) + assert item["needs_approval"] is True + assert "irreversible" in item["header"].lower() + + +def test_delete_exec_dispatches(coord_session): + sess, coord, _ui = coord_session + coord.delete.return_value = {"status": 200} + item = sess._prepare_tool(_tc("delete_workstream", {"ws_id": "x"})) + _call_id, output = sess._exec_delete_workstream(item) + coord.delete.assert_called_once_with("x") + parsed = json.loads(output) + assert parsed["deleted"] is True + + +# --------------------------------------------------------------------------- +# list_workstreams +# --------------------------------------------------------------------------- + + +def test_list_prepare_is_auto_approved(coord_session): + sess, _coord, _ui = coord_session + item = sess._prepare_tool(_tc("list_workstreams", {})) + assert item["needs_approval"] is False + + +def test_list_prepare_defaults_parent_to_self_ws(coord_session): + sess, _coord, _ui = coord_session + item = sess._prepare_tool(_tc("list_workstreams", {})) + assert item["parent_ws_id"] == "coord-1" + + +def test_list_prepare_accepts_explicit_parent(coord_session): + sess, _coord, _ui = coord_session + item = sess._prepare_tool( + _tc("list_workstreams", {"parent_ws_id": "other-coord", "state": "idle"}) + ) + assert item["parent_ws_id"] == "other-coord" + assert item["state"] == "idle" + + +def test_list_exec_dispatches(coord_session): + sess, coord, _ui = coord_session + coord.list_children.return_value = { + "children": [ + {"ws_id": "a", "state": "idle"}, + {"ws_id": "b", "state": "running"}, + ], + "truncated": False, + } + item = sess._prepare_tool(_tc("list_workstreams", {})) + _call_id, output = sess._exec_list_workstreams(item) + coord.list_children.assert_called_once() + parsed = json.loads(output) + assert parsed["parent_ws_id"] == "coord-1" + assert len(parsed["children"]) == 2 + assert parsed["truncated"] is False + + +def test_list_exec_surfaces_truncated_sentinel(coord_session): + sess, coord, _ui = coord_session + coord.list_children.return_value = { + "children": [{"ws_id": "a", "state": "idle"}], + "truncated": True, + } + item = sess._prepare_tool(_tc("list_workstreams", {})) + _call_id, output = sess._exec_list_workstreams(item) + parsed = json.loads(output) + assert parsed["truncated"] is True + + +# --------------------------------------------------------------------------- +# Defensive guard: missing coord_client +# --------------------------------------------------------------------------- + + +def test_prepare_fails_cleanly_when_coord_client_missing(monkeypatch): + """If somehow a coordinator-kind session is built without a coord_client, + prepare methods return an error item rather than NPE.""" + monkeypatch.setattr(ChatSession, "_load_skills", lambda self: None) + monkeypatch.setattr(ChatSession, "_init_system_messages", lambda self: None) + monkeypatch.setattr(ChatSession, "_save_config", lambda self: None) + ui = _StubUI() + sess = ChatSession( + client=MagicMock(), + model="m", + ui=ui, # type: ignore[arg-type] + instructions=None, + temperature=0.0, + max_tokens=1024, + tool_timeout=30, + context_window=16384, + ws_id="coord-1", + kind="coordinator", + coord_client=None, + ) + for tool, args in ( + ("spawn_workstream", {"initial_message": "hi"}), + ("inspect_workstream", {"ws_id": "x"}), + ("send_to_workstream", {"ws_id": "x", "message": "m"}), + ("close_workstream", {"ws_id": "x"}), + ("delete_workstream", {"ws_id": "x"}), + ("list_workstreams", {}), + ): + item = sess._prepare_tool(_tc(tool, args)) + assert "error" in item, f"{tool} did not error on missing coord_client" diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py index fb275da8..7581e875 100644 --- a/tests/test_mcp_client.py +++ b/tests/test_mcp_client.py @@ -17,7 +17,7 @@ from turnstone.core.mcp_client import ( _mcp_to_openai, load_mcp_config, ) -from turnstone.core.tools import TOOLS, merge_mcp_tools +from turnstone.core.tools import INTERACTIVE_TOOLS, TOOLS, merge_mcp_tools # --------------------------------------------------------------------------- # Helpers @@ -349,14 +349,15 @@ class TestSessionIntegration: def test_session_without_mcp(self, tmp_db): session = self._make_session(mcp_client=None) - assert session._tools is TOOLS + # Interactive session surface — coordinator tools excluded. + assert session._tools is INTERACTIVE_TOOLS assert session._mcp_client is None def test_session_with_mcp(self, tmp_db): mock_mcp = MagicMock() mock_mcp.get_tools.return_value = [_fake_openai_tool()] session = self._make_session(mcp_client=mock_mcp) - assert len(session._tools) == len(TOOLS) + 1 + assert len(session._tools) == len(INTERACTIVE_TOOLS) + 1 assert session._tools[-1]["function"]["name"] == "mcp__test__search" def test_task_tools_include_mcp(self, tmp_db): diff --git a/tests/test_prompt_templates_runtime.py b/tests/test_prompt_templates_runtime.py index 49ee6fd4..33300786 100644 --- a/tests/test_prompt_templates_runtime.py +++ b/tests/test_prompt_templates_runtime.py @@ -449,7 +449,7 @@ class TestSkillFactoryPassthrough: captured_skill = None - def factory(ui, model_alias=None, ws_id=None, *, skill=None): + def factory(ui, model_alias=None, ws_id=None, *, skill=None, **_kwargs): nonlocal captured_skill captured_skill = skill return _make_session(skill=captured_skill) @@ -465,7 +465,7 @@ class TestSkillFactoryPassthrough: """WorkstreamManager.create() without skill passes None.""" captured_skill = "sentinel" - def factory(ui, model_alias=None, ws_id=None, *, skill=None): + def factory(ui, model_alias=None, ws_id=None, *, skill=None, **_kwargs): nonlocal captured_skill captured_skill = skill return _make_session(skill=skill) diff --git a/tests/test_tools_schema.py b/tests/test_tools_schema.py index 77d02143..0a11c556 100644 --- a/tests/test_tools_schema.py +++ b/tests/test_tools_schema.py @@ -72,7 +72,8 @@ class TestToolsMetadata: """Validate the metadata extracted from JSON files.""" def test_tool_count(self): - assert len(TOOLS) == 19 + # 19 original tools + 6 coordinator tools + assert len(TOOLS) == 25 def test_agent_tools_count(self): assert len(AGENT_TOOLS) == 10 @@ -80,6 +81,19 @@ class TestToolsMetadata: def test_task_agent_tools_count(self): assert len(TASK_AGENT_TOOLS) == 13 + def test_coordinator_tools_count(self): + from turnstone.core.tools import COORDINATOR_TOOLS + + assert len(COORDINATOR_TOOLS) == 6 + assert {t["function"]["name"] for t in COORDINATOR_TOOLS} == { + "spawn_workstream", + "inspect_workstream", + "send_to_workstream", + "close_workstream", + "delete_workstream", + "list_workstreams", + } + def test_auto_approve_sets_match(self): expected = { "read_file", @@ -90,6 +104,9 @@ class TestToolsMetadata: "web_fetch", "web_search", "notify", + # Coordinator read-only tools (no-mutation, safe to auto-approve): + "inspect_workstream", + "list_workstreams", } assert expected == AGENT_AUTO_TOOLS assert expected == TASK_AUTO_TOOLS @@ -115,12 +132,18 @@ class TestToolsMetadata: "use_prompt": "name", "skill": "name", "diff_file": "path_a", + # Coordinator tools: + "spawn_workstream": "initial_message", + "inspect_workstream": "ws_id", + "send_to_workstream": "message", + "close_workstream": "ws_id", + "delete_workstream": "ws_id", } assert expected == PRIMARY_KEY_MAP def test_no_metadata_in_function_dicts(self): """Ensure turnstone metadata keys are stripped from the OpenAI schema.""" - meta_keys = {"agent", "task_agent", "auto_approve", "primary_key"} + meta_keys = {"agent", "task_agent", "coordinator", "auto_approve", "primary_key"} for tool in TOOLS: func = tool["function"] leaked = meta_keys & set(func) diff --git a/tests/test_workstream.py b/tests/test_workstream.py index ee355c29..10229fc0 100644 --- a/tests/test_workstream.py +++ b/tests/test_workstream.py @@ -161,6 +161,21 @@ class TestManagerCreation: ws = mgr.create(ui_factory=FakeUI) assert ws.name.startswith("ws-") + def test_create_persists_user_id_on_dataclass(self): + """Regression: ``mgr.create(user_id=X)`` must surface X on the + ``Workstream`` dataclass. The server-side handler used to omit + ``user_id=uid`` in the call, leaving interactive workstreams + unowned and weakening ownership-based access controls. + """ + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=FakeUI, user_id="user-abc") + assert ws.user_id == "user-abc" + + def test_create_defaults_user_id_to_empty(self): + mgr = WorkstreamManager(_fake_factory) + ws = mgr.create(ui_factory=FakeUI) + assert ws.user_id == "" + def test_create_max_workstreams_all_active(self): mgr = WorkstreamManager(_fake_factory, max_workstreams=3) ws1 = mgr.create(ui_factory=FakeUI) diff --git a/tests/test_workstream_kind.py b/tests/test_workstream_kind.py new file mode 100644 index 00000000..734c11f9 --- /dev/null +++ b/tests/test_workstream_kind.py @@ -0,0 +1,332 @@ +"""Tests for Phase A schema additions: ``kind`` + ``parent_ws_id`` on workstreams. + +Covers: + +- ``register_workstream`` persists the two new columns. +- ``get_workstream`` returns the full row including the new fields. +- ``list_workstreams`` filters on ``kind`` and ``parent_ws_id`` correctly. +- ``parent_ws_id`` empty-string normalization at the storage edge. +- Defaults remain ``"interactive"`` / ``NULL`` when not specified. +- ``Workstream`` dataclass exposes ``kind`` / ``parent_ws_id`` / ``user_id`` + with safe defaults. +""" + +from __future__ import annotations + +import pytest + +from turnstone.core.storage._sqlite import SQLiteBackend +from turnstone.core.workstream import Workstream + + +@pytest.fixture +def storage(tmp_path): + return SQLiteBackend(str(tmp_path / "test.db")) + + +# --------------------------------------------------------------------------- +# register_workstream / get_workstream +# --------------------------------------------------------------------------- + + +def test_register_defaults_to_interactive_no_parent(storage): + storage.register_workstream("ws-a") + row = storage.get_workstream("ws-a") + assert row is not None + assert row["kind"] == "interactive" + assert row["parent_ws_id"] is None + + +def test_register_coordinator_kind_and_parent(storage): + storage.register_workstream("ws-coord", node_id="console", user_id="user-1", kind="coordinator") + storage.register_workstream( + "ws-child", + node_id="node-a", + user_id="user-1", + kind="interactive", + parent_ws_id="ws-coord", + ) + + coord = storage.get_workstream("ws-coord") + child = storage.get_workstream("ws-child") + + assert coord is not None and child is not None + assert coord["kind"] == "coordinator" + assert coord["parent_ws_id"] is None + assert coord["user_id"] == "user-1" + + assert child["kind"] == "interactive" + assert child["parent_ws_id"] == "ws-coord" + assert child["user_id"] == "user-1" + + +def test_register_normalizes_empty_parent_to_null(storage): + """Empty-string parent_ws_id must be persisted as NULL so + ``WHERE parent_ws_id IS NULL`` filters stay correct.""" + storage.register_workstream("ws-a", parent_ws_id="") + row = storage.get_workstream("ws-a") + assert row is not None + assert row["parent_ws_id"] is None + + +def test_get_workstream_missing_returns_none(storage): + assert storage.get_workstream("nonexistent") is None + + +def test_get_workstream_includes_all_fields(storage): + storage.register_workstream( + "ws-full", + node_id="n1", + user_id="u1", + alias="alias-1", + title="Title 1", + name="name-1", + state="idle", + skill_id="skill-x", + skill_version=3, + kind="interactive", + parent_ws_id="parent-x", + ) + row = storage.get_workstream("ws-full") + assert row is not None + for expected in ( + "ws_id", + "node_id", + "user_id", + "alias", + "title", + "name", + "state", + "skill_id", + "skill_version", + "kind", + "parent_ws_id", + "created", + "updated", + ): + assert expected in row + assert row["skill_version"] == 3 + assert row["parent_ws_id"] == "parent-x" + + +# --------------------------------------------------------------------------- +# list_workstreams filter params +# --------------------------------------------------------------------------- + + +def test_list_workstreams_no_filters_unchanged(storage): + storage.register_workstream("ws-a") + storage.register_workstream("ws-b") + rows = storage.list_workstreams() + assert len(rows) == 2 + + +def test_list_workstreams_filter_by_kind(storage): + storage.register_workstream("ws-int-1") + storage.register_workstream("ws-int-2") + storage.register_workstream("ws-coord", kind="coordinator") + + interactive = storage.list_workstreams(kind="interactive") + coord = storage.list_workstreams(kind="coordinator") + + assert {r[0] for r in interactive} == {"ws-int-1", "ws-int-2"} + assert {r[0] for r in coord} == {"ws-coord"} + + +def test_list_workstreams_filter_by_parent(storage): + storage.register_workstream("ws-coord", kind="coordinator") + storage.register_workstream("child-1", parent_ws_id="ws-coord") + storage.register_workstream("child-2", parent_ws_id="ws-coord") + storage.register_workstream("other-1") # no parent + + children = storage.list_workstreams(parent_ws_id="ws-coord") + assert {r[0] for r in children} == {"child-1", "child-2"} + + +def test_list_workstreams_combined_filters(storage): + storage.register_workstream("ws-coord", kind="coordinator") + storage.register_workstream("child-1", parent_ws_id="ws-coord") + storage.register_workstream("child-coord", parent_ws_id="ws-coord", kind="coordinator") + + # Children of ws-coord that are themselves interactive. + rows = storage.list_workstreams(parent_ws_id="ws-coord", kind="interactive") + assert {r[0] for r in rows} == {"child-1"} + + +def test_list_workstreams_node_id_filter_still_works(storage): + """The existing ``node_id`` filter keeps working after the signature change.""" + storage.register_workstream("ws-a", node_id="node-1") + storage.register_workstream("ws-b", node_id="node-2") + rows = storage.list_workstreams(node_id="node-1") + assert {r[0] for r in rows} == {"ws-a"} + + +def test_list_workstreams_returns_kind_and_parent_columns(storage): + storage.register_workstream("ws-coord", kind="coordinator") + storage.register_workstream("child-1", parent_ws_id="ws-coord") + rows = storage.list_workstreams() + by_id = {r[0]: r for r in rows} + # Columns: ws_id, node_id, name, state, created, updated, kind, parent_ws_id + coord_row = by_id["ws-coord"] + child_row = by_id["child-1"] + assert coord_row[6] == "coordinator" + assert coord_row[7] is None + assert child_row[6] == "interactive" + assert child_row[7] == "ws-coord" + + +# --------------------------------------------------------------------------- +# Workstream dataclass field additions +# --------------------------------------------------------------------------- + + +def test_workstream_dataclass_defaults(): + ws = Workstream() + assert ws.user_id == "" + assert ws.kind == "interactive" + assert ws.parent_ws_id is None + + +def test_workstream_dataclass_accepts_coordinator_kind(): + ws = Workstream(kind="coordinator", user_id="user-1") + assert ws.kind == "coordinator" + assert ws.user_id == "user-1" + assert ws.parent_ws_id is None + + +def test_workstream_dataclass_accepts_parent(): + ws = Workstream(parent_ws_id="parent-x") + assert ws.parent_ws_id == "parent-x" + + +# --------------------------------------------------------------------------- +# Tool-namespace isolation between kinds +# --------------------------------------------------------------------------- + + +def test_interactive_and_coordinator_tool_sets_are_disjoint(): + """Interactive sessions must not see coordinator tools and vice versa. + + Regression guard for the latent threshold bug where coordinator tools + counted against the interactive session's tool-search threshold, and + a future reader might naively expose ``TOOLS`` (the union) to an + interactive session. + """ + from turnstone.core.tools import COORDINATOR_TOOLS, INTERACTIVE_TOOLS, TOOLS + + interactive_names = {t["function"]["name"] for t in INTERACTIVE_TOOLS} + coord_names = {t["function"]["name"] for t in COORDINATOR_TOOLS} + + # No overlap. + assert interactive_names.isdisjoint(coord_names), ( + f"interactive ∩ coordinator tools should be empty, got {interactive_names & coord_names}" + ) + # Coordinator set is non-empty (spawn/inspect/send/close/delete/list). + assert coord_names, "expected at least one coordinator tool" + # Union covers every loaded tool (no tool is in neither set). + all_names = {t["function"]["name"] for t in TOOLS} + assert interactive_names | coord_names == all_names + + +def test_chatsession_interactive_kind_excludes_coordinator_tools(tmp_db): + """An interactive ``ChatSession`` does not surface coordinator tools.""" + from unittest.mock import MagicMock + + from turnstone.core.session import ChatSession + + class _NullUI: + def __getattr__(self, _name): + return lambda *a, **kw: None + + sess = ChatSession( + client=MagicMock(), + model="test-model", + ui=_NullUI(), + instructions=None, + temperature=0.5, + max_tokens=4096, + tool_timeout=30, + ) + names = {t["function"]["name"] for t in sess._tools} + # None of the coordinator-only names should be in the interactive + # session's tool set. + for coord_name in ( + "spawn_workstream", + "inspect_workstream", + "send_to_workstream", + "close_workstream", + "delete_workstream", + "list_workstreams", + ): + assert coord_name not in names, f"{coord_name} leaked into interactive session tools" + + +def test_chatsession_coordinator_kind_excludes_interactive_tools(tmp_db): + """A coordinator ``ChatSession`` sees only coordinator tools.""" + from unittest.mock import MagicMock + + from turnstone.core.session import ChatSession + + class _NullUI: + def __getattr__(self, _name): + return lambda *a, **kw: None + + sess = ChatSession( + client=MagicMock(), + model="test-model", + ui=_NullUI(), + instructions=None, + temperature=0.5, + max_tokens=4096, + tool_timeout=30, + kind="coordinator", + ) + names = {t["function"]["name"] for t in sess._tools} + # Coordinator tools present, interactive tools absent. + assert "spawn_workstream" in names + assert "bash" not in names + assert "edit_file" not in names + assert "memory" not in names + # Sub-agent tool lists are zeroed for coordinators. + assert sess._task_tools == [] + assert sess._agent_tools == [] + + +def test_chatsession_coordinator_kind_does_not_merge_mcp_tools(tmp_db): + """Coordinator ChatSession ignores any attached MCP client tool surface. + + Coordinators are meta-orchestrators that spawn child workstreams; + MCP tools live on the children. Giving the coordinator direct MCP + access defeats the child-spawning pattern. + """ + from unittest.mock import MagicMock + + from turnstone.core.session import ChatSession + + class _NullUI: + def __getattr__(self, _name): + return lambda *a, **kw: None + + mcp_client = MagicMock() + mcp_client.get_tools.return_value = [ + {"type": "function", "function": {"name": "mcp__foo__bar", "parameters": {}}} + ] + sess = ChatSession( + client=MagicMock(), + model="test-model", + ui=_NullUI(), + instructions=None, + temperature=0.5, + max_tokens=4096, + tool_timeout=30, + kind="coordinator", + mcp_client=mcp_client, + ) + names = {t["function"]["name"] for t in sess._tools} + # No MCP tools in the coordinator surface. + assert "mcp__foo__bar" not in names + # And no MCP listeners were registered (defence-in-depth: MCP tool + # refreshes can't mutate the coordinator's fixed tool set). + mcp_client.add_listener.assert_not_called() + mcp_client.add_resource_listener.assert_not_called() + mcp_client.add_prompt_listener.assert_not_called() diff --git a/turnstone/api/server_schemas.py b/turnstone/api/server_schemas.py index 18a76972..eacd36eb 100644 --- a/turnstone/api/server_schemas.py +++ b/turnstone/api/server_schemas.py @@ -140,6 +140,23 @@ class CreateWorkstreamRequest(BaseModel): "lands. Auto-generated when omitted." ), ) + kind: str = Field( + default="interactive", + description=( + "Workstream kind — 'interactive' (default) or 'coordinator'. " + "Coordinator workstreams are created by the console's own " + "/v1/api/coordinator/new endpoint; clients hitting " + "/v1/api/workstreams/new should leave this at the default." + ), + ) + parent_ws_id: str | None = Field( + default=None, + description=( + "Optional parent workstream id. Populated on children spawned " + "by a coordinator so the parent/child relationship survives " + "restart and appears in audit / list views." + ), + ) class CreateWorkstreamResponse(BaseModel): diff --git a/turnstone/cli.py b/turnstone/cli.py index 1d6fce97..75c9690d 100644 --- a/turnstone/cli.py +++ b/turnstone/cli.py @@ -1130,6 +1130,8 @@ def main() -> None: *, skill: str | None = None, client_type: str = "", + kind: str = "interactive", + parent_ws_id: str | None = None, ) -> ChatSession: assert ui is not None, "session_factory requires a non-None UI" r_client, r_model, r_cfg = registry.resolve(model_alias) @@ -1156,6 +1158,8 @@ def main() -> None: web_search_backend=args.web_search_backend, skill=skill or args.skill or None, judge_config=judge_config, + kind=kind, + parent_ws_id=parent_ws_id, ) # Create workstream manager and initial workstream diff --git a/turnstone/console/coordinator.py b/turnstone/console/coordinator.py new file mode 100644 index 00000000..c2842058 --- /dev/null +++ b/turnstone/console/coordinator.py @@ -0,0 +1,519 @@ +"""CoordinatorManager — console-side lifecycle for coordinator workstreams. + +A coordinator workstream is a :class:`turnstone.core.session.ChatSession` +with ``kind="coordinator"`` running inside ``turnstone-console``. Each +one has its own :class:`ConsoleCoordinatorUI`, :class:`CoordinatorClient`, +per-session JWT manager, and worker thread. The manager tracks them in +a ``dict[ws_id, Workstream]`` (same dataclass +``turnstone/core/workstream.py`` uses) so dashboard aggregation reuses +the existing types. + +Design notes: + +- **No eager rehydration on startup.** Coordinator rows survive in the + DB, but spinning up every persisted coordinator's worker thread on + console start would waste resources. Rehydration happens lazily in + ``open(ws_id)`` — called by the GET endpoint when a user browses to a + persisted-but-not-loaded coordinator. +- **Shared session factory closure.** The factory (built in + ``session_factory.py``) captures ``registry`` / ``config_store`` / + ``node_id`` / ``coord_client_factory`` so each ``create`` / ``open`` + call just forwards ``ws_id`` + ``user_id``. +- **max_active gate.** Before constructing a new session, enforce + ``coordinator.max_active``: evict the oldest idle coordinator when + full, or raise ``RuntimeError`` (translated to HTTP 429 by the + endpoint) if all slots are non-idle. +""" + +from __future__ import annotations + +import queue +import secrets +import threading +import time +from typing import TYPE_CHECKING + +from turnstone.core.log import get_logger +from turnstone.core.workstream import ( + Workstream, + WorkstreamManager, + WorkstreamState, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from turnstone.console.coordinator_ui import ConsoleCoordinatorUI + from turnstone.core.session import ChatSession + from turnstone.core.storage._protocol import StorageBackend + +log = get_logger(__name__) + + +class CoordinatorManager: + """Tracks live coordinator sessions in the console process.""" + + # Pseudo-node id persisted on coordinator rows so ``workstreams.node_id`` + # stays non-NULL and list / audit surfaces can distinguish coordinators + # from real-node workstreams. The hash-ring router treats it as an + # unroutable sentinel — coordinators never land on real nodes. + NODE_ID = "console" + + def __init__( + self, + *, + session_factory: Callable[..., ChatSession], + ui_factory: Callable[[str, str], ConsoleCoordinatorUI], + storage: StorageBackend, + max_active: int, + ) -> None: + if max_active < 1: + raise ValueError(f"max_active must be >= 1, got {max_active}") + self._session_factory = session_factory + self._ui_factory = ui_factory + self._storage = storage + self._max_active = max_active + self._workstreams: dict[str, Workstream] = {} + self._order: list[str] = [] + self._lock = threading.Lock() + # Per-ws_id locks that serialize concurrent lazy rehydration of the + # same workstream. Without these, two GETs for the same persisted- + # but-not-loaded ws_id both miss the fast path, both call the + # session factory, and the second clobbers the first — orphaning + # a worker thread and leaking SSE listeners. The dict is guarded + # by ``self._lock``; entries are refcounted so we only pop when + # the last waiter releases — popping while a waiter still holds + # the lock would let a fresh arrival allocate a different lock + # for the same ws_id, breaking serialization on the failure path. + self._open_locks: dict[str, tuple[threading.Lock, int]] = {} + + @property + def max_active(self) -> int: + return self._max_active + + # ------------------------------------------------------------------ + # create — new coordinator session + # ------------------------------------------------------------------ + + def create( + self, + *, + user_id: str, + name: str = "", + skill: str | None = None, + initial_message: str = "", + ws_id: str = "", + ) -> Workstream: + """Construct a new coordinator workstream and register it. + + Raises ``RuntimeError`` if the manager is at capacity and no + idle coordinator can be evicted — the API endpoint translates + this to HTTP 429. + + Concurrency model: the placeholder Workstream (session=None) is + inserted into ``self._workstreams`` under ``self._lock`` as part + of slot reservation, so concurrent ``create()`` callers observe + the slot as used immediately. Session construction happens + outside the lock — on failure we re-acquire and remove the + placeholder so capacity isn't leaked. + """ + ws_id = ws_id or secrets.token_hex(16) + with self._lock: + ws, evicted = self._reserve_and_install_locked( + ws_id, user_id, name or f"coord-{ws_id[:4]}" + ) + + if evicted is not None: + self._cleanup(evicted) + + # Persist before constructing the session so lazy rehydration on + # restart can find the row even if the ChatSession build fails. + # Fail-closed: a coordinator that's only in-memory would be + # invisible to lazy rehydration and reappear as "missing" after + # console restart, so surface the storage failure to the caller + # (endpoint returns 500) rather than quietly limping along. + try: + self._storage.register_workstream( + ws_id, + node_id=self.NODE_ID, + user_id=user_id, + name=ws.name, + kind="coordinator", + parent_ws_id=None, + ) + except Exception: + log.warning( + "coord_mgr.register_failed ws=%s", + ws_id[:8], + exc_info=True, + ) + with self._lock: + self._remove_locked(ws_id) + raise + + try: + ws.session = self._session_factory( + ws.ui, + None, # model_alias — factory reads coordinator.model_alias + ws_id, + skill=skill, + kind="coordinator", + parent_ws_id=None, + ) + except Exception: + # Roll back the slot + persisted row on construction failure + # so a misconfigured coordinator doesn't wedge capacity. + with self._lock: + self._remove_locked(ws_id) + try: + self._storage.delete_workstream(ws_id) + except Exception: + # Storage rollback failed — the orphan row will silently + # fail rehydration on future opens. Warn so operators + # can see and clean up manually rather than debugging a + # mysterious "coordinator missing" later. + log.warning( + "coord_mgr.rollback_delete_failed ws=%s", + ws_id[:8], + exc_info=True, + ) + raise + + if initial_message: + self._spawn_worker(ws, initial_message) + + return ws + + # ------------------------------------------------------------------ + # open — lazy rehydration for a persisted coordinator + # ------------------------------------------------------------------ + + def open(self, ws_id: str, user_id: str) -> Workstream | None: + """Rehydrate a persisted coordinator session on demand. + + Returns ``None`` if the row doesn't exist or doesn't belong to + ``user_id``. Callers that need ownership bypass (admin view) + should call ``open_admin`` instead. + """ + return self._open_impl(ws_id, user_id=user_id, admin=False) + + def open_admin(self, ws_id: str) -> Workstream | None: + """Rehydrate regardless of ownership — admin paths only.""" + return self._open_impl(ws_id, user_id="", admin=True) + + def _open_impl(self, ws_id: str, *, user_id: str, admin: bool) -> Workstream | None: + """Serialize concurrent open() for the same ws_id. + + Two concurrent GET /v1/api/coordinator/{ws_id} requests for the + same persisted-but-unloaded ws_id must not each construct a + session. A per-ws_id lock ensures the second arrival sees the + first thread's installed workstream and returns it instead of + spinning up a duplicate. The lock entry is refcounted so it + survives until the last waiter releases — only then is it + popped. Popping earlier would let a third arrival allocate a + fresh lock for the same ws_id, defeating the serialization. + """ + with self._lock: + entry = self._open_locks.get(ws_id) + if entry is None: + open_lock = threading.Lock() + self._open_locks[ws_id] = (open_lock, 1) + else: + open_lock, refs = entry + self._open_locks[ws_id] = (open_lock, refs + 1) + try: + with open_lock: + # Fast-path: someone else installed the session while we + # were waiting on the per-ws lock. + with self._lock: + existing = self._workstreams.get(ws_id) + if existing is not None and existing.session is not None: + return existing + + row = self._storage.get_workstream(ws_id) + if row is None or row.get("kind") != "coordinator": + return None + # close()/delete() only soft-mark the row — refuse to + # resurrect those sessions on any subsequent GET, or the + # Close button becomes silently reversible on URL revisit + # and burns max_active capacity. + if row.get("state") in {"closed", "deleted"}: + return None + row_owner = row.get("user_id") or "" + # Strict equality (not short-circuit on empty row_owner) + # — empty-owner rows must not allow non-admin callers to + # rehydrate orphan / system-owned coordinators (would + # consume a max_active slot + evict another tenant's + # IDLE coordinator). + if not admin and row_owner != user_id: + return None + + # Reserve the slot + install placeholder under the lock + # so concurrent creates/opens count us toward capacity. + with self._lock: + # Re-check fast path (another thread may have raced + # through the whole open while we checked storage). + existing = self._workstreams.get(ws_id) + if existing is not None and existing.session is not None: + return existing + ws, evicted = self._reserve_and_install_locked( + ws_id, + row_owner, + row.get("name") or f"coord-{ws_id[:4]}", + ) + + if evicted is not None: + self._cleanup(evicted) + + try: + ws.session = self._session_factory( + ws.ui, + None, + ws_id, + skill=None, + kind="coordinator", + parent_ws_id=None, + ) + except Exception: + with self._lock: + self._remove_locked(ws_id) + raise + + # Restore message history from storage. + if ws.session is not None and hasattr(ws.session, "resume"): + try: + ws.session.resume(ws_id) + except Exception: + log.debug( + "coord_mgr.resume_failed ws=%s", + ws_id[:8], + exc_info=True, + ) + return ws + finally: + with self._lock: + entry = self._open_locks.get(ws_id) + if entry is not None: + lk, refs = entry + if refs <= 1: + self._open_locks.pop(ws_id, None) + else: + self._open_locks[ws_id] = (lk, refs - 1) + + # ------------------------------------------------------------------ + # Worker thread dispatch + # ------------------------------------------------------------------ + + def send(self, ws_id: str, message: str) -> bool: + """Queue a message onto a coordinator session's ChatSession. + + Returns False if the coordinator isn't loaded in the manager or + if the worker's pending-message queue is full (caller should + surface 429 / backpressure). Priority is parsed from the + message prefix (``/high``, ``/urgent``, etc.) by + :meth:`ChatSession.queue_message`. + """ + ws = self.get(ws_id) + if ws is None or ws.session is None: + return False + return self._spawn_worker(ws, message) + + def _spawn_worker(self, ws: Workstream, message: str) -> bool: + """Start (or reuse) a worker thread that drives session.send. + + Returns True on successful enqueue (existing worker) or thread + spawn (no live worker). Returns False when an existing worker's + queue is full — must NOT spawn a second concurrent worker on + the same ChatSession (mutates messages, queued_messages, + streaming state, LLM client cursors, approvals). + """ + session = ws.session + if session is None: + return False + # If a worker is already running for this ws, enqueue instead of + # spawning a duplicate — ChatSession.queue_message handles FIFO. + if ( + ws.worker_thread is not None + and ws.worker_thread.is_alive() + and hasattr(session, "queue_message") + ): + try: + session.queue_message(message) + return True + except queue.Full: + # Queue is at capacity AND a worker is still running. + # Spawning a second thread on the same ChatSession would + # corrupt history / cursors / approvals — return False so + # the caller can surface backpressure (HTTP 429). + log.warning( + "coord_mgr.queue_full ws=%s — message dropped (worker still busy)", + ws.id[:8], + ) + return False + except Exception: + log.warning( + "coord_mgr.queue_message_failed ws=%s", + ws.id[:8], + exc_info=True, + ) + return False + + def _run() -> None: + try: + session.send(message) + except Exception: + log.exception("coord_mgr.worker_failed ws=%s", ws.id[:8]) + + t = threading.Thread( + target=_run, + name=f"coord-worker-{ws.id[:8]}", + daemon=True, + ) + ws.worker_thread = t + t.start() + return True + + # ------------------------------------------------------------------ + # Inspect / list / close + # ------------------------------------------------------------------ + + def get(self, ws_id: str) -> Workstream | None: + with self._lock: + return self._workstreams.get(ws_id) + + def list_for_user(self, user_id: str) -> list[Workstream]: + """Return coordinators owned by ``user_id``. + + Does NOT include rows with empty ``user_id`` — those are either + system-created sessions or rows migrated in without an owner, + and returning them to every caller would leak ws_id + name + + state across tenants. Admins call :meth:`list_all` instead. + """ + with self._lock: + return [ws for ws in self._workstreams.values() if ws.user_id and ws.user_id == user_id] + + def list_all(self) -> list[Workstream]: + with self._lock: + return list(self._workstreams.values()) + + def close(self, ws_id: str) -> bool: + """Soft-close: unload from memory + mark state=closed in DB.""" + with self._lock: + ws = self._workstreams.pop(ws_id, None) + if ws is None: + return False + if ws_id in self._order: + self._order.remove(ws_id) + self._cleanup(ws) + try: + self._storage.update_workstream_state(ws_id, "closed") + except Exception: + log.debug("coord_mgr.state_update_failed ws=%s", ws_id[:8], exc_info=True) + return True + + def cancel(self, ws_id: str) -> bool: + """Cancel in-flight generation and unblock any pending approval/plan.""" + ws = self.get(ws_id) + if ws is None: + return False + if ws.session is not None and hasattr(ws.session, "cancel"): + try: + ws.session.cancel() + except Exception: + log.debug("coord_mgr.cancel_failed ws=%s", ws_id[:8], exc_info=True) + # Unblock any blocked approval or plan event. + if ws.ui is not None: + if hasattr(ws.ui, "resolve_approval"): + ws.ui.resolve_approval(False, "cancelled") + if hasattr(ws.ui, "resolve_plan"): + ws.ui.resolve_plan("reject") + return True + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _reserve_and_install_locked( + self, + ws_id: str, + user_id: str, + name: str, + ) -> tuple[Workstream, Workstream | None]: + """Install a placeholder Workstream under ``self._lock``. + + Caller MUST hold ``self._lock``. The placeholder has + ``session=None`` — the caller fills it in after construction. + The UI is allocated here (cheap) so concurrent ``get()`` never + observes a placeholder with ``ui=None``; the session is the + only field that lags. + + Returns ``(placeholder, evicted)``. ``evicted`` is a Workstream + the caller must ``self._cleanup(...)`` outside the lock, or + ``None`` when no eviction was needed. + + Raises ``RuntimeError`` when all slots are non-idle — the + endpoint translates this to HTTP 429. + """ + if ws_id in self._workstreams: + # Defensive — should be unreachable: create() uses a fresh + # random ws_id and open() serializes on per-ws locks that + # already bounce the repeated call via the fast path. Raise + # loudly so any real regression surfaces. + raise RuntimeError(f"ws_id {ws_id[:8]!r} already tracked by CoordinatorManager") + evicted: Workstream | None = None + if len(self._workstreams) >= self._max_active: + # Only fully-constructed IDLE sessions are eviction candidates. + # Placeholders (session=None) are in-flight creations that + # count toward capacity but must not evict each other — + # otherwise a burst of concurrent creates would evict every + # prior placeholder and silently exceed ``max_active``. + oldest: Workstream | None = None + for wid in self._order: + w = self._workstreams.get(wid) + if w is None or w.session is None: + continue + if w.state == WorkstreamState.IDLE and ( + oldest is None or w.last_active < oldest.last_active + ): + oldest = w + if oldest is None: + raise RuntimeError(f"All {self._max_active} coordinator slots are active") + self._workstreams.pop(oldest.id, None) + if oldest.id in self._order: + self._order.remove(oldest.id) + evicted = oldest + ws = Workstream(id=ws_id, name=name or f"coord-{ws_id[:4]}") + ws.kind = "coordinator" + ws.user_id = user_id + ws.parent_ws_id = None + # ui_factory is fast (just allocates a ConsoleCoordinatorUI) + # so holding the lock over it is fine. Keeping it inside the + # lock means every observer of the placeholder sees a non-None + # ui; only ``session`` lags behind. + ws.ui = self._ui_factory(ws_id, user_id) + self._workstreams[ws_id] = ws + self._order.append(ws_id) + return ws, evicted + + def _remove_locked(self, ws_id: str) -> None: + """Remove a (possibly-placeholder) workstream from tracking. + + Caller MUST hold ``self._lock``. + """ + self._workstreams.pop(ws_id, None) + if ws_id in self._order: + self._order.remove(ws_id) + + def _cleanup(self, ws: Workstream) -> None: + """Unblock events + cancel session. Matches WorkstreamManager._cleanup_ui.""" + WorkstreamManager._cleanup_ui(ws) + + # ------------------------------------------------------------------ + # Utilities + # ------------------------------------------------------------------ + + def touch(self, ws_id: str) -> None: + """Update ``last_active`` timestamp for eviction ordering.""" + ws = self.get(ws_id) + if ws is not None: + ws.last_active = time.monotonic() diff --git a/turnstone/console/coordinator_client.py b/turnstone/console/coordinator_client.py new file mode 100644 index 00000000..2b095466 --- /dev/null +++ b/turnstone/console/coordinator_client.py @@ -0,0 +1,417 @@ +"""In-process helper for coordinator workstream tool execs. + +A coordinator's ChatSession runs on a worker thread inside +``turnstone-console`` and drives its child workstreams through two +channels: + +- **Mutating ops** (``spawn``, ``send``, ``approve``, ``cancel``, + ``close``, ``delete``) go through the console's own HTTP routing + proxy (``/v1/api/route/*``). Sending over HTTP keeps the normal + middleware stack (auth, rate limit, route pinning) in the loop — + the coordinator gets no special privileges the proxy can't see. +- **Read ops** (``list_children``, ``inspect``) hit the shared + storage backend directly because the routing proxy doesn't cover + list/inspect paths today. Storage is same-process and same-DB, so + this is as safe as any other read inside the console. + +The client is **synchronous by design** — coordinator tool execs run +on the ChatSession's worker thread, not on the event loop — so it uses +``httpx.Client`` rather than the async client. A per-session +:class:`CoordinatorTokenManager` mints short-lived console-audience +JWTs carrying the real user's identity + scopes. +""" + +from __future__ import annotations + +import threading +import time +from typing import TYPE_CHECKING, Any + +import httpx + +from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt +from turnstone.core.log import get_logger + +if TYPE_CHECKING: + from collections.abc import Callable + + from turnstone.core.storage._protocol import StorageBackend + +log = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# Per-session coordinator JWT +# --------------------------------------------------------------------------- + + +class CoordinatorTokenManager: + """Auto-rotating console-audience JWT for a single coordinator session. + + Mints a token with: + + - ``sub`` — the coordinator's real creator ``user_id``. + - ``scopes`` — the creator's scopes (narrowed in the creator's identity + already; the coordinator inherits without escalation). + - ``src`` — ``"coordinator"`` so server-side audit can attribute tool + calls to a coordinator session. + - ``aud`` — :data:`JWT_AUD_CONSOLE` because the issued token is + consumed by the console's own routing-proxy auth middleware. + - ``coord_ws_id`` — the coordinator session's ``ws_id`` for forensics. + + Thread-safe: :attr:`token` re-mints on demand when the current JWT is + within the refresh margin of expiry. + """ + + def __init__( + self, + user_id: str, + scopes: frozenset[str], + permissions: frozenset[str], + secret: str, + coord_ws_id: str, + ttl_seconds: int = 300, + refresh_margin: float = 0.2, + ) -> None: + if ttl_seconds <= 0: + raise ValueError("ttl_seconds must be positive") + self._user_id = user_id + self._scopes = scopes + self._permissions = permissions + self._secret = secret + self._coord_ws_id = coord_ws_id + self._ttl = ttl_seconds + self._margin = ttl_seconds * refresh_margin + self._token: str = "" + self._expires_at: float = 0.0 + self._lock = threading.Lock() + + def _mint(self) -> None: + self._token = create_jwt( + user_id=self._user_id, + scopes=self._scopes, + source="coordinator", + secret=self._secret, + audience=JWT_AUD_CONSOLE, + permissions=self._permissions, + expiry_seconds=self._ttl, + extra_claims={"coord_ws_id": self._coord_ws_id}, + ) + self._expires_at = time.time() + self._ttl + + @property + def token(self) -> str: + with self._lock: + if time.time() >= self._expires_at - self._margin: + self._mint() + return self._token + + +# --------------------------------------------------------------------------- +# Coordinator client +# --------------------------------------------------------------------------- + + +# URL paths on the console's routing proxy — must match the routes +# registered in turnstone/console/server.py (_CONSOLE_ROUTES). Tested +# in test_coordinator_client.py against the live route table. +_ROUTE_PATHS: dict[str, str] = { + "spawn": "/v1/api/route/workstreams/new", + "send": "/v1/api/route/send", + "approve": "/v1/api/route/approve", + "cancel": "/v1/api/route/cancel", + "close": "/v1/api/route/workstreams/close", + "delete": "/v1/api/route/workstreams/delete", +} + + +class CoordinatorClient: + """Sync helper driving a coordinator session's children. + + See module docstring. Not part of the public SDK — internal to + ``turnstone-console`` only. + """ + + def __init__( + self, + console_base_url: str, + storage: StorageBackend, + token_factory: Callable[[], str], + *, + coord_ws_id: str, + user_id: str, + timeout: float = 30.0, + http_client: httpx.Client | None = None, + ) -> None: + self._base_url = console_base_url.rstrip("/") + self._storage = storage + self._token_factory = token_factory + self._coord_ws_id = coord_ws_id + self._user_id = user_id + self._timeout = timeout + # ``http_client`` override exists for testing — prod always + # constructs a fresh sync client so connection pools live and die + # with the coordinator session. + self._http = http_client or httpx.Client(timeout=timeout) + self._owns_http = http_client is None + + # -- lifecycle ---------------------------------------------------------- + + def close(self) -> None: + """Release the underlying HTTP connection pool.""" + if self._owns_http: + try: + self._http.close() + except httpx.HTTPError: + log.debug("coord_client.close.failed", exc_info=True) + + # -- internal helpers --------------------------------------------------- + + def _headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self._token_factory()}"} + + def _post(self, path_key: str, body: dict[str, Any]) -> dict[str, Any]: + path = _ROUTE_PATHS[path_key] + url = f"{self._base_url}{path}" + try: + resp = self._http.post(url, json=body, headers=self._headers()) + except httpx.HTTPError as exc: + log.warning("coord_client.http_error path=%s err=%s", path, exc) + return {"error": f"upstream unreachable: {exc}", "status": 0} + try: + data = resp.json() if resp.content else {} + except ValueError: + data = {"raw": resp.text} + if resp.status_code >= 400: + data.setdefault("error", f"HTTP {resp.status_code}") + data.setdefault("status", resp.status_code) + return data + + # -- model-invoked mutating ops (HTTP) --------------------------------- + + def spawn( + self, + *, + initial_message: str, + parent_ws_id: str, + user_id: str, + skill: str = "", + name: str = "", + model: str = "", + target_node: str = "", + ) -> dict[str, Any]: + """Create a child workstream via the routing proxy.""" + body: dict[str, Any] = { + "kind": "interactive", + "parent_ws_id": parent_ws_id, + "user_id": user_id, + "initial_message": initial_message, + } + if skill: + body["skill"] = skill + if name: + body["name"] = name + if model: + body["model"] = model + if target_node: + body["target_node"] = target_node + return self._post("spawn", body) + + def send(self, ws_id: str, message: str) -> dict[str, Any]: + return self._post("send", {"ws_id": ws_id, "message": message}) + + def close_workstream(self, ws_id: str, reason: str = "") -> dict[str, Any]: + body: dict[str, Any] = {"ws_id": ws_id} + if reason: + # Forwarded for audit / future server-side use. The server's + # close handler currently ignores the key but the routing + # proxy re-mint preserves the payload so downstream audit + # middleware (when it lands) can read it. + body["reason"] = reason + return self._post("close", body) + + def delete(self, ws_id: str) -> dict[str, Any]: + return self._post("delete", {"ws_id": ws_id}) + + # -- console-endpoint helpers (NOT model-invoked tools) ----------------- + + def approve( + self, + ws_id: str, + *, + call_id: str, + approved: bool, + feedback: str = "", + always: bool = False, + ) -> dict[str, Any]: + body = { + "ws_id": ws_id, + "call_id": call_id, + "approved": approved, + "feedback": feedback, + "always": always, + } + return self._post("approve", body) + + def cancel(self, ws_id: str) -> dict[str, Any]: + return self._post("cancel", {"ws_id": ws_id}) + + # -- model-invoked read ops (direct storage) --------------------------- + + def list_children( + self, + parent_ws_id: str, + *, + state: str | None = None, + skill: str | None = None, + limit: int = 100, + ) -> dict[str, Any]: + """Return children of ``parent_ws_id`` excluding other coordinators. + + ``skill`` matches on ``skill_id`` (template id) when provided. + + Returns a dict ``{"children": [...], "truncated": bool}``. The + ``truncated`` flag is ``True`` when the SQL fetch returned a full + ``limit``-sized page — the model can signal to the user there may + be more rows and request pagination. ``kind`` is pushed into the + SQL query so coordinator-siblings never burn the row budget here. + + Cross-tenant guard: the coordinator's LLM input is untrusted + (prompt injection is a first-class threat), so ``parent_ws_id`` + is constrained to the coordinator's own ws_id. A model that + emits some other ws_id gets an empty result rather than a peek + into another tenant's subtree. + """ + if parent_ws_id != self._coord_ws_id: + return {"children": [], "truncated": False} + raw = self._storage.list_workstreams( + limit=limit, + parent_ws_id=parent_ws_id, + kind="interactive", + ) + children: list[dict[str, Any]] = [] + for row in raw: + # Dict access via ``._mapping`` is resilient to SELECT + # column-order changes; a positional row[6] lookup would + # silently corrupt the response if a future migration added + # a column earlier in the projection. + try: + m = row._mapping # SQLAlchemy Row + except AttributeError: + # Fallback for non-Row tuples (test doubles, etc.). + m = { + "ws_id": row[0], + "node_id": row[1], + "name": row[2], + "state": row[3], + "created": row[4], + "updated": row[5], + "kind": row[6] if len(row) > 6 else "interactive", + "parent_ws_id": row[7] if len(row) > 7 else None, + "skill_id": row[8] if len(row) > 8 else None, + "skill_version": row[9] if len(row) > 9 else None, + } + if state is not None and m["state"] != state: + continue + child: dict[str, Any] = { + "ws_id": m["ws_id"], + "node_id": m["node_id"], + "name": m["name"], + "state": m["state"], + "created": m["created"], + "updated": m["updated"], + "kind": m["kind"], + "parent_ws_id": m["parent_ws_id"], + } + if skill is not None: + # skill_id / skill_version are projected by list_workstreams — + # no per-row get_workstream round-trip needed. + if m["skill_id"] != skill: + continue + child["skill_id"] = m["skill_id"] + child["skill_version"] = m["skill_version"] + children.append(child) + # The DB filled a full page → more matching rows may exist behind + # the cap; tell the model so it can re-query with a narrower filter + # or larger limit. Python-side post-filtering is unrelated to + # whether the DB has more pages. + truncated = len(raw) >= limit + return {"children": children, "truncated": truncated} + + def inspect(self, ws_id: str, *, message_limit: int = 20) -> dict[str, Any]: + """Return persisted workstream state + tail-N messages + recent verdicts. + + Cross-tenant guard: the coordinator's LLM input is untrusted, so + the inspectable scope is restricted to (a) the coordinator + itself or (b) a row whose ``parent_ws_id`` is this coordinator + (i.e. one of its own children). Any other ws_id returns the + same not-found shape used for genuine misses, avoiding an + existence oracle. + """ + full = self._storage.get_workstream(ws_id) + miss = {"error": f"workstream not found: {ws_id}", "ws_id": ws_id} + if full is None: + return miss + is_self = ws_id == self._coord_ws_id + is_own_child = full.get("parent_ws_id") == self._coord_ws_id + if not (is_self or is_own_child): + return miss + # load_messages returns the full history in chronological order + # (no limit param in the Protocol) — slice the tail here. Defensive + # try/except: storage errors should not break inspect. + messages: list[Any] = [] + try: + all_msgs = self._storage.load_messages(ws_id) + if message_limit and message_limit > 0: + messages = all_msgs[-message_limit:] + else: + messages = all_msgs + except Exception: + log.debug("coord_client.load_messages.failed ws=%s", ws_id, exc_info=True) + # Recent intent-judge verdicts — useful for "did this child go off + # the rails?" inspection. Capped at 10; advisory, so swallow failures. + verdicts: list[Any] = [] + try: + verdicts = self._storage.list_intent_verdicts(ws_id=ws_id, limit=10) + except Exception: + log.debug("coord_client.list_verdicts.failed ws=%s", ws_id, exc_info=True) + return { + **full, + "messages": _serialize_messages(messages), + "verdicts": _serialize_verdicts(verdicts), + } + + +# --------------------------------------------------------------------------- +# Serialization helpers +# --------------------------------------------------------------------------- + + +def _serialize_messages(rows: list[Any]) -> list[dict[str, Any]]: + """Normalize load_messages rows to JSON-friendly dicts. + + ``load_messages`` historically returns provider-specific message dicts + (``role``/``content``/``tool_name``/...). Keep the passthrough but + ensure the list is serializable. + """ + out: list[dict[str, Any]] = [] + for r in rows: + if isinstance(r, dict): + out.append(r) + else: + # Fall back to a string repr so at least something lands. + out.append({"raw": str(r)}) + return out + + +def _serialize_verdicts(rows: list[Any]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for r in rows: + if isinstance(r, dict): + out.append(r) + else: + try: + out.append(dict(r._mapping)) # SQLAlchemy Row + except Exception: + out.append({"raw": str(r)}) + return out diff --git a/turnstone/console/coordinator_ui.py b/turnstone/console/coordinator_ui.py new file mode 100644 index 00000000..b9650f18 --- /dev/null +++ b/turnstone/console/coordinator_ui.py @@ -0,0 +1,278 @@ +"""SessionUI implementation for console-hosted coordinator workstreams. + +Mirrors ``turnstone.server.WebUI`` but scoped to the console's needs: + +- Per-session SSE listener fan-out (same ``threading.Lock`` + queue list + pattern as WebUI). +- ``threading.Event`` + ``_approval_result`` / ``_plan_result`` for + blocking the worker thread until a console endpoint delivers the + decision. +- No global broadcast channel and no per-node metrics — the console is + not a node. Dashboard aggregation for coordinator sessions lands in + Phase D; here we only emit events the one-pane UI consumes. + +Contract: this class must conform to :class:`turnstone.core.session.SessionUI`. +""" + +from __future__ import annotations + +import contextlib +import queue +import threading +from typing import Any + +from turnstone.core.log import get_logger + +log = get_logger(__name__) + +# Per-queue cap keeps a slow SSE consumer from bloating memory. Matches +# WebUI's listener queue size. +_LISTENER_QUEUE_MAX = 500 + +# Hard cap on how long a worker thread blocks waiting for an approval / +# plan-review decision. Exported as a constant so both blocking paths +# stay in lockstep and a future `coordinator.approval_timeout_seconds` +# setting can swap the literal. +_APPROVAL_WAIT_TIMEOUT = 3600 + + +class ConsoleCoordinatorUI: + """SessionUI for a single coordinator session in the console. + + Thread-safe: the ChatSession worker thread calls the ``on_*`` methods; + HTTP handlers (``_register_listener`` / ``resolve_*``) run on the + event loop. All shared state is guarded by ``_listeners_lock`` or + threading primitives. + """ + + def __init__(self, ws_id: str = "", user_id: str = "") -> None: + self.ws_id = ws_id + self._user_id = user_id + # SSE listener fan-out — one per connected browser tab. + self._listeners: list[queue.Queue[dict[str, Any]]] = [] + self._listeners_lock = threading.Lock() + # Approval blocking — the worker thread calls approve_tools which + # waits on _approval_event; the /approve endpoint sets it via + # resolve_approval. + self._approval_event = threading.Event() + self._approval_result: tuple[bool, str | None] = (False, None) + # Pending approval shape — re-sent on SSE reconnect so a user + # switching tabs still sees the prompt. + self._pending_approval: dict[str, Any] | None = None + self._plan_event = threading.Event() + self._plan_result: str = "" + self._pending_plan_review: dict[str, Any] | None = None + self.auto_approve = False + self.auto_approve_tools: set[str] = set() + # Foreground event — compatible with _cleanup_ui's hasattr check. + self._fg_event = threading.Event() + self._fg_event.set() + + # ------------------------------------------------------------------ + # Listener plumbing (SSE) + # ------------------------------------------------------------------ + + def _enqueue(self, data: dict[str, Any]) -> None: + """Fan an event out to all registered SSE listener queues.""" + if "ws_id" not in data: + data = {**data, "ws_id": self.ws_id} + with self._listeners_lock: + snapshot = list(self._listeners) + for lq in snapshot: + with contextlib.suppress(queue.Full): + lq.put_nowait(data) + + def _register_listener(self) -> queue.Queue[dict[str, Any]]: + """Create and register a per-client queue.""" + client_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=_LISTENER_QUEUE_MAX) + with self._listeners_lock: + self._listeners.append(client_queue) + return client_queue + + def _unregister_listener(self, client_queue: queue.Queue[dict[str, Any]]) -> None: + with self._listeners_lock, contextlib.suppress(ValueError): + self._listeners.remove(client_queue) + + # ------------------------------------------------------------------ + # SessionUI protocol — streaming + # ------------------------------------------------------------------ + + def on_thinking_start(self) -> None: + self._enqueue({"type": "thinking_start"}) + + def on_thinking_stop(self) -> None: + self._enqueue({"type": "thinking_stop"}) + + def on_reasoning_token(self, text: str) -> None: + self._enqueue({"type": "reasoning", "text": text}) + + def on_content_token(self, text: str) -> None: + self._enqueue({"type": "content", "text": text}) + + def on_stream_end(self) -> None: + self._enqueue({"type": "stream_end"}) + + # ------------------------------------------------------------------ + # SessionUI protocol — approvals + # ------------------------------------------------------------------ + + def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]: + pending = [it for it in items if it.get("needs_approval") and not it.get("error")] + + serialized = [] + for item in items: + entry: dict[str, Any] = { + "call_id": item.get("call_id", ""), + "header": item.get("header", ""), + "preview": item.get("preview", ""), + "func_name": item.get("func_name", ""), + "approval_label": item.get("approval_label", item.get("func_name", "")), + "needs_approval": item.get("needs_approval", False), + "error": item.get("error"), + } + serialized.append(entry) + + if not pending: + # Nothing to approve; broadcast tool info anyway so the UI + # can render the tool preview. + if serialized: + self._enqueue({"type": "tools_auto_approved", "items": serialized}) + return True, None + + # Per-tool auto-approve: 'Always approve this tool' adds the + # tool name to ``auto_approve_tools``. This must short-circuit + # independently of the blanket ``auto_approve`` flag — matches + # the WebUI two-tier contract (turnstone/server.py). + if self.auto_approve_tools: + pending_names = {it.get("func_name", "") for it in pending if it.get("func_name")} + if pending_names and pending_names.issubset(self.auto_approve_tools): + self._enqueue({"type": "tools_auto_approved", "items": serialized}) + return True, None + + # Blanket auto-approve (set e.g. during scripted + # restart-rehydration) — also matches WebUI semantics. + if self.auto_approve: + self._enqueue({"type": "tools_auto_approved", "items": serialized}) + return True, None + + self._approval_event.clear() + self._pending_approval = { + "type": "approve_request", + "items": serialized, + "judge_pending": False, + } + self._enqueue(self._pending_approval) + if not self._approval_event.wait(timeout=_APPROVAL_WAIT_TIMEOUT): + log.warning("coord_ui.approval_timeout ws=%s", self.ws_id) + self.resolve_approval(False, "Approval timed out after 1 hour") + self._pending_approval = None + approved, feedback = self._approval_result + + if not approved: + denial_msg = "Denied by user" + if feedback: + denial_msg += f": {feedback}" + for item in pending: + item["denied"] = True + item["denial_msg"] = denial_msg + + return approved, feedback + + def resolve_approval(self, approved: bool, feedback: str | None = None) -> None: + """Called by the POST /v1/api/coordinator/{ws_id}/approve handler.""" + self._approval_result = (approved, feedback) + self._enqueue( + { + "type": "approval_resolved", + "approved": approved, + "feedback": feedback or "", + } + ) + self._approval_event.set() + + def on_plan_review(self, content: str) -> str: + # Coordinator sessions don't fire plan_agent (AGENT_TOOLS is [] + # for coordinator kind) so this path shouldn't normally run. + # Implemented defensively for SessionUI protocol compatibility. + self._plan_event.clear() + self._pending_plan_review = {"type": "plan_review", "content": content} + self._enqueue(self._pending_plan_review) + if not self._plan_event.wait(timeout=_APPROVAL_WAIT_TIMEOUT): + log.warning("coord_ui.plan_review_timeout ws=%s", self.ws_id) + self.resolve_plan("reject") + self._pending_plan_review = None + return self._plan_result + + def resolve_plan(self, feedback: str) -> None: + self._plan_result = feedback + if self._pending_plan_review is None: + self._plan_event.set() + return + self._pending_plan_review = None + self._enqueue({"type": "plan_resolved", "feedback": feedback}) + self._plan_event.set() + + # ------------------------------------------------------------------ + # SessionUI protocol — tool results + status + misc + # ------------------------------------------------------------------ + + def on_tool_result( + self, + call_id: str, + name: str, + output: str, + *, + is_error: bool = False, + ) -> None: + event: dict[str, Any] = { + "type": "tool_result", + "call_id": call_id, + "name": name, + "output": output, + } + if is_error: + event["is_error"] = True + self._enqueue(event) + + def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: + self._enqueue({"type": "tool_output_chunk", "call_id": call_id, "chunk": chunk}) + + def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: + total = usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0) + pct = round(total / context_window * 100, 1) if context_window > 0 else 0 + self._enqueue( + { + "type": "status", + "prompt_tokens": usage.get("prompt_tokens", 0), + "completion_tokens": usage.get("completion_tokens", 0), + "total_tokens": total, + "context_window": context_window, + "pct": pct, + "effort": effort, + "cache_creation_tokens": usage.get("cache_creation_tokens", 0), + "cache_read_tokens": usage.get("cache_read_tokens", 0), + } + ) + + def on_info(self, message: str) -> None: + self._enqueue({"type": "info", "message": message}) + + def on_error(self, message: str) -> None: + self._enqueue({"type": "error", "message": message}) + + def on_state_change(self, state: str) -> None: + self._enqueue({"type": "state_change", "state": state}) + + def on_rename(self, name: str) -> None: + self._enqueue({"type": "rename", "name": name}) + + def on_intent_verdict(self, verdict: dict[str, Any]) -> None: + # Coordinator sessions use the intent judge like any other session. + # Surface verdicts to the UI for visibility, but skip the + # persistence + late-decision plumbing that WebUI does — those + # are acceptable to defer for v1 and add alongside the broader + # audit-on-proxy work in a follow-up. + self._enqueue({"type": "intent_verdict", **verdict}) + + def on_output_warning(self, call_id: str, assessment: dict[str, Any]) -> None: + self._enqueue({"type": "output_warning", "call_id": call_id, **assessment}) diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 1017aed4..c0f8e06e 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -12,6 +12,7 @@ from __future__ import annotations import argparse import asyncio +import contextlib import functools import html import json @@ -174,19 +175,36 @@ def _proxy_auth_headers(request: Request) -> dict[str, str]: so the upstream server records correct audit attribution and enforces scope narrowing. Falls back to the ServiceTokenManager when no user context is available. + + When the inbound request authenticated with a coordinator-minted JWT + (``auth_result.token_source == "coordinator"``), the re-mint + preserves that source AND the ``coord_ws_id`` custom claim so + upstream audit rows retain coordinator-origin visibility. For all + other inbound sources the re-mint uses ``"console-proxy"`` as before. """ auth_result = getattr(getattr(request, "state", None), "auth_result", None) jwt_secret: str = getattr(request.app.state, "jwt_secret", "") if auth_result is not None and auth_result.user_id and jwt_secret: + # Preserve coordinator-origin source on re-mint — otherwise + # every upstream call from a coordinator session would be + # indistinguishable from a human-originated console proxy call. + is_coord = auth_result.token_source == "coordinator" + source = "coordinator" if is_coord else "console-proxy" + extra: dict[str, Any] = {} + if is_coord: + coord_ws_id = auth_result.extra_claims.get("coord_ws_id") + if coord_ws_id: + extra["coord_ws_id"] = coord_ws_id token = create_jwt( user_id=auth_result.user_id, scopes=auth_result.scopes, - source="console-proxy", + source=source, secret=jwt_secret, audience=JWT_AUD_SERVER, permissions=auth_result.permissions, expiry_seconds=_PROXY_JWT_EXPIRY_SECONDS, + extra_claims=extra or None, ) return {"Authorization": f"Bearer {token}"} @@ -1090,6 +1108,96 @@ async def route_proxy(request: Request) -> Response: ) +async def route_workstream_delete(request: Request) -> Response: + """POST /v1/api/route/workstreams/delete — proxy to the upstream delete endpoint. + + The upstream server exposes delete at ``POST /v1/api/workstreams/{ws_id}/delete`` + (path parameter), so ``route_proxy``'s ``/api/route/... → /api/...`` + rewrite doesn't apply. This dedicated handler reads ``ws_id`` from the + request body, routes to the owning node, and forwards to the path-parameter + form. Used by the coordinator's ``delete_workstream`` tool. + """ + t0 = time.monotonic() + router: ConsoleRouter | None = request.app.state.router + ring_ready = router is not None and router.is_ready() + if not ring_ready: + if router is not None: + await asyncio.to_thread(router.refresh_cache) + ring_ready = router.is_ready() + if not ring_ready: + return _record_route( + request, + "delete", + 503, + t0, + JSONResponse( + {"error": "Cluster routing not initialized"}, + status_code=503, + ), + ) + assert router is not None + + try: + body = await request.json() + except Exception: + return _record_route( + request, + "delete", + 400, + t0, + JSONResponse({"error": "Invalid JSON body"}, status_code=400), + ) + + ws_id = body.get("ws_id", "") + if not ws_id: + return _record_route( + request, + "delete", + 400, + t0, + JSONResponse({"error": "ws_id required"}, status_code=400), + ) + try: + ref = router.route(ws_id) + except (NoAvailableNodeError, ValueError): + return _record_route( + request, + "delete", + 503, + t0, + JSONResponse({"error": "routing failed"}, status_code=503), + ) + + client: httpx.AsyncClient = request.app.state.proxy_client + headers = _proxy_auth_headers(request) + upstream_url = f"{ref.url}/v1/api/workstreams/{ws_id}/delete" + try: + resp = await client.post(upstream_url, json=body, headers=headers) + except httpx.HTTPError: + return _record_route( + request, + "delete", + 502, + t0, + JSONResponse( + {"error": f"upstream node {ref.node_id} unreachable"}, + status_code=502, + ), + ) + + return _record_route( + request, + "delete", + resp.status_code, + t0, + Response( + content=resp.content, + status_code=resp.status_code, + headers=dict(resp.headers), + ), + ) + + async def route_lookup(request: Request) -> JSONResponse: """GET /v1/api/route — look up which node owns a workstream.""" t0 = time.monotonic() @@ -1375,6 +1483,505 @@ async def _proxy_sse( ) +# --------------------------------------------------------------------------- +# Coordinator workstream endpoints — POST /v1/api/coordinator/* +# --------------------------------------------------------------------------- + + +def _require_coord_mgr(request: Request) -> tuple[Any, JSONResponse | None]: + """Resolve the coordinator manager or return a 503 with remediation. + + Returns ``(coord_mgr, None)`` on success, ``(None, JSONResponse)`` + when the coordinator subsystem isn't configured. + """ + coord_mgr = getattr(request.app.state, "coord_mgr", None) + config_store = getattr(request.app.state, "config_store", None) + if coord_mgr is None: + registry_err = getattr(request.app.state, "coord_registry_error", "") or "" + msg = "Coordinator subsystem not initialized. " + ( + registry_err or "Check coordinator.model_alias and Models tab configuration." + ) + return None, JSONResponse({"error": msg}, status_code=503) + if config_store is None: + return None, JSONResponse({"error": "ConfigStore unavailable"}, status_code=503) + alias = (config_store.get("coordinator.model_alias") or "").strip() + if not alias: + return None, JSONResponse( + { + "error": ( + "coordinator.model_alias is not configured. Set it in " + "the admin Settings tab (coordinator section) before " + "creating coordinator sessions." + ) + }, + status_code=503, + ) + # Registry must be present for alias resolution. Defensive — when + # coord_mgr is built the lifespan also sets coord_registry, so this + # branch catches partial-init states rather than intentional misuse. + registry = getattr(request.app.state, "coord_registry", None) + if registry is None: + return None, JSONResponse( + { + "error": ( + "ModelRegistry unavailable for coordinator sessions. " + "Restart the console after adding a model definition." + ) + }, + status_code=503, + ) + try: + registry.resolve(alias) + except Exception as exc: + return None, JSONResponse( + { + "error": ( + f"coordinator.model_alias '{alias}' does not resolve: " + f"{exc}. Fix the alias in the admin Settings tab." + ) + }, + status_code=503, + ) + return coord_mgr, None + + +def _require_admin_coordinator(request: Request) -> JSONResponse | None: + """Gate a coordinator endpoint on the ``admin.coordinator`` permission.""" + from turnstone.core.auth import require_permission + + return require_permission(request, "admin.coordinator") + + +def _auth_user_id(request: Request) -> str: + auth = getattr(getattr(request, "state", None), "auth_result", None) + return getattr(auth, "user_id", "") or "" + + +def _is_admin(request: Request) -> bool: + auth = getattr(getattr(request, "state", None), "auth_result", None) + perms: frozenset[str] = getattr(auth, "permissions", frozenset()) + return "admin.users" in perms or "admin.roles" in perms + + +async def coordinator_create(request: Request) -> JSONResponse: + """POST /v1/api/coordinator/new — create a new coordinator session.""" + from turnstone.core.audit import record_audit + from turnstone.core.web_helpers import read_json_or_400 + + err = _require_admin_coordinator(request) + if err is not None: + return err + coord_mgr, err503 = _require_coord_mgr(request) + if err503 is not None: + return err503 + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + user_id = _auth_user_id(request) + if not user_id: + return JSONResponse({"error": "authentication required"}, status_code=401) + name = (body.get("name") or "").strip() + skill = (body.get("skill") or "").strip() or None + initial_message = (body.get("initial_message") or "").strip() + try: + ws = coord_mgr.create( + user_id=user_id, + name=name, + skill=skill, + initial_message=initial_message, + ) + except RuntimeError as exc: + return JSONResponse({"error": str(exc)}, status_code=429) + except ValueError as exc: + # Session factory raises ValueError on misconfigured alias — + # surface as 503 with the factory's remediation text. + return JSONResponse({"error": str(exc)}, status_code=503) + except Exception: + # Don't echo the exception text to the caller — it can leak + # internals (stack frame names, file paths, etc.). Log with a + # short correlation id and return that to the client so support + # can match a user report to the log line. + correlation_id = secrets.token_hex(4) + log.warning( + "coordinator_create.failed correlation_id=%s", + correlation_id, + exc_info=True, + ) + return JSONResponse( + { + "error": ( + "failed to create coordinator (internal error). " + f"correlation_id={correlation_id}" + ) + }, + status_code=500, + ) + storage = getattr(request.app.state, "auth_storage", None) + if storage is not None: + try: + record_audit( + storage, + user_id, + "coordinator.create", + "workstream", + ws.id, + {"coord_ws_id": ws.id, "src": "coordinator", "name": ws.name}, + request.client.host if request.client else "", + ) + except Exception: + log.debug("coordinator_create.audit_failed", exc_info=True) + return JSONResponse({"ws_id": ws.id, "name": ws.name}, status_code=201) + + +async def coordinator_send(request: Request) -> JSONResponse: + """POST /v1/api/coordinator/{ws_id}/send — queue a user message.""" + from turnstone.core.web_helpers import read_json_or_400 + + err = _require_admin_coordinator(request) + if err is not None: + return err + coord_mgr, err503 = _require_coord_mgr(request) + if err503 is not None: + return err503 + ws_id = request.path_params.get("ws_id", "") + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + message = (body.get("message") or "").strip() + if not message: + return JSONResponse({"error": "message is required"}, status_code=400) + user_id = _auth_user_id(request) + ws = coord_mgr.get(ws_id) + if ws is None: + return JSONResponse({"error": "coordinator not found"}, status_code=404) + if ws.user_id != user_id and not _is_admin(request): + # Strict equality (not short-circuit on empty ws.user_id) — + # empty-owner rows would otherwise leak ws_id/state/history + # across tenants to anyone with admin.coordinator. 404 (not + # 403) to avoid leaking existence. + return JSONResponse({"error": "coordinator not found"}, status_code=404) + if not coord_mgr.send(ws_id, message): + # Distinguish "worker busy + queue full" from "ws not loaded". + # If ws is loaded and session exists, the worker queue is full — + # tell the client to back off rather than retry blindly. + ws_now = coord_mgr.get(ws_id) + if ws_now is not None and ws_now.session is not None: + return JSONResponse({"error": "worker queue full; retry shortly"}, status_code=429) + return JSONResponse({"error": "send failed"}, status_code=500) + return JSONResponse({"status": "ok"}) + + +async def coordinator_approve(request: Request) -> JSONResponse: + """POST /v1/api/coordinator/{ws_id}/approve — unblock pending approval.""" + from turnstone.core.web_helpers import read_json_or_400 + + err = _require_admin_coordinator(request) + if err is not None: + return err + coord_mgr, err503 = _require_coord_mgr(request) + if err503 is not None: + return err503 + ws_id = request.path_params.get("ws_id", "") + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + approved = bool(body.get("approved", False)) + feedback = body.get("feedback") + always = bool(body.get("always", False)) + user_id = _auth_user_id(request) + ws = coord_mgr.get(ws_id) + if ws is None: + return JSONResponse({"error": "coordinator not found"}, status_code=404) + if ws.user_id != user_id and not _is_admin(request): + # Strict equality — empty-owner rows must not leak across tenants. + return JSONResponse({"error": "coordinator not found"}, status_code=404) + ui = ws.ui + if ui is None or not hasattr(ui, "resolve_approval"): + return JSONResponse( + {"error": "coordinator UI does not support approval"}, + status_code=409, + ) + if always and approved and getattr(ui, "_pending_approval", None): + tool_names = { + it.get("approval_label", "") or it.get("func_name", "") + for it in ui._pending_approval.get("items", []) + if it.get("needs_approval") and it.get("func_name") and not it.get("error") + } + tool_names.discard("") + ui.auto_approve_tools.update(tool_names) + ui.resolve_approval(approved, feedback) + return JSONResponse({"status": "ok"}) + + +async def coordinator_cancel(request: Request) -> JSONResponse: + """POST /v1/api/coordinator/{ws_id}/cancel — cancel in-flight generation.""" + from turnstone.core.audit import record_audit + + err = _require_admin_coordinator(request) + if err is not None: + return err + coord_mgr, err503 = _require_coord_mgr(request) + if err503 is not None: + return err503 + ws_id = request.path_params.get("ws_id", "") + user_id = _auth_user_id(request) + ws = coord_mgr.get(ws_id) + if ws is None: + return JSONResponse({"error": "coordinator not found"}, status_code=404) + if ws.user_id != user_id and not _is_admin(request): + # Strict equality — empty-owner rows must not leak across tenants. + return JSONResponse({"error": "coordinator not found"}, status_code=404) + coord_mgr.cancel(ws_id) + storage = getattr(request.app.state, "auth_storage", None) + if storage is not None: + try: + record_audit( + storage, + user_id, + "coordinator.cancel", + "workstream", + ws_id, + {"coord_ws_id": ws_id, "src": "coordinator"}, + request.client.host if request.client else "", + ) + except Exception: + log.debug("coordinator_cancel.audit_failed", exc_info=True) + return JSONResponse({"status": "ok"}) + + +async def coordinator_close(request: Request) -> JSONResponse: + """POST /v1/api/coordinator/{ws_id}/close — unload the session.""" + from turnstone.core.audit import record_audit + + err = _require_admin_coordinator(request) + if err is not None: + return err + coord_mgr, err503 = _require_coord_mgr(request) + if err503 is not None: + return err503 + ws_id = request.path_params.get("ws_id", "") + user_id = _auth_user_id(request) + ws = coord_mgr.get(ws_id) + if ws is None: + return JSONResponse({"error": "coordinator not found"}, status_code=404) + if ws.user_id != user_id and not _is_admin(request): + # Strict equality — empty-owner rows must not leak across tenants. + return JSONResponse({"error": "coordinator not found"}, status_code=404) + if not coord_mgr.close(ws_id): + return JSONResponse({"error": "close failed"}, status_code=500) + storage = getattr(request.app.state, "auth_storage", None) + if storage is not None: + try: + record_audit( + storage, + user_id, + "coordinator.close", + "workstream", + ws_id, + {"coord_ws_id": ws_id, "src": "coordinator"}, + request.client.host if request.client else "", + ) + except Exception: + log.debug("coordinator_close.audit_failed", exc_info=True) + return JSONResponse({"status": "ok"}) + + +async def coordinator_events(request: Request) -> Response: + """GET /v1/api/coordinator/{ws_id}/events — SSE event stream.""" + err = _require_admin_coordinator(request) + if err is not None: + return err + coord_mgr, err503 = _require_coord_mgr(request) + if err503 is not None: + return err503 + ws_id = request.path_params.get("ws_id", "") + user_id = _auth_user_id(request) + ws = coord_mgr.get(ws_id) + if ws is None: + return JSONResponse({"error": "coordinator not found"}, status_code=404) + if ws.user_id != user_id and not _is_admin(request): + # Strict equality — empty-owner rows must not leak across tenants. + return JSONResponse({"error": "coordinator not found"}, status_code=404) + ui = ws.ui + if ui is None or not hasattr(ui, "_register_listener"): + return JSONResponse({"error": "coordinator has no UI"}, status_code=409) + + client_queue = ui._register_listener() + # Replay any pending approval so a reconnecting tab sees the prompt. + pending = getattr(ui, "_pending_approval", None) + if pending is not None: + with contextlib.suppress(queue.Full): + client_queue.put_nowait(pending) + pending_plan = getattr(ui, "_pending_plan_review", None) + if pending_plan is not None: + with contextlib.suppress(queue.Full): + client_queue.put_nowait(pending_plan) + + async def event_generator() -> AsyncGenerator[dict[str, Any], None]: + try: + while True: + if await request.is_disconnected(): + break + try: + event = await asyncio.to_thread(client_queue.get, True, 1.0) + yield {"data": json.dumps(event)} + except queue.Empty: + pass # ping keeps the connection alive + finally: + ui._unregister_listener(client_queue) + + return EventSourceResponse(event_generator(), ping=5) + + +async def coordinator_history(request: Request) -> JSONResponse: + """GET /v1/api/coordinator/{ws_id}/history — message history for page load.""" + err = _require_admin_coordinator(request) + if err is not None: + return err + coord_mgr, err503 = _require_coord_mgr(request) + if err503 is not None: + return err503 + ws_id = request.path_params.get("ws_id", "") + user_id = _auth_user_id(request) + ws = coord_mgr.get(ws_id) + storage = getattr(request.app.state, "auth_storage", None) + if ws is None: + # Fall through to storage — maybe the user needs to lazy-rehydrate. + if storage is None: + return JSONResponse({"error": "coordinator not found"}, status_code=404) + row = storage.get_workstream(ws_id) + if row is None or row.get("kind") != "coordinator": + return JSONResponse({"error": "coordinator not found"}, status_code=404) + # Strict equality (not short-circuit on empty user_id) — empty- + # owner rows would otherwise leak the full persisted message + # history of an orphan/system-owned coordinator to anyone + # holding admin.coordinator. + if (row.get("user_id") or "") != user_id and not _is_admin(request): + return JSONResponse({"error": "coordinator not found"}, status_code=404) + messages = storage.load_messages(ws_id) + return JSONResponse({"ws_id": ws_id, "messages": messages}) + if ws.user_id != user_id and not _is_admin(request): + # Strict equality — empty-owner rows must not leak across tenants. + return JSONResponse({"error": "coordinator not found"}, status_code=404) + messages = [] + if storage is not None: + try: + messages = storage.load_messages(ws_id) + except Exception: + log.debug("coordinator_history.load_failed ws=%s", ws_id[:8], exc_info=True) + return JSONResponse({"ws_id": ws_id, "messages": messages}) + + +async def coordinator_list(request: Request) -> JSONResponse: + """GET /v1/api/coordinator — list active coordinator sessions for the caller.""" + err = _require_admin_coordinator(request) + if err is not None: + return err + coord_mgr, err503 = _require_coord_mgr(request) + if err503 is not None: + return err503 + user_id = _auth_user_id(request) + rows = coord_mgr.list_all() if _is_admin(request) else coord_mgr.list_for_user(user_id) + return JSONResponse( + { + "coordinators": [ + { + "ws_id": r.id, + "name": r.name, + "state": r.state.value, + "user_id": r.user_id, + } + for r in rows + ] + } + ) + + +_VALID_WS_ID_RE = re.compile(r"^[a-f0-9]{1,64}$") + + +async def coordinator_page(request: Request) -> Response: + """GET /coordinator/{ws_id} — serve the one-pane coordinator HTML. + + The handler injects ``data-ws-id`` on the tag so + ``coordinator.js`` can read it without a separate API round-trip. + Auth gating happens on the API endpoints the page calls — this + handler simply serves the static template (same model as /static). + """ + ws_id = request.path_params.get("ws_id", "") + if not _VALID_WS_ID_RE.match(ws_id): + return JSONResponse({"error": "invalid ws_id"}, status_code=400) + template_path = _STATIC_DIR / "coordinator" / "index.html" + if not template_path.is_file(): + return JSONResponse({"error": "coordinator UI template missing"}, status_code=500) + try: + body = template_path.read_text(encoding="utf-8") + except OSError: + return JSONResponse({"error": "failed to read coordinator UI template"}, status_code=500) + # Inject the ws_id as an HTML attribute. ws_id passed the + # ``_VALID_WS_ID_RE`` gate above (hex only) so there's nothing + # to HTML-escape; leave the replacement simple. + body = body.replace("{{WS_ID}}", ws_id) + return Response(body, media_type="text/html; charset=utf-8") + + +async def coordinator_detail(request: Request) -> JSONResponse: + """GET /v1/api/coordinator/{ws_id} — detail + lazy rehydrate on miss.""" + err = _require_admin_coordinator(request) + if err is not None: + return err + coord_mgr, err503 = _require_coord_mgr(request) + if err503 is not None: + return err503 + ws_id = request.path_params.get("ws_id", "") + user_id = _auth_user_id(request) + ws = coord_mgr.get(ws_id) + if ws is None: + # Lazy rehydration goes through the session factory, which can + # raise the same exceptions coordinator_create handles — match + # the correlation-id mask so stack traces don't leak through + # the detail endpoint either. + try: + ws = ( + coord_mgr.open_admin(ws_id) + if _is_admin(request) + else coord_mgr.open(ws_id, user_id) + ) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=503) + except Exception: + correlation_id = secrets.token_hex(4) + log.warning( + "coordinator_detail.rehydrate_failed correlation_id=%s ws_id=%s", + correlation_id, + ws_id[:8], + exc_info=True, + ) + return JSONResponse( + { + "error": ( + "failed to rehydrate coordinator (internal error). " + f"correlation_id={correlation_id}" + ) + }, + status_code=500, + ) + if ws is None: + return JSONResponse({"error": "coordinator not found"}, status_code=404) + if ws.user_id != user_id and not _is_admin(request): + # Strict equality — empty-owner rows must not leak across tenants. + return JSONResponse({"error": "coordinator not found"}, status_code=404) + return JSONResponse( + { + "ws_id": ws.id, + "name": ws.name, + "state": ws.state.value, + "user_id": ws.user_id, + "kind": ws.kind, + } + ) + + # --------------------------------------------------------------------------- # Lifespan # --------------------------------------------------------------------------- @@ -1546,6 +2153,85 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]: except Exception: log.warning("TLS initialization failed — continuing without TLS", exc_info=True) + # Coordinator workstream plumbing. Lazy — failure here is non-fatal; + # the coordinator endpoints return 503 with a remediation message + # when coord_mgr is None, so the rest of the console still works. + app.state.coord_mgr = None + app.state.coord_registry = None + app.state.coord_registry_error = "" + if storage and config_store: + try: + from turnstone.core.model_registry import load_model_registry + + try: + coord_registry = load_model_registry(storage=storage) + app.state.coord_registry = coord_registry + except ValueError as exc: + # No model rows configured. Endpoint returns 503 with + # the error text so admin sees remediation in the UI. + app.state.coord_registry_error = str(exc) + coord_registry = None + + if coord_registry is not None: + from turnstone.console.coordinator import CoordinatorManager + from turnstone.console.coordinator_client import ( + CoordinatorClient, + CoordinatorTokenManager, + ) + from turnstone.console.coordinator_ui import ConsoleCoordinatorUI + from turnstone.console.session_factory import ( + build_console_session_factory, + ) + + jwt_secret: str = getattr(app.state, "jwt_secret", "") + console_bind_url: str = getattr(app.state, "console_url", "") or ( + "http://127.0.0.1:8001" + ) + + def _ui_factory(ws_id: str, user_id: str) -> ConsoleCoordinatorUI: + return ConsoleCoordinatorUI(ws_id=ws_id, user_id=user_id) + + def _coord_client_factory(ws_id: str, user_id: str) -> CoordinatorClient: + ttl = int(config_store.get("coordinator.session_jwt_ttl_seconds")) + tm = CoordinatorTokenManager( + user_id=user_id or "system", + scopes=frozenset({"read", "write", "approve"}), + permissions=frozenset({"admin.coordinator"}), + secret=jwt_secret, + coord_ws_id=ws_id, + ttl_seconds=ttl, + ) + + def _token_factory() -> str: + return tm.token + + return CoordinatorClient( + console_base_url=console_bind_url, + storage=storage, + token_factory=_token_factory, + coord_ws_id=ws_id, + user_id=user_id, + ) + + coord_factory = build_console_session_factory( + registry=coord_registry, + config_store=config_store, + node_id="console", + coord_client_factory=_coord_client_factory, + ) + app.state.coord_mgr = CoordinatorManager( + session_factory=coord_factory, + ui_factory=_ui_factory, + storage=storage, + max_active=int(config_store.get("coordinator.max_active")), + ) + log.info( + "console.coordinator_mgr_ready max_active=%s", + config_store.get("coordinator.max_active"), + ) + except Exception: + log.warning("console.coordinator_init_failed", exc_info=True) + yield # Shutdown if _console_heartbeat_task is not None: @@ -2491,6 +3177,10 @@ _VALID_PERMISSIONS = frozenset( "admin.settings", "admin.mcp", "admin.models", + # Coordinator workstream kind — granted per-user, NOT part of any + # builtin role. Operators opt in to let specific users run + # console-hosted coordinator sessions. + "admin.coordinator", "tools.approve", "workstreams.create", "workstreams.close", @@ -7643,6 +8333,13 @@ def create_app( Route("/api/route/command", route_proxy, methods=["POST"]), Route("/api/route/plan", route_proxy, methods=["POST"]), Route("/api/route/workstreams/close", route_proxy, methods=["POST"]), + # Coordinator-only hard delete — forwards to the server's + # path-parameter form at /v1/api/workstreams/{ws_id}/delete. + Route( + "/api/route/workstreams/delete", + route_workstream_delete, + methods=["POST"], + ), Route( "/api/route/workstreams/{ws_id}/attachments", route_attachment_proxy, @@ -7659,6 +8356,49 @@ def create_app( methods=["GET"], ), Route("/api/route", route_lookup, methods=["GET"]), + # Coordinator workstream API — console-hosted sessions. + # All require admin.coordinator permission. + Route( + "/api/coordinator/new", + coordinator_create, + methods=["POST"], + ), + Route("/api/coordinator", coordinator_list, methods=["GET"]), + Route( + "/api/coordinator/{ws_id}/send", + coordinator_send, + methods=["POST"], + ), + Route( + "/api/coordinator/{ws_id}/approve", + coordinator_approve, + methods=["POST"], + ), + Route( + "/api/coordinator/{ws_id}/cancel", + coordinator_cancel, + methods=["POST"], + ), + Route( + "/api/coordinator/{ws_id}/close", + coordinator_close, + methods=["POST"], + ), + Route( + "/api/coordinator/{ws_id}/events", + coordinator_events, + methods=["GET"], + ), + Route( + "/api/coordinator/{ws_id}/history", + coordinator_history, + methods=["GET"], + ), + Route( + "/api/coordinator/{ws_id}", + coordinator_detail, + methods=["GET"], + ), Route("/api/models", list_available_models), Route("/api/skills", list_skills_summary), Route("/api/auth/login", auth_login, methods=["POST"]), @@ -8031,6 +8771,10 @@ def create_app( Route("/docs", _docs_handler), Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"), Mount("/shared", app=StaticFiles(directory=str(_SHARED_DIR)), name="shared"), + # Coordinator one-pane UI — the route serves a single + # index.html template with the ws_id injected via data-ws-id + # so coordinator.js can pull it without an extra round-trip. + Route("/coordinator/{ws_id}", coordinator_page), # Proxy routes — serve server UI through console port Route("/node/{node_id}/", proxy_index), Route("/node/{node_id}/static/{path:path}", proxy_static), diff --git a/turnstone/console/session_factory.py b/turnstone/console/session_factory.py new file mode 100644 index 00000000..6c700c47 --- /dev/null +++ b/turnstone/console/session_factory.py @@ -0,0 +1,202 @@ +"""Console-local session factory for coordinator workstreams. + +Mirrors the server's factory closure (``turnstone/server.py`` +``session_factory``), but: + +- Always builds a ``kind="coordinator"`` ChatSession. +- Injects the shared :class:`CoordinatorClient` as the ``coord_client`` + kwarg so coordinator tool execs can dispatch through the console's + routing proxy + shared storage. +- Reads ``tools.*`` / ``judge.*`` / ``memory.*`` / ``session.*`` + settings from ``config_store.get(...)`` — same pattern the server + uses, so admin hot-reloads flow through to new coordinator sessions. + +Unlike the server factory this does not consult ``args`` (CLI argparse) — +the console doesn't carry that surface. ``coordinator.model_alias`` + +``coordinator.reasoning_effort`` come from the DB-backed settings +registry. +""" + +from __future__ import annotations + +import dataclasses +from typing import TYPE_CHECKING + +from turnstone.core.log import get_logger +from turnstone.core.session import ChatSession +from turnstone.prompts import ClientType + +if TYPE_CHECKING: + from collections.abc import Callable + + from turnstone.console.coordinator_client import CoordinatorClient + from turnstone.core.config_store import ConfigStore + from turnstone.core.model_registry import ModelRegistry + from turnstone.core.session import SessionUI + +log = get_logger(__name__) + + +def build_console_session_factory( + *, + registry: ModelRegistry, + config_store: ConfigStore, + node_id: str, + coord_client_factory: Callable[[str, str], CoordinatorClient], +) -> Callable[..., ChatSession]: + """Return a session factory that builds coordinator-kind ChatSessions. + + The factory signature matches :class:`turnstone.core.workstream._SessionFactory`. + ``coord_client_factory`` is called at session-create time with + ``(ws_id, user_id)`` and returns a prepared :class:`CoordinatorClient`. + + Only ``kind="coordinator"`` is supported here — the console doesn't + host interactive workstreams. The factory rejects any other kind + defensively so a bug upstream surfaces loudly instead of silently + constructing a malformed session. + """ + from turnstone.core.judge import JudgeConfig + from turnstone.core.memory_relevance import MemoryConfig + + def _build_judge_config() -> JudgeConfig: + return JudgeConfig( + enabled=config_store.get("judge.enabled"), + model=config_store.get("judge.model"), + confidence_threshold=config_store.get("judge.confidence_threshold"), + max_context_ratio=config_store.get("judge.max_context_ratio"), + timeout=config_store.get("judge.timeout"), + read_only_tools=config_store.get("judge.read_only_tools"), + output_guard=config_store.get("judge.output_guard"), + redact_secrets=config_store.get("judge.redact_secrets"), + ) + + def _build_memory_config() -> MemoryConfig: + return MemoryConfig( + relevance_k=config_store.get("memory.relevance_k"), + fetch_limit=config_store.get("memory.fetch_limit"), + max_content=config_store.get("memory.max_content"), + nudge_cooldown=config_store.get("memory.nudge_cooldown"), + nudges=config_store.get("memory.nudges"), + ) + + def factory( + ui: SessionUI | None, + model_alias: str | None = None, + ws_id: str | None = None, + *, + skill: str | None = None, + client_type: str = "web", + kind: str = "coordinator", + parent_ws_id: str | None = None, + ) -> ChatSession: + assert ui is not None, "console session_factory requires a non-None UI" + if kind != "coordinator": + raise ValueError( + f"console session factory only supports kind='coordinator', got {kind!r}" + ) + + # Resolve coordinator.model_alias from settings if caller didn't + # override. Empty string → raise so the caller (CoordinatorManager) + # surfaces a 503 remediation message rather than constructing a + # session that explodes on first LLM call. + effective_alias = model_alias or config_store.get("coordinator.model_alias") or "" + if not effective_alias: + raise ValueError( + "coordinator.model_alias is not configured — set it in the " + "admin Settings tab before creating coordinator sessions." + ) + + r_client, r_model, r_cfg = registry.resolve(effective_alias) + + uid = getattr(ui, "_user_id", "") or "" + _username = "" + if uid: + try: + from turnstone.core.storage._registry import get_storage as _gs + + st = _gs() + if st: + u = st.get_user(uid) + if u: + _username = u.get("username", "") + except Exception: + log.debug("coord_factory.username_resolve_failed uid=%s", uid, exc_info=True) + + live_memory_config = _build_memory_config() + live_judge_config = _build_judge_config() + if live_judge_config and live_judge_config.enabled and live_judge_config.model: + # Allow per-coordinator judge model override via registry alias + # if the admin points judge.model at an alias rather than a + # model id. Matches the server factory's shape. + try: + _jc, j_model, _jcfg = registry.resolve(live_judge_config.model) + live_judge_config = dataclasses.replace(live_judge_config, model=j_model) + except Exception: + log.debug( + "coord_factory.judge_resolve_failed model=%s", + live_judge_config.model, + exc_info=True, + ) + + eff_temperature = ( + r_cfg.temperature + if r_cfg.temperature is not None + else config_store.get("model.temperature") + ) + eff_max_tokens = ( + r_cfg.max_tokens + if r_cfg.max_tokens is not None + else config_store.get("model.max_tokens") + ) + # Coordinator has its own effort setting; fall back to model-level + # override, then global default. + eff_reasoning_effort = ( + r_cfg.reasoning_effort + if r_cfg.reasoning_effort is not None + else ( + config_store.get("coordinator.reasoning_effort") + or config_store.get("model.reasoning_effort") + ) + ) + + coord_client = coord_client_factory(ws_id or "", uid) + + return ChatSession( + client=r_client, + model=r_model, + ui=ui, + instructions=config_store.get("session.instructions") or None, + temperature=eff_temperature, + max_tokens=eff_max_tokens, + tool_timeout=config_store.get("tools.timeout"), + reasoning_effort=eff_reasoning_effort, + context_window=r_cfg.context_window, + compact_max_tokens=config_store.get("session.compact_max_tokens"), + auto_compact_pct=config_store.get("session.auto_compact_pct"), + agent_max_turns=config_store.get("tools.agent_max_turns"), + tool_truncation=config_store.get("tools.truncation"), + mcp_client=None, # console doesn't host MCP today + registry=registry, + model_alias=effective_alias, + health_registry=None, + node_id=node_id, + ws_id=ws_id, + tool_search=config_store.get("tools.search"), + tool_search_threshold=config_store.get("tools.search_threshold"), + tool_search_max_results=config_store.get("tools.search_max_results"), + web_search_backend=config_store.get("tools.web_search_backend"), + skill=skill or None, + judge_config=live_judge_config, + user_id=uid, + memory_config=live_memory_config, + config_store=config_store, + client_type=ClientType(client_type) + if client_type in {ct.value for ct in ClientType} + else ClientType.WEB, + username=_username, + kind="coordinator", + parent_ws_id=parent_ws_id, + coord_client=coord_client, + ) + + return factory diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js index ab370e61..c6add278 100644 --- a/turnstone/console/static/app.js +++ b/turnstone/console/static/app.js @@ -1,12 +1,19 @@ // --- Shared hooks --- window.onLoginSuccess = function () { connectSSE(); + // Refresh permission-gated header buttons (admin.coordinator etc). + if (typeof refreshCoordButtonVisibility === "function") { + refreshCoordButtonVisibility(); + } }; window.onLogout = function () { if (evtSource) { evtSource.close(); evtSource = null; } + if (typeof refreshCoordButtonVisibility === "function") { + refreshCoordButtonVisibility(); + } }; window.onThemeChange = function (next) { var btn = document.getElementById("theme-toggle"); @@ -1472,6 +1479,137 @@ document.addEventListener("keydown", function (e) { } }); +// --------------------------------------------------------------------------- +// New coordinator modal +// +// Header button is visibility-gated on the admin.coordinator permission. +// Modal is small: optional name / skill / initial_message, POSTs to +// /v1/api/coordinator/new, redirects to /coordinator/{ws_id} on success. +// --------------------------------------------------------------------------- + +function _hasCoordPermission() { + var perms = sessionStorage.getItem("turnstone_permissions") || ""; + return perms.split(",").indexOf("admin.coordinator") !== -1; +} + +function refreshCoordButtonVisibility() { + var btn = document.getElementById("new-coord-btn"); + if (!btn) return; + btn.style.display = _hasCoordPermission() ? "" : "none"; +} + +function showNewCoordModal() { + if (!_hasCoordPermission()) { + showToast("admin.coordinator permission required"); + return; + } + var login = document.getElementById("login-overlay"); + if (login && login.style.display !== "none") return; + var overlay = document.getElementById("new-coord-overlay"); + overlay.style.display = "flex"; + document.body.style.overflow = "hidden"; + overlay.onclick = function (e) { + if (e.target === overlay) hideNewCoordModal(); + }; + + // Reset form + document.getElementById("new-coord-name").value = ""; + document.getElementById("new-coord-task").value = ""; + var errEl = document.getElementById("new-coord-error"); + errEl.style.display = "none"; + errEl.textContent = ""; + var btn = document.getElementById("new-coord-submit"); + btn.disabled = false; + btn.textContent = "Create"; + + // Populate skill dropdown (same endpoint as new-ws modal) + var sel = document.getElementById("new-coord-skill"); + sel.innerHTML = ''; + authFetch("/v1/api/skills") + .then(function (r) { return r.json(); }) + .then(function (data) { + (data.skills || []).forEach(function (t) { + var opt = document.createElement("option"); + opt.value = t.name; + opt.textContent = t.is_default ? t.name + " (default)" : t.name; + sel.appendChild(opt); + }); + }) + .catch(function () { /* defaults still work */ }); + + setTimeout(function () { + document.getElementById("new-coord-task").focus(); + }, 50); +} + +function hideNewCoordModal() { + document.getElementById("new-coord-overlay").style.display = "none"; + document.body.style.overflow = ""; + var triggerBtn = document.getElementById("new-coord-btn"); + if (triggerBtn) triggerBtn.focus(); +} + +function submitNewCoord() { + var name = document.getElementById("new-coord-name").value.trim(); + var skill = document.getElementById("new-coord-skill").value; + var task = document.getElementById("new-coord-task").value.trim(); + var errEl = document.getElementById("new-coord-error"); + var btn = document.getElementById("new-coord-submit"); + btn.disabled = true; + btn.textContent = "Creating\u2026"; + errEl.style.display = "none"; + var body = {}; + if (name) body.name = name; + if (skill) body.skill = skill; + if (task) body.initial_message = task; + + authFetch("/v1/api/coordinator/new", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + .then(function (r) { + return r.json().then(function (data) { + return { ok: r.ok, status: r.status, data: data }; + }); + }) + .then(function (res) { + btn.disabled = false; + btn.textContent = "Create"; + if (!res.ok || !res.data || !res.data.ws_id) { + errEl.textContent = (res.data && res.data.error) || "HTTP " + res.status; + errEl.style.display = "block"; + return; + } + hideNewCoordModal(); + window.location.href = "/coordinator/" + encodeURIComponent(res.data.ws_id); + }) + .catch(function () { + btn.disabled = false; + btn.textContent = "Create"; + errEl.textContent = "Request failed"; + errEl.style.display = "block"; + }); +} + +// Escape closes the new-coord modal; Enter submits +document.addEventListener("keydown", function (e) { + var overlay = document.getElementById("new-coord-overlay"); + if (!overlay || overlay.style.display === "none") return; + if (e.key === "Escape") { + e.preventDefault(); + hideNewCoordModal(); + } + if (e.key === "Enter") { + if (e.target.tagName === "SELECT") return; + if (e.target.tagName === "BUTTON") return; + if (e.target.tagName === "TEXTAREA" && !(e.ctrlKey || e.metaKey)) return; + e.preventDefault(); + var btn = document.getElementById("new-coord-submit"); + if (btn && !btn.disabled) submitNewCoord(); + } +}); + // --- Init --- // SSE connects after auth is confirmed — either via onLoginSuccess after // login, or after the first successful data load (page refresh with valid cookie). @@ -1485,6 +1623,10 @@ function _ensureSSE() { history.replaceState({ view: "overview" }, ""); initLogin(); loadOverview(); +// Refresh the coord button visibility after initial whoami lands in +// sessionStorage (auth.js populates it asynchronously). A short delay +// is good enough — the shared pattern for permission-gated UI. +setTimeout(refreshCoordButtonVisibility, 500); // --- Node Metadata Panel (read-only in node detail view) --- function _loadNodeMetadataPanel(nodeId) { diff --git a/turnstone/console/static/coordinator/coordinator.js b/turnstone/console/static/coordinator/coordinator.js new file mode 100644 index 00000000..9b617137 --- /dev/null +++ b/turnstone/console/static/coordinator/coordinator.js @@ -0,0 +1,620 @@ +/* coordinator.js — one-pane UI for console-hosted coordinator sessions. + * + * Connects to: + * GET /v1/api/coordinator/{ws_id}/events (SSE) + * GET /v1/api/coordinator/{ws_id}/history (initial history) + * POST /v1/api/coordinator/{ws_id}/send + * POST /v1/api/coordinator/{ws_id}/approve + * POST /v1/api/coordinator/{ws_id}/cancel + * POST /v1/api/coordinator/{ws_id}/close + * + * Depends on shared/auth.js (authFetch, fetchWithCreds), shared/theme.js + * (toggleTheme), shared/toast.js (toast.info / toast.error), + * shared/utils.js (escapeHtml, linkify helpers). + * + * Assistant content follows the renderer.js pipeline from the server UI + * (ui/static/app.js): buffer the raw markdown as tokens stream in, keep + * the visible text plain during streaming, and swap to the rendered + * innerHTML + postRenderMarkdown() once the stream ends. Reasoning + * bubbles stay text-only because they're transient and styled dim/italic. + * See turnstone/ui/static/app.js (search for renderMarkdown) for the + * canonical pattern we mirror. + */ +(function () { + "use strict"; + + const wsId = document.documentElement.dataset.wsId || ""; + if (!wsId) { + document.getElementById("coord-messages").innerHTML = + '
Missing ws_id on <html> tag.
'; + return; + } + + const messagesEl = document.getElementById("coord-messages"); + const inputEl = document.getElementById("coord-input"); + const sendBtn = document.getElementById("coord-send-btn"); + const statusEl = document.getElementById("coord-status"); + const sseEl = document.getElementById("coord-sse-status"); + const nameEl = document.getElementById("coord-name"); + const approvalBar = document.getElementById("coord-approval-bar"); + const approvalTools = document.getElementById("coord-approval-tools"); + const cancelBtn = document.getElementById("coord-cancel-btn"); + + let pendingApprovalCallId = null; + let evtSource = null; + let reconnectAttempts = 0; + let reconnectTimer = null; + + // ------------------------------------------------------------------ + // HTML escaping and safe ws_id linkification + // ------------------------------------------------------------------ + + function esc(s) { + // shared/utils.js exposes the lowercase-h name; check that first. + if (typeof escapeHtml === "function") + return escapeHtml(String(s == null ? "" : s)); + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + // Post-process tool-output JSON: wrap known ws_id + node_id pairs in a + // link pointing at /node/{node_id}/?ws_id={child_ws_id}. Only applies + // when BOTH keys are present and look like valid hex ids. + const WS_ID_RE = /^[a-f0-9]{8,64}$/i; + const NODE_ID_RE = /^[A-Za-z0-9._-]{1,256}$/; + + function renderToolOutput(rawText) { + // Try parse JSON first — coordinator tool output is JSON-shaped. + let parsed = null; + try { + parsed = JSON.parse(rawText); + } catch (_) { + /* fall through */ + } + if (!parsed || typeof parsed !== "object") { + return esc(rawText); + } + // Normalize to an array of rows we can linkify. + let rows = []; + if (Array.isArray(parsed.children)) { + rows = parsed.children; + } else if (parsed.ws_id && parsed.node_id) { + rows = [parsed]; + } + if (rows.length === 0) { + return "
" + esc(JSON.stringify(parsed, null, 2)) + "
"; + } + const lines = rows.map((row) => { + const safeWs = row.ws_id && WS_ID_RE.test(row.ws_id) ? row.ws_id : null; + const safeNode = + row.node_id && NODE_ID_RE.test(row.node_id) ? row.node_id : null; + let link = safeWs || ""; + if (safeWs && safeNode) { + link = + '' + + esc(safeWs) + + ""; + } + const meta = []; + if (row.state) meta.push("state=" + esc(row.state)); + if (row.name) meta.push("name=" + esc(row.name)); + if (row.node_id) meta.push("node=" + esc(row.node_id)); + return ( + " " + (link || esc("?")) + (meta.length ? " " + meta.join(" ") : "") + ); + }); + return "
" + lines.join("\n") + "
"; + } + + // ------------------------------------------------------------------ + // Message append helpers + // ------------------------------------------------------------------ + + function appendMsg(role, html, opts) { + opts = opts || {}; + const el = document.createElement("div"); + el.className = "coord-msg role-" + role; + if (opts.callId) el.dataset.callId = opts.callId; + if (opts.label) { + const labelEl = document.createElement("div"); + labelEl.className = "role-label"; + labelEl.textContent = opts.label; + el.appendChild(labelEl); + } + const body = document.createElement("div"); + body.className = "coord-body"; + body.innerHTML = html; + el.appendChild(body); + messagesEl.appendChild(el); + messagesEl.scrollTop = messagesEl.scrollHeight; + return el; + } + + function appendText(role, text, opts) { + return appendMsg(role, esc(text), opts); + } + + function appendToolCall(item) { + const label = item.func_name || "tool"; + const html = + "" + + esc(item.header || label) + + "" + + (item.preview ? "
" + esc(item.preview) + "
" : ""); + return appendMsg("tool", html, { label: label, callId: item.call_id }); + } + + function appendToolResult(name, callId, output, isError) { + const html = renderToolOutput(output); + const el = appendMsg(isError ? "error" : "tool", html, { + label: (isError ? "error · " : "") + (name || "tool"), + callId: callId, + }); + return el; + } + + // ------------------------------------------------------------------ + // Content streaming + // ------------------------------------------------------------------ + + let currentAssistantEl = null; + let currentAssistantBuf = ""; + let currentReasoningEl = null; + let currentReasoningBuf = ""; + + function appendContentToken(text) { + if (!currentAssistantEl) { + currentAssistantEl = appendMsg("assistant", "", { label: "assistant" }); + currentAssistantBuf = ""; + // Mute the live region while tokens stream in so screen readers + // don't re-announce the full buffer on every delta. Restored on + // stream_end. + messagesEl.setAttribute("aria-live", "off"); + } + currentAssistantBuf += text; + // During streaming we keep the visible text as textContent (plain + // string, not rendered markdown). Re-running renderMarkdown on every + // token would be expensive and also produces half-formed output for + // partial code fences / lists. The raw buffer is stashed on the + // element so finishAssistantStream() can do one final render pass. + // Mirrors ui/static/app.js's `this.contentBuffer` approach — we just + // keep the buffer in a closure variable instead of on `this`. + const body = currentAssistantEl.querySelector(".coord-body"); + if (body) { + body.textContent = currentAssistantBuf; + body._rawMarkdown = currentAssistantBuf; + } + messagesEl.scrollTop = messagesEl.scrollHeight; + } + + function appendReasoningToken(text) { + // Reasoning tokens arrive ahead of assistant content when the + // coordinator model has reasoning_effort > none. Rendering them in + // a dimmed "role-reasoning" bubble avoids the "UI is hung" impression + // of a silent delay. A separate element means reasoning and content + // don't mix in the main assistant buffer. + if (!currentReasoningEl) { + currentReasoningEl = appendMsg("reasoning", "", { label: "reasoning" }); + currentReasoningBuf = ""; + messagesEl.setAttribute("aria-live", "off"); + } + currentReasoningBuf += text; + const body = currentReasoningEl.querySelector(".coord-body"); + if (body) body.textContent = currentReasoningBuf; + messagesEl.scrollTop = messagesEl.scrollHeight; + } + + function finishAssistantStream() { + // Promote the streamed plaintext buffer to rendered markdown (code + // fences, lists, KaTeX, mermaid, syntax highlighting). renderMarkdown + // escapes HTML internally so innerHTML here is XSS-safe as long as + // renderer.js is trusted — same contract as ui/static/app.js. Guard + // the call in a try/catch so a renderer bug can never brick the + // coordinator UI; on failure the visible textContent stays put. + if (currentAssistantEl && currentAssistantBuf) { + const body = currentAssistantEl.querySelector(".coord-body"); + if (body && typeof renderMarkdown === "function") { + try { + body.innerHTML = renderMarkdown(currentAssistantBuf); + if (typeof postRenderMarkdown === "function") { + postRenderMarkdown(body); + } + } catch (e) { + console.warn("coordinator renderMarkdown failed", e); + } + } + } + currentAssistantEl = null; + currentAssistantBuf = ""; + currentReasoningEl = null; + currentReasoningBuf = ""; + messagesEl.setAttribute("aria-live", "polite"); + } + + // ------------------------------------------------------------------ + // Approval UI + // ------------------------------------------------------------------ + + function showApproval(items) { + approvalTools.replaceChildren(); + const pending = (items || []).filter((it) => it.needs_approval); + // Header row — "Approve N tool calls" clarifies that the batch + // approval applies to every row, not just the focused one. + if (pending.length > 0) { + const header = document.createElement("div"); + header.className = "tool-row approval-header"; + header.textContent = + pending.length === 1 + ? "Approve 1 tool call:" + : "Approve " + pending.length + " tool calls (batch):"; + approvalTools.appendChild(header); + } + let firstCallId = null; + pending.forEach((it, idx) => { + if (!firstCallId) firstCallId = it.call_id; + const row = document.createElement("div"); + row.className = "tool-row"; + const label = + it.header || it.approval_label || it.func_name || "(unknown tool)"; + row.textContent = pending.length > 1 ? idx + 1 + ". " + label : label; + approvalTools.appendChild(row); + }); + pendingApprovalCallId = firstCallId; + approvalBar.classList.add("visible"); + // Move focus to the approve button — non-modal region, so keyboard + // users don't have to Shift+Tab back from the composer. One-time + // focus shift; subsequent Tab/Shift+Tab navigates normally. + const approveBtn = document.getElementById("coord-approve-btn"); + if (approveBtn) { + try { + approveBtn.focus({ preventScroll: false }); + } catch (_) { + /* noop */ + } + } + } + + function hideApproval() { + approvalBar.classList.remove("visible"); + approvalTools.replaceChildren(); + pendingApprovalCallId = null; + setApprovalButtonsDisabled(false); + // Return focus to the composer for keyboard users. Only if the + // approval bar itself was the focus holder — don't steal focus from + // e.g. a user who clicked into the history log. + if ( + document.activeElement && + approvalBar.contains(document.activeElement) + ) { + try { + inputEl.focus(); + } catch (_) { + /* noop */ + } + } + } + + function setApprovalButtonsDisabled(disabled) { + // Disable all three approval buttons during an in-flight POST to + // prevent double-submit via double-click / Enter-hold. The bar + // either dismisses on success or re-enables on error. + ["coord-approve-btn", "coord-approve-always-btn", "coord-deny-btn"].forEach( + (id) => { + const btn = document.getElementById(id); + if (btn) btn.disabled = !!disabled; + }, + ); + } + + window.coordApprove = async function (approved, always) { + if (!pendingApprovalCallId) return; // no-op if bar already resolved + const body = { + approved: !!approved, + always: !!always, + call_id: pendingApprovalCallId, + }; + setApprovalButtonsDisabled(true); + try { + const resp = await postJSON( + "/v1/api/coordinator/" + encodeURIComponent(wsId) + "/approve", + body, + ); + if (!resp.ok) throw new Error("approve failed: HTTP " + resp.status); + } catch (e) { + setApprovalButtonsDisabled(false); + if (typeof toast !== "undefined" && toast.error) toast.error(String(e)); + else console.error(e); + return; + } + hideApproval(); + }; + + // ------------------------------------------------------------------ + // Send / cancel / close + // ------------------------------------------------------------------ + + window.coordSend = function (evt) { + if (evt && evt.preventDefault) evt.preventDefault(); + const msg = (inputEl.value || "").trim(); + if (!msg) return false; + inputEl.value = ""; + sendBtn.disabled = true; + postJSON("/v1/api/coordinator/" + encodeURIComponent(wsId) + "/send", { + message: msg, + }) + .then((resp) => { + if (!resp.ok) { + return resp.text().then((txt) => { + throw new Error("send failed: " + resp.status + " " + txt); + }); + } + appendText("user", msg, { label: "you" }); + }) + .catch((e) => { + if (typeof toast !== "undefined" && toast.error) toast.error(String(e)); + else console.error(e); + }) + .finally(() => { + sendBtn.disabled = false; + }); + return false; + }; + + window.coordCancel = function () { + postJSON( + "/v1/api/coordinator/" + encodeURIComponent(wsId) + "/cancel", + {}, + ).catch((e) => console.error(e)); + }; + + window.coordCloseSession = async function () { + if (!window.confirm("Close this coordinator session?")) return; + try { + const resp = await postJSON( + "/v1/api/coordinator/" + encodeURIComponent(wsId) + "/close", + {}, + ); + if (!resp.ok) throw new Error("close failed: HTTP " + resp.status); + window.location.href = "/"; + } catch (e) { + if (typeof toast !== "undefined" && toast.error) toast.error(String(e)); + else console.error(e); + } + }; + + // ------------------------------------------------------------------ + // HTTP helpers + // ------------------------------------------------------------------ + + function postJSON(url, body) { + const fn = typeof authFetch === "function" ? authFetch : fetch; + return fn(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body || {}), + }); + } + + function getJSON(url) { + const fn = typeof authFetch === "function" ? authFetch : fetch; + return fn(url, { credentials: "include" }).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + + // ------------------------------------------------------------------ + // SSE connection with reconnect + // ------------------------------------------------------------------ + + function setSseStatus(text, cls) { + // Prepend a leading glyph so the state isn't conveyed by colour + // alone (WCAG 1.4.1). ● connected, ○ connecting, ⚠ disconnected. + const glyph = cls === "ok" ? "● " : cls === "err" ? "⚠ " : "○ "; + sseEl.textContent = glyph + text; + sseEl.className = cls || ""; + } + + function connectSSE() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (evtSource) { + try { + evtSource.close(); + } catch (_) { + /* noop */ + } + } + setSseStatus("connecting…", ""); + const url = "/v1/api/coordinator/" + encodeURIComponent(wsId) + "/events"; + evtSource = new EventSource(url, { withCredentials: true }); + evtSource.onopen = function () { + reconnectAttempts = 0; + setSseStatus("live", "ok"); + }; + evtSource.onerror = function () { + setSseStatus("disconnected", "err"); + try { + evtSource.close(); + } catch (_) { + /* noop */ + } + scheduleReconnect(); + }; + evtSource.onmessage = function (event) { + let data = null; + try { + data = JSON.parse(event.data); + } catch (_) { + return; + } + handleEvent(data); + }; + } + + function scheduleReconnect() { + const base = Math.min(30000, 1000 * Math.pow(2, reconnectAttempts)); + const jitter = Math.floor(Math.random() * 500); + reconnectAttempts += 1; + reconnectTimer = setTimeout(connectSSE, base + jitter); + } + + // ------------------------------------------------------------------ + // SSE event router + // ------------------------------------------------------------------ + + function handleEvent(ev) { + switch (ev.type) { + case "content": + appendContentToken(ev.text || ""); + break; + case "reasoning": + appendReasoningToken(ev.text || ""); + break; + case "stream_end": + finishAssistantStream(); + break; + case "tool_result": + appendToolResult( + ev.name || "tool", + ev.call_id || "", + ev.output || "", + !!ev.is_error, + ); + break; + case "approve_request": + showApproval(ev.items); + // Surface each tool for context. Dedupe by call_id: the console + // replays _pending_approval into every new SSE subscriber (see + // coordinator_events handler), so without this check an SSE + // reconnect would render each tool row a second time. + (ev.items || []).forEach((it) => { + if (!it.needs_approval) return; + if ( + it.call_id && + document.querySelector( + '.coord-msg[data-call-id="' + CSS.escape(it.call_id) + '"]', + ) + ) { + return; // already rendered in this pane — skip + } + appendToolCall(it); + }); + break; + case "approval_resolved": + hideApproval(); + break; + case "intent_verdict": + // Minimal surfacing — risk_level + recommendation. + appendText( + "tool", + "[judge] " + + (ev.recommendation || "?") + + " (risk=" + + (ev.risk_level || "?") + + ")", + { label: "judge" }, + ); + break; + case "output_warning": + appendText( + "error", + "[output guard] " + + (ev.risk_level || "?") + + ": " + + (ev.flags || []).join(","), + { label: "warning" }, + ); + break; + case "error": + appendText("error", ev.message || "(unknown error)", { + label: "error", + }); + break; + case "info": + appendText("tool", ev.message || "", { label: "info" }); + break; + case "state_change": + statusEl.textContent = ev.state || ""; + cancelBtn.style.display = + ev.state === "running" || ev.state === "thinking" ? "" : "none"; + break; + case "rename": + nameEl.textContent = ev.name || ""; + break; + case "tools_auto_approved": + (ev.items || []).forEach((it) => appendToolCall(it)); + break; + default: + // Unknown event type — ignore silently. + break; + } + } + + // ------------------------------------------------------------------ + // Initial load — history then SSE + // ------------------------------------------------------------------ + + async function init() { + try { + const data = await getJSON( + "/v1/api/coordinator/" + encodeURIComponent(wsId), + ); + nameEl.textContent = data.name || ""; + statusEl.textContent = data.state || ""; + } catch (e) { + appendText("error", "Failed to load coordinator: " + e.message); + return; + } + try { + const hist = await getJSON( + "/v1/api/coordinator/" + encodeURIComponent(wsId) + "/history", + ); + (hist.messages || []).forEach((m) => { + const role = m.role || "tool"; + const content = + typeof m.content === "string" + ? m.content + : JSON.stringify(m.content || ""); + if (!content) return; + if (role === "tool") { + appendToolResult( + m.tool_name || "tool", + m.tool_call_id || "", + content, + false, + ); + } else { + appendText(role, content, { label: role }); + } + }); + } catch (e) { + console.warn("history load failed", e); + } + connectSSE(); + } + + init(); + + // Plain Enter submits; Shift+Enter inserts a newline. IME composition + // (isComposing) is respected so users typing in CJK/other IMEs aren't + // cut off mid-word. + inputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { + e.preventDefault(); + coordSend(e); + } + }); +})(); diff --git a/turnstone/console/static/coordinator/index.html b/turnstone/console/static/coordinator/index.html new file mode 100644 index 00000000..28b68ff0 --- /dev/null +++ b/turnstone/console/static/coordinator/index.html @@ -0,0 +1,205 @@ + + + + + +coordinator · turnstone + + + + + + + + + + +
+

turnstone coordinator

+ + + + connecting… + + + +
+ +
+ +
+ + +
+
Approval required
+
+
+ + + +
+
+ +
+ + +
+
+ + + + + + + + + + + + + diff --git a/turnstone/console/static/coordinator/renderer.js b/turnstone/console/static/coordinator/renderer.js new file mode 100644 index 00000000..b63e4d1f --- /dev/null +++ b/turnstone/console/static/coordinator/renderer.js @@ -0,0 +1,872 @@ +// renderer.js — Markdown + LaTeX rendering (no external deps except KaTeX) +// +// Mirrored from turnstone/ui/static/renderer.js so the console-hosted +// coordinator page can reuse the server UI's markdown / KaTeX / hljs / +// mermaid pipeline without the console depending on the ui static mount. +// If you edit one copy, edit the other — a future cleanup could promote +// this file into shared_static/ and drop the duplicate. + +// --------------------------------------------------------------------------- +// Inline formatting +// --------------------------------------------------------------------------- +// Safe inline HTML tags allowed through escapeHtml (no attributes — XSS safe) +// Block-level tags (details, summary, hr) handled by their own protection passes +var _SAFE_TAGS = + /<(\/?(?:br|kbd|mark|sub|sup|ins|wbr|abbr|small|u|s))(?:\s*\/?)>/gi; + +function inlineMarkdown(text) { + // Escape HTML first so only tags we generate are real + text = escapeHtml(text); + // Restore safe HTML tags (attribute-free only — already escaped so no XSS) + text = text.replace(_SAFE_TAGS, function (m, tag) { + return "<" + tag + ">"; + }); + // Bold (asterisks only — underscores cause false positives on snake_case) + text = text.replace(/\*\*(.+?)\*\*/g, "$1"); + // Italic (asterisks only) + text = text.replace( + /(?$1", + ); + // Strikethrough + text = text.replace(/~~(.+?)~~/g, "$1"); + // Images (must come before links — render as click-to-load placeholder) + text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (m, alt, url) { + if (!/^\s*(https?:\/\/|data:image\/)/i.test(url)) return m; + var safeAlt = alt || "Image"; + var domain = ""; + try { + domain = escapeHtml(new URL(url).hostname); + } catch (e) { + domain = url.length > 40 ? url.slice(0, 40) + "…" : url; + } + return ( + '' + + '🖼 ' + + '' + + safeAlt + + "" + + '' + + domain + + "" + + "" + ); + }); + // Links (allow http, https, and same-origin relative URLs only) + text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (m, label, url) { + if (!/^\s*(https?:\/\/|\/(?!\/))/i.test(url)) return m; + return ( + '' + + label + + "" + ); + }); + // Footnote references [^id] — after links (link regex requires (url), so no conflict) + text = text.replace(/\[\^([^\]]+)\]/g, function (m, fnId) { + var safeFnId = escapeHtml(fnId); + return ( + '' + + '[' + + safeFnId + + "]" + ); + }); + return text; +} + +// Attach click-to-load listener for image placeholders (delegated) +document.addEventListener("click", function (e) { + var ph = e.target.closest(".img-placeholder"); + if (!ph) return; + var raw = ph.getAttribute("data-src") || ""; + if (!/^(https?:\/\/|data:image\/)/i.test(raw)) return; + var src; + try { + src = new URL(raw).href; + } catch (_e) { + return; + } + var img = document.createElement("img"); + img.src = src; + img.alt = ph.getAttribute("data-alt"); + img.loading = "lazy"; + ph.replaceWith(img); +}); +document.addEventListener("keydown", function (e) { + if (e.key !== "Enter") return; + var ph = e.target.closest(".img-placeholder"); + if (!ph) return; + ph.click(); +}); + +// Smooth-scroll footnote navigation (native fragment jumps don't work in scrollable containers) +document.addEventListener("click", function (e) { + var a = e.target.closest(".fn-ref a, .fn-backref"); + if (!a) return; + var href = a.getAttribute("href"); + if (!href || href[0] !== "#") return; + var target = document.getElementById(href.slice(1)); + if (!target) return; + e.preventDefault(); + target.scrollIntoView({ behavior: "smooth", block: "center" }); +}); + +// --------------------------------------------------------------------------- +// List rendering (nested + task lists) +// --------------------------------------------------------------------------- +function renderListBlock(items) { + if (items.length === 0) return ""; + var minIndent = items[0].indent; + for (var i = 1; i < items.length; i++) { + if (items[i].indent < minIndent) minIndent = items[i].indent; + } + // Split into separate lists when marker type changes at top indent level + var segments = []; + var cur = [items[0]]; + for (var i = 1; i < items.length; i++) { + if (items[i].indent <= minIndent && items[i].ordered !== cur[0].ordered) { + segments.push(cur); + cur = [items[i]]; + } else { + cur.push(items[i]); + } + } + segments.push(cur); + if (segments.length > 1) { + return segments.map(renderListBlock).join("\n"); + } + var type = items[0].ordered ? "ol" : "ul"; + var html = "<" + type + ">"; + var i = 0; + while (i < items.length) { + var item = items[i]; + if (item.indent <= minIndent) { + var content = item.content; + // Task list checkboxes + var taskMatch = content.match(/^\[([ xX])\]\s*(.*)/); + if (taskMatch) { + var checked = taskMatch[1] !== " "; + content = + ' ' + + inlineMarkdown(taskMatch[2]); + } else { + content = inlineMarkdown(content); + } + // Collect children (deeper indent items following this one) + var children = []; + var j = i + 1; + while (j < items.length && items[j].indent > minIndent) { + children.push(items[j]); + j++; + } + if (children.length > 0) { + html += "
  • " + content + renderListBlock(children) + "
  • "; + } else { + html += "
  • " + content + "
  • "; + } + i = j; + } else { + i++; + } + } + html += ""; + return html; +} + +// --------------------------------------------------------------------------- +// LaTeX rendering via KaTeX +// --------------------------------------------------------------------------- +function renderLatex(tex, displayMode) { + if (typeof katex === "undefined") return escapeHtml(tex); + try { + return katex.renderToString(tex, { + displayMode: displayMode, + throwOnError: false, + errorColor: "#f87171", + output: "html", + }); + } catch (e) { + return '' + escapeHtml(tex) + ""; + } +} + +// --------------------------------------------------------------------------- +// Footnote scope — prevents ID collisions across multiple renderMarkdown calls +// --------------------------------------------------------------------------- +var _fnScopeId = 0; +var _fnDepth = 0; + +// --------------------------------------------------------------------------- +// GFM callout types (alerts) +// --------------------------------------------------------------------------- +var CALLOUT_TYPES = { + NOTE: { icon: "\u2139", label: "Note" }, + TIP: { icon: "\u{1F4A1}", label: "Tip" }, + IMPORTANT: { icon: "\u2757", label: "Important" }, + WARNING: { icon: "\u26A0", label: "Warning" }, + CAUTION: { icon: "\u{1F6D1}", label: "Caution" }, +}; + +// --------------------------------------------------------------------------- +// Code fence language → CSS class normalization +// --------------------------------------------------------------------------- +var _LANG_ALIASES = { "c++": "cpp", "c#": "csharp", "f#": "fsharp" }; + +function _langToCssClass(lang) { + if (!lang) return ""; + var lower = lang.toLowerCase(); + if (_LANG_ALIASES[lower]) return _LANG_ALIASES[lower]; + // Strip chars invalid in CSS class names (keep alphanumeric + hyphen) + return lower.replace(/[^a-z0-9-]/g, ""); +} + +// --------------------------------------------------------------------------- +// Main markdown renderer +// --------------------------------------------------------------------------- +function renderMarkdown(text) { + // Scope footnote IDs per top-level render call (prevents collisions across messages) + if (_fnDepth === 0) _fnScopeId++; + _fnDepth++; + + // Pre-pass: extract blockquote blocks and recursively render. + // Must run FIRST (before code/math protection) so the recursive call + // processes raw markdown, not text with outer-scope placeholders. + var bqBlocks = []; + (function () { + var blines = text.split("\n"); + var result = []; + var i = 0; + while (i < blines.length) { + if (blines[i].startsWith("> ") || blines[i] === ">") { + var inner = []; + while ( + i < blines.length && + (blines[i].startsWith("> ") || blines[i] === ">") + ) { + inner.push(blines[i] === ">" ? "" : blines[i].slice(2)); + i++; + } + // Check for GFM alert/callout syntax: > [!NOTE], > [!TIP], etc. + var alertMatch = + inner.length > 0 && + inner[0].match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$/i); + if (alertMatch) { + var alertType = alertMatch[1].toUpperCase(); + var info = CALLOUT_TYPES[alertType]; + var bodyLines = inner.slice(1); + if (bodyLines.length > 0 && bodyLines[0].trim() === "") { + bodyLines = bodyLines.slice(1); + } + var bodyHtml = + bodyLines.length > 0 ? renderMarkdown(bodyLines.join("\n")) : ""; + bqBlocks.push( + '
    ' + + '
    ' + + ' " + + '' + + info.label + + "" + + "
    " + + (bodyHtml + ? '
    ' + bodyHtml + "
    " + : "") + + "
    ", + ); + } else { + bqBlocks.push( + "
    " + renderMarkdown(inner.join("\n")) + "
    ", + ); + } + result.push("\x00BQ" + (bqBlocks.length - 1) + "\x00"); + } else { + result.push(blines[i]); + i++; + } + } + text = result.join("\n"); + })(); + + // Protect code blocks + var codeBlocks = []; + text = text.replace(/```([^\s`]*)\n([\s\S]*?)```/g, function (m, lang, code) { + var cssLang = _langToCssClass(lang); + codeBlocks.push( + "
    " +
    +        escapeHtml(code.replace(/\n$/, "")) +
    +        "
    ", + ); + return "\x00CB" + (codeBlocks.length - 1) + "\x00"; + }); + + // Protect
    blocks (safe HTML — attribute-free only) + var detailsBlocks = []; + text = text.replace( + /
    \s*\n?([\s\S]*?)<\/details>/gi, + function (m, inner) { + var sumMatch = inner.match( + /^\s*([\s\S]*?)<\/summary>\s*\n?([\s\S]*)/i, + ); + var html; + if (sumMatch) { + html = + "
    " + + inlineMarkdown(sumMatch[1].trim()) + + "" + + renderMarkdown(sumMatch[2]) + + "
    "; + } else { + html = "
    " + renderMarkdown(inner) + "
    "; + } + detailsBlocks.push(html); + return "\x00DT" + (detailsBlocks.length - 1) + "\x00"; + }, + ); + + // Protect display math ($$...$$) — must come before inline code/math + var mathBlocks = []; + text = text.replace(/\$\$([\s\S]+?)\$\$/g, function (m, tex) { + mathBlocks.push(renderLatex(tex.trim(), true)); + return "\x00MB" + (mathBlocks.length - 1) + "\x00"; + }); + + // Protect inline code + var inlineCodes = []; + text = text.replace(/`([^`\n]+)`/g, function (m, code) { + inlineCodes.push("" + escapeHtml(code) + ""); + return "\x00IC" + (inlineCodes.length - 1) + "\x00"; + }); + + // Protect inline math ($...$) — after inline code so `$x$` in code is safe + var inlineMaths = []; + text = text.replace(/\$([^\$\n]+?)\$/g, function (m, tex) { + inlineMaths.push(renderLatex(tex, false)); + return "\x00IM" + (inlineMaths.length - 1) + "\x00"; + }); + + // Protect markdown tables (extract before line-by-line processing) + var tableBlocks = []; + (function () { + var tlines = text.split("\n"); + var sepRe = /^\|?(\s*:?-{1,}:?\s*\|)+\s*:?-{1,}:?\s*\|?\s*$/; + var result = []; + var i = 0; + while (i < tlines.length) { + if ( + i + 1 < tlines.length && + tlines[i].includes("|") && + sepRe.test(tlines[i + 1]) + ) { + var headerLine = tlines[i]; + var sepLine = tlines[i + 1]; + var sepCells = sepLine + .replace(/^\|/, "") + .replace(/\|?\s*$/, "") + .split("|"); + var aligns = sepCells.map(function (c) { + c = c.trim(); + if (c.startsWith(":") && c.endsWith(":")) return "center"; + if (c.endsWith(":")) return "right"; + return "left"; + }); + var hdrCells = headerLine + .replace(/^\|/, "") + .replace(/\|?\s*$/, "") + .split("|") + .map(function (c) { + return c.trim(); + }); + var dataRows = []; + var j = i + 2; + while ( + j < tlines.length && + tlines[j].includes("|") && + tlines[j].trim() !== "" + ) { + var row = tlines[j] + .replace(/^\|/, "") + .replace(/\|?\s*$/, "") + .split("|") + .map(function (c) { + return c.trim(); + }); + dataRows.push(row); + j++; + } + var html = + '
    '; + html += ""; + for (var k = 0; k < hdrCells.length; k++) { + var align = aligns[k] || "left"; + html += + '"; + } + html += ""; + for (var r = 0; r < dataRows.length; r++) { + html += ""; + for (var k = 0; k < hdrCells.length; k++) { + var align = aligns[k] || "left"; + var cell = dataRows[r][k] || ""; + html += + '"; + } + html += ""; + } + html += "
    ' + + inlineMarkdown(hdrCells[k]) + + "
    ' + + inlineMarkdown(cell) + + "
    "; + tableBlocks.push(html); + result.push("\x00TB" + (tableBlocks.length - 1) + "\x00"); + i = j; + } else { + result.push(tlines[i]); + i++; + } + } + text = result.join("\n"); + })(); + + // Collect footnote definitions ([^id]: content) + var footnoteDefs = {}; + (function () { + var flines = text.split("\n"); + var result = []; + var i = 0; + while (i < flines.length) { + var fnm = flines[i].match(/^\[\^([^\]]+)\]:\s*(.*)/); + if (fnm) { + var fnId = fnm[1]; + var fnContent = fnm[2]; + // Collect continuation lines (indented by 2+ spaces) + var j = i + 1; + while (j < flines.length && /^ {2}/.test(flines[j])) { + fnContent += "\n" + flines[j].slice(2); + j++; + } + footnoteDefs[fnId] = fnContent; + i = j; + } else { + result.push(flines[i]); + i++; + } + } + text = result.join("\n"); + })(); + + // Process block-level elements per line + var lines = text.split("\n"); + var out = []; + + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + + // Horizontal rule + if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) { + out.push("
    "); + continue; + } + + // Headers + var hm = line.match(/^(#{1,6})\s+(.+)/); + if (hm) { + var level = hm[1].length; + out.push( + "" + inlineMarkdown(hm[2]) + "", + ); + continue; + } + + // Lists — collect consecutive list lines, then render with nesting + var ulm = line.match(/^(\s*)[-*+]\s+(.*)/); + var olm = !ulm ? line.match(/^(\s*)\d+[.)]\s+(.*)/) : null; + if (ulm || olm) { + var listItems = []; + while (i < lines.length) { + var um = lines[i].match(/^(\s*)[-*+]\s+(.*)/); + var om = !um ? lines[i].match(/^(\s*)\d+[.)]\s+(.*)/) : null; + if (um || om) { + var lm = um || om; + listItems.push({ + indent: lm[1].length, + ordered: !!om, + content: lm[2], + }); + i++; + } else { + break; + } + } + i--; // for-loop will increment + out.push(renderListBlock(listItems)); + continue; + } + + // Definition list — term followed by `: definition` lines + if ( + line.trim() !== "" && + i + 1 < lines.length && + /^:\s+/.test(lines[i + 1]) + ) { + var dlItems = []; + while (i < lines.length) { + if (lines[i].trim() === "" || /^:\s+/.test(lines[i])) break; + var dlTerm = lines[i]; + var dlDefs = []; + i++; + while (i < lines.length && /^:\s+/.test(lines[i])) { + dlDefs.push(lines[i].match(/^:\s+(.*)/)[1]); + i++; + } + if (dlDefs.length > 0) { + dlItems.push({ term: dlTerm, defs: dlDefs }); + } else { + break; + } + // Skip blank lines between entries + while (i < lines.length && lines[i].trim() === "") i++; + // Check if another term+definition follows + if ( + i < lines.length && + lines[i].trim() !== "" && + !/^:\s+/.test(lines[i]) && + i + 1 < lines.length && + /^:\s+/.test(lines[i + 1]) + ) { + continue; + } + break; + } + if (dlItems.length > 0) { + var dlHtml = "
    "; + for (var di = 0; di < dlItems.length; di++) { + dlHtml += "
    " + inlineMarkdown(dlItems[di].term) + "
    "; + for (var dd = 0; dd < dlItems[di].defs.length; dd++) { + dlHtml += "
    " + inlineMarkdown(dlItems[di].defs[dd]) + "
    "; + } + } + dlHtml += "
    "; + out.push(dlHtml); + i--; // for-loop will increment + continue; + } + } + + // Paragraph / plain text + if (line.trim() === "") { + out.push(""); + } else { + out.push("

    " + inlineMarkdown(line) + "

    "); + } + } + + var result = out.join("\n"); + + // Append footnote section if any definitions were collected + var fnKeys = Object.keys(footnoteDefs); + if (fnKeys.length > 0) { + var fnHtml = + '
    ' + + '
      '; + for (var fi = 0; fi < fnKeys.length; fi++) { + var fid = fnKeys[fi]; + var safeFid = escapeHtml(fid); + fnHtml += + '
    1. ' + + renderMarkdown(footnoteDefs[fid]) + + ' \u21A9
    2. '; + } + fnHtml += "
    "; + result += fnHtml; + } + + // Restore protected blocks + result = result.replace(/\x00CB(\d+)\x00/g, function (m, idx) { + return codeBlocks[parseInt(idx)]; + }); + result = result.replace(/

    \x00DT(\d+)\x00<\/p>/g, function (m, idx) { + return detailsBlocks[parseInt(idx)]; + }); + result = result.replace(/\x00DT(\d+)\x00/g, function (m, idx) { + return detailsBlocks[parseInt(idx)]; + }); + result = result.replace(/

    \x00BQ(\d+)\x00<\/p>/g, function (m, idx) { + return bqBlocks[parseInt(idx)]; + }); + result = result.replace(/\x00BQ(\d+)\x00/g, function (m, idx) { + return bqBlocks[parseInt(idx)]; + }); + result = result.replace(/

    \x00MB(\d+)\x00<\/p>/g, function (m, idx) { + return mathBlocks[parseInt(idx)]; + }); + result = result.replace(/\x00MB(\d+)\x00/g, function (m, idx) { + return mathBlocks[parseInt(idx)]; + }); + result = result.replace(/

    \x00TB(\d+)\x00<\/p>/g, function (m, idx) { + return tableBlocks[parseInt(idx)]; + }); + result = result.replace(/\x00TB(\d+)\x00/g, function (m, idx) { + return tableBlocks[parseInt(idx)]; + }); + result = result.replace(/\x00IC(\d+)\x00/g, function (m, idx) { + return inlineCodes[parseInt(idx)]; + }); + result = result.replace(/\x00IM(\d+)\x00/g, function (m, idx) { + return inlineMaths[parseInt(idx)]; + }); + + _fnDepth--; + return result; +} + +// --------------------------------------------------------------------------- +// Post-render hook — syntax highlighting via highlight.js +// --------------------------------------------------------------------------- +var _NO_HIGHLIGHT_LANGS = { + ascii: true, + text: true, + plaintext: true, + plain: true, + nohighlight: true, + mermaid: true, + plantuml: true, +}; +var _TERMINAL_LANGS = { + bash: true, + shell: true, + sh: true, + zsh: true, + console: true, + terminal: true, +}; +var _hljsConfigured = false; + +function postRenderMarkdown(containerEl) { + // Syntax highlighting (skip if highlight.js unavailable) + if (typeof hljs !== "undefined") { + if (!_hljsConfigured) { + hljs.configure({ ignoreUnescapedHTML: true }); + _hljsConfigured = true; + } + var codeEls = containerEl.querySelectorAll("pre code[class*='language-']"); + for (var i = 0; i < codeEls.length; i++) { + var el = codeEls[i]; + if (el.classList.contains("hljs")) continue; + // Extract language name from class + var langClass = ""; + for (var j = 0; j < el.classList.length; j++) { + if (el.classList[j].startsWith("language-")) { + langClass = el.classList[j].substring(9); + break; + } + } + // Skip plaintext variants + if (_NO_HIGHLIGHT_LANGS[langClass]) { + el.classList.add("nohighlight"); + continue; + } + // Apply highlighting + hljs.highlightElement(el); + // Add terminal styling class for shell languages + if (_TERMINAL_LANGS[langClass]) { + el.closest("pre").classList.add("code-terminal"); + } + } + } + // Render mermaid diagrams (lazy-loads mermaid.js on first use) + postRenderMermaid(containerEl); +} + +// --------------------------------------------------------------------------- +// Mermaid diagram rendering (lazy-loaded) +// --------------------------------------------------------------------------- +var _mermaidState = "idle"; // idle | loading | ready +var _mermaidQueue = []; // callbacks queued while loading +var _mermaidIdCounter = 0; + +function _initMermaid() { + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: "base", + themeVariables: _getMermaidTheme(), + }); +} + +function _loadMermaid(callback) { + if (_mermaidState === "ready") { + callback(); + return; + } + _mermaidQueue.push(callback); + if (_mermaidState === "loading") return; + _mermaidState = "loading"; + var script = document.createElement("script"); + script.src = "/shared/mermaid-11.14.0/mermaid.min.js"; + script.onload = function () { + _initMermaid(); + _mermaidState = "ready"; + var q = _mermaidQueue; + _mermaidQueue = []; + for (var i = 0; i < q.length; i++) q[i](); + }; + script.onerror = function () { + _mermaidState = "idle"; + _mermaidQueue = []; + var els = document.querySelectorAll(".mermaid-loading"); + for (var i = 0; i < els.length; i++) { + els[i].classList.remove("mermaid-loading"); + els[i].classList.add("mermaid-error"); + els[i].textContent = "Failed to load diagram renderer"; + } + }; + document.head.appendChild(script); +} + +function _getMermaidTheme() { + var s = getComputedStyle(document.documentElement); + return { + primaryColor: s.getPropertyValue("--bg-surface").trim(), + primaryTextColor: s.getPropertyValue("--fg").trim(), + primaryBorderColor: + s.getPropertyValue("--border-strong").trim() || "rgba(255,255,255,0.1)", + lineColor: s.getPropertyValue("--fg-dim").trim(), + secondaryColor: s.getPropertyValue("--bg-highlight").trim(), + tertiaryColor: s.getPropertyValue("--bg").trim(), + noteBkgColor: s.getPropertyValue("--bg-surface").trim(), + noteTextColor: s.getPropertyValue("--fg").trim(), + noteBorderColor: s.getPropertyValue("--accent").trim(), + actorTextColor: s.getPropertyValue("--fg-bright").trim(), + actorBkg: s.getPropertyValue("--bg-surface").trim(), + actorBorder: s.getPropertyValue("--accent").trim(), + signalColor: s.getPropertyValue("--fg").trim(), + signalTextColor: s.getPropertyValue("--fg").trim(), + }; +} + +// Render a single mermaid block, then call callback (serialized to avoid +// concurrent mermaid.render() calls which corrupt shared internal state) +function _renderMermaidBlock(container, callback) { + var source = container.getAttribute("data-mermaid-source"); + if (!source) { + if (callback) callback(); + return; + } + var id = "mermaid-" + ++_mermaidIdCounter; + function _onError(err) { + // Clean up orphaned temp SVG element mermaid may have left + var orphan = document.getElementById(id); + if (orphan) orphan.remove(); + container.classList.remove("mermaid-loading"); + container.classList.add("mermaid-error"); + container.innerHTML = + '

    ' + + escapeHtml(err.message || "Diagram error") + + "
    " + + "
    " +
    +      escapeHtml(source) +
    +      "
    "; + if (callback) callback(); + } + try { + mermaid + .render(id, source) + .then(function (result) { + container.innerHTML = result.svg; + container.classList.remove("mermaid-loading", "mermaid-error"); + container.classList.add("mermaid-rendered"); + if (result.bindFunctions) result.bindFunctions(container); + if (callback) callback(); + }) + .catch(_onError); + } catch (err) { + _onError(err); + } +} + +// Render mermaid blocks sequentially (mermaid uses shared state internally) +function _renderMermaidSequence(containers, idx) { + if (idx >= containers.length) return; + _renderMermaidBlock(containers[idx], function () { + _renderMermaidSequence(containers, idx + 1); + }); +} + +function postRenderMermaid(containerEl) { + var codeEls = containerEl.querySelectorAll("pre code.language-mermaid"); + if (codeEls.length === 0) return; + var containers = []; + for (var i = 0; i < codeEls.length; i++) { + var pre = codeEls[i].closest("pre"); + if (!pre) continue; + var source = codeEls[i].textContent; + var div = document.createElement("div"); + div.className = "mermaid-container mermaid-loading"; + div.setAttribute("data-mermaid-source", source); + div.textContent = "Loading diagram\u2026"; + pre.replaceWith(div); + containers.push(div); + } + if (containers.length === 0) return; + _loadMermaid(function () { + _renderMermaidSequence(containers, 0); + }); +} + +function reRenderAllMermaid() { + if (_mermaidState !== "ready") return; + _initMermaid(); + var els = document.querySelectorAll( + ".mermaid-container[data-mermaid-source]", + ); + var arr = []; + for (var i = 0; i < els.length; i++) { + els[i].classList.add("mermaid-loading"); + els[i].classList.remove("mermaid-rendered", "mermaid-error"); + arr.push(els[i]); + } + _renderMermaidSequence(arr, 0); +} diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index 22d1cc4e..ff4e46ed 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -16,6 +16,7 @@ + @@ -819,6 +820,26 @@ window.TURNSTONE_KB_SHORTCUTS = [ + + +