Files
turnstone/tests/test_coordinator_endpoints.py
T
Patrick Buckley cdbdf3dc2b fix: address review — request-scoped storage in coord tenancy checks
- _coordinator_tenant_check and _coord_attachment_owner resolved storage from
  the global registry (get_workstream_row / for_request without a storage arg),
  which can evaluate the project-tenancy decision against a different or
  auto-initialised backend and fail OPEN on a missing project row. Use
  request.app.state.auth_storage explicitly, matching cluster_ws_detail and
  _resolve_coordinator_or_404; fail closed (404) when it is unset.
- reject_unassignable_scopes now derives its allowed-scope error message from
  ASSIGNABLE_SCOPES so validation and the message can't drift.

(cherry picked from commit a40ff249ec)
2026-07-06 22:16:20 -07:00

3143 lines
126 KiB
Python

"""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}. Also exercises
the lifted ``approve`` and ``close`` handlers from
``turnstone.core.session_routes`` wired through the coord
``SessionEndpointConfig`` — same code path the live console uses.
"""
from __future__ import annotations
import hashlib
from typing import cast
from unittest.mock import MagicMock
import httpx
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Route
from starlette.testclient import TestClient
# ---------------------------------------------------------------------------
# Auth injection middleware
# ---------------------------------------------------------------------------
from tests._coord_test_helpers import (
_AuthMiddleware,
_build_mgr,
_build_mgr_with_factory,
_fake_registry,
_FakeConfigStore,
_seed_children,
)
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
from turnstone.console.server import (
_audit_cancel_coordinator,
_audit_close_coordinator,
_audit_coordinator_create,
_coord_create_build_kwargs,
_coord_create_post_install,
_coord_create_validate_request,
_coord_saved_loaded_lookup,
_coordinator_tenant_check,
_require_admin_coordinator,
_require_coord_mgr,
cluster_ws_detail,
coordinator_children,
coordinator_tasks,
)
from turnstone.core.attachments import (
classify_upload as _coord_test_classify_upload,
)
from turnstone.core.auth import AuthResult
from turnstone.core.session_routes import (
AttachmentUploadHelpers,
SessionEndpointConfig,
make_approve_handler,
make_attachment_handlers,
make_cancel_handler,
make_close_handler,
make_create_handler,
make_dequeue_handler,
make_detail_handler,
make_export_handler,
make_history_handler,
make_list_handler,
make_open_handler,
make_refresh_title_handler,
make_saved_handler,
make_send_handler,
make_set_title_handler,
)
from turnstone.core.workstream import WorkstreamKind
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _coord_attach_owner(request, ws_id, mgr):
"""Coord attachment owner resolver mirroring production wiring.
Kind-strict — coord attachments can only be accessed for
workstreams currently held by ``coord_mgr``; no storage fallback
so cross-kind ws_ids 404 instead of leaking through storage. Also
project-tenancy-strict: mirrors ``_coord_attachment_owner`` so a
private-project coordinator's attachments 404-mask non-members.
"""
from starlette.responses import JSONResponse
from turnstone.core.auth import WorkstreamProjectVisibility
from turnstone.core.web_helpers import auth_user_id
ws = mgr.get(ws_id)
if ws is None:
return "", JSONResponse({"error": "coordinator not found"}, status_code=404)
storage = getattr(request.app.state, "auth_storage", None)
if storage is None:
return "", JSONResponse({"error": "coordinator not found"}, status_code=404)
visibility = WorkstreamProjectVisibility.for_request(request, storage=storage)
if not visibility.ws_visible(getattr(ws, "project_id", "") or "", ws_owner=ws.user_id or ""):
return "", JSONResponse({"error": "coordinator not found"}, status_code=404)
return ws.user_id or auth_user_id(request), None
# Per-kind config the lifted handler factories capture by closure.
# Mirrors the production console wiring so tests exercise the same
# code path as the live server.
_coord_endpoint_config = SessionEndpointConfig(
permission_gate=_require_admin_coordinator,
manager_lookup=_require_coord_mgr,
tenant_check=_coordinator_tenant_check,
not_found_label="coordinator not found",
audit_action_prefix="coordinator",
supports_attachments=True,
attachment_owner_resolver=_coord_attach_owner,
attachment_helpers=AttachmentUploadHelpers(
classify_upload=_coord_test_classify_upload,
),
spawn_metrics=None,
emit_message_queued=True,
create_supports_attachments=True,
create_supports_user_id_override=False,
create_validate_request=_coord_create_validate_request,
create_build_kwargs=_coord_create_build_kwargs,
create_post_install=_coord_create_post_install,
list_resolve_titles=None,
list_kind=WorkstreamKind.COORDINATOR,
saved_state_filter="closed",
saved_loaded_lookup=_coord_saved_loaded_lookup,
)
@pytest.fixture
def storage(tmp_path):
# The attachment handlers resolve storage via ``memory.get_storage()`` (the
# registry singleton), not the instance passed to ``_make_client``/``_build_mgr``.
# Register the test backend so ``get_attachment`` & co. hit this fresh db rather
# than a stale default — otherwise schema drift (e.g. a new column) surfaces here.
from turnstone.core.storage import init_storage, reset_storage
reset_storage()
backend = init_storage("sqlite", path=str(tmp_path / "coord.db"), run_migrations=False)
# The per-node upload buffer is a process-global singleton; clear it so a
# prior test's staged uploads can't leak into this one (pending uploads
# live here now, not in storage).
from turnstone.core.attachment_buffer import get_attachment_buffer
get_attachment_buffer().clear()
yield backend
get_attachment_buffer().clear()
reset_storage()
def _make_client(
storage,
*,
coord_mgr=None,
alias="my-model",
registry=None,
) -> TestClient:
"""Build a TestClient exposing just the coordinator routes."""
coord_attachments = make_attachment_handlers(_coord_endpoint_config)
coord_create_handler = make_create_handler(
_coord_endpoint_config, audit_emit=_audit_coordinator_create
)
app = Starlette(
routes=[
Route(
"/v1/api/workstreams/new",
coord_create_handler,
methods=["POST"],
),
Route(
"/v1/api/workstreams",
make_list_handler(_coord_endpoint_config),
methods=["GET"],
),
# Literal path before the /{ws_id} routes below so Starlette
# matches "saved" as the literal, not as a ws_id.
Route(
"/v1/api/workstreams/saved",
make_saved_handler(_coord_endpoint_config),
methods=["GET"],
),
Route(
"/v1/api/workstreams/{ws_id}/send",
make_send_handler(_coord_endpoint_config),
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/send",
make_dequeue_handler(_coord_endpoint_config),
methods=["DELETE"],
),
Route(
"/v1/api/workstreams/{ws_id}/approve",
make_approve_handler(_coord_endpoint_config),
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/cancel",
make_cancel_handler(
_coord_endpoint_config,
audit_emit=_audit_cancel_coordinator,
),
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/close",
make_close_handler(
_coord_endpoint_config,
audit_emit=_audit_close_coordinator,
supports_close_reason=False,
),
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/refresh-title",
make_refresh_title_handler(_coord_endpoint_config),
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/title",
make_set_title_handler(_coord_endpoint_config),
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/history",
make_history_handler(_coord_endpoint_config),
methods=["GET"],
),
Route(
"/v1/api/workstreams/{ws_id}/export",
make_export_handler(_coord_endpoint_config),
methods=["GET"],
),
Route(
"/v1/api/workstreams/{ws_id}/open",
make_open_handler(_coord_endpoint_config),
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/children",
coordinator_children,
methods=["GET"],
),
Route(
"/v1/api/workstreams/{ws_id}/tasks",
coordinator_tasks,
methods=["GET"],
),
Route(
"/v1/api/workstreams/{ws_id}/attachments",
coord_attachments.upload,
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/attachments",
coord_attachments.list,
methods=["GET"],
),
Route(
"/v1/api/workstreams/{ws_id}/attachments/{attachment_id}/content",
coord_attachments.get_content,
methods=["GET"],
),
Route(
"/v1/api/workstreams/{ws_id}/attachments/{attachment_id}",
coord_attachments.delete,
methods=["DELETE"],
),
Route(
"/v1/api/workstreams/{ws_id}",
make_detail_handler(_coord_endpoint_config),
methods=["GET"],
),
Route(
"/v1/api/cluster/ws/{ws_id}/detail",
cluster_ws_detail,
methods=["GET"],
),
],
middleware=[Middleware(_AuthMiddleware)],
)
app.state.coord_mgr = coord_mgr
app.state.coord_adapter = coord_mgr._adapter if coord_mgr is not None else None
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/workstreams/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/workstreams/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/workstreams/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_falls_back_to_registry_default(storage):
"""``coordinator.model_alias`` unset → resolve through the
registry's default alias rather than 503-ing. Operators get a
working coordinator out of the box once any model is configured."""
mgr = _build_mgr(storage)
client = _make_client(storage, coord_mgr=mgr, alias="", registry=_fake_registry())
resp = client.post(
"/v1/api/workstreams/new",
json={},
headers={"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"},
)
# The fake registry resolves any alias (including None → default)
# so the create call succeeds past the gate. We only assert that
# the 503 remediation stack is NOT fired — any success or further
# downstream failure is unrelated to this regression.
assert resp.status_code != 503, resp.json()
def test_missing_alias_and_no_default_returns_503(storage):
"""When neither ``coordinator.model_alias`` nor the registry
default resolves, 503 with remediation still fires so operators
know they haven't configured any model at all."""
mgr = _build_mgr(storage)
broken_registry = MagicMock()
broken_registry.resolve.side_effect = KeyError("no-default")
client = _make_client(storage, coord_mgr=mgr, alias="", registry=broken_registry)
resp = client.post(
"/v1/api/workstreams/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"]
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/workstreams/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
# ---------------------------------------------------------------------------
_COORD_HEADERS = {"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"}
# ---------------------------------------------------------------------------
# Title verbs — refresh-title (LLM regenerate) + set title (manual alias),
# ported to coordinators via the lifted make_refresh_title_handler /
# make_set_title_handler factories so both kinds share one body.
# ---------------------------------------------------------------------------
def test_coord_refresh_title_triggers_regeneration(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1", name="c1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(f"/v1/api/workstreams/{ws.id}/refresh-title", headers=_COORD_HEADERS)
assert resp.status_code == 200
# The lifted handler resolves the current display name and asks the
# live session to regenerate a (different) title in the background.
ws.session.request_title_refresh.assert_called_once_with("c1")
def test_coord_refresh_title_requires_operator_permission(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1", name="c1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/refresh-title",
headers={"X-Test-User": "user-1", "X-Test-Perms": "read"},
)
assert resp.status_code == 403
ws.session.request_title_refresh.assert_not_called()
def test_coord_refresh_title_unknown_ws_404(storage):
mgr = _build_mgr(storage)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
"/v1/api/workstreams/" + ("0" * 32) + "/refresh-title", headers=_COORD_HEADERS
)
assert resp.status_code == 404
def test_coord_set_title_stores_alias_and_broadcasts(storage):
from turnstone.core.memory import get_workstream_display_name
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1", name="c1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/title",
json={"title": "Nightly migration sweep"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["title"] == "Nightly migration sweep"
# Stored as the alias (outranks the auto-title) ...
assert get_workstream_display_name(ws.id) == "Nightly migration sweep"
# ... and broadcast live to the dashboard via the session UI.
ws.session.ui.on_rename.assert_called_once_with("Nightly migration sweep")
def test_coord_set_title_empty_400(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1", name="c1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/title", json={"title": " "}, headers=_COORD_HEADERS
)
assert resp.status_code == 400
def test_coord_set_title_alias_conflict_409(storage):
mgr = _build_mgr(storage)
first = mgr.create(user_id="user-1", name="c1")
second = mgr.create(user_id="user-1", name="c2")
storage.set_workstream_alias(first.id, "taken")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{second.id}/title", json={"title": "taken"}, headers=_COORD_HEADERS
)
assert resp.status_code == 409
def test_coord_set_title_rejects_unowned_ws_404(storage):
"""An admin.coordinator operator can't rename a workstream the coord
manager doesn't own (here a cross-kind interactive row) via the coord
/title route: set_workstream_alias is a global kind-unscoped UPDATE, so
the handler 404s on the in-memory coord lookup BEFORE writing — no
silent 200, no cross-kind alias write."""
from turnstone.core.memory import get_workstream_display_name
mgr = _build_mgr(storage)
# An interactive-kind row in storage, NOT held by coord_mgr.
storage.register_workstream(
"i" * 32,
node_id="node-1",
user_id="user-1",
name="interactive-ws",
kind=WorkstreamKind.INTERACTIVE,
)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{'i' * 32}/title",
json={"title": "hijacked"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 404
# The interactive ws's display name is untouched — the alias write never fired.
assert get_workstream_display_name("i" * 32) == "interactive-ws"
def test_active_list_row_shape_includes_unified_fields(storage):
"""Stage 2 list-verb-lift parity regression — coord active-list row
carries the always-include fields (ws_id, name, state, kind,
parent_ws_id, user_id) that the lifted ``make_list_handler``
produces on every kind. SDK consumers don't have to branch on
kind to read any of these. Pre-lift coord returned a smaller
row ({ws_id, name, state, user_id}) under a different top-level
key (``coordinators`` vs ``workstreams``)."""
mgr = _build_mgr(storage)
mgr.create(user_id="u1", name="lifted-coord")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get("/v1/api/workstreams", headers=_COORD_HEADERS)
assert resp.status_code == 200
body = resp.json()
# Top-level key converged on `workstreams`.
assert "workstreams" in body
assert "coordinators" not in body
rows = body["workstreams"]
assert len(rows) == 1
row = rows[0]
# Always-include row shape — coord populates kind=COORDINATOR
# and parent_ws_id=None (coordinators have no parent today).
assert set(row.keys()) == {
"ws_id",
"name",
"state",
"kind",
"parent_ws_id",
"user_id",
"project_id",
"persona",
}
assert row["name"] == "lifted-coord"
assert row["kind"] == "coordinator"
assert row["parent_ws_id"] is None
assert row["user_id"] == "u1"
# mgr.create without a persona kwarg stamps nothing at this layer
# (default resolution lives in the HTTP create handler), so the
# row carries the null slug — not a fabricated default.
assert row["persona"] is None
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/workstreams/new",
json={"name": "my-coord"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert body["ws_id"]
assert "my-coord" in body["name"]
# Always-include parity fields land on coord post-`create` lift —
# SDK consumers don't have to branch on kind to read them.
assert body["resumed"] is False
assert body["message_count"] == 0
assert body["attachment_ids"] == []
# Audit row recorded on 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 _capture_factory_pair():
"""Return ``(factory, captured)`` — factory records model_alias +
judge_model into the captured dict on every call so tests can assert
the per-call override threading."""
captured: dict = {}
def _factory(ui, model_alias=None, ws_id=None, **kw): # type: ignore[no-untyped-def]
captured["model_alias"] = model_alias
captured["judge_model"] = kw.get("judge_model")
return MagicMock()
return _factory, captured
def test_create_forwards_model_and_judge_model_overrides(storage):
"""Per-call ``model`` + ``judge_model`` body fields land on the
coord session factory (mirrors interactive's create surface)."""
factory, captured = _capture_factory_pair()
mgr = _build_mgr_with_factory(storage, factory)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
"/v1/api/workstreams/new",
json={
"name": "tuned-coord",
"model": "gpt-5",
"judge_model": "gpt-5-mini",
},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200, resp.text
assert captured == {"model_alias": "gpt-5", "judge_model": "gpt-5-mini"}
def test_create_empty_model_fields_collapse_to_none(storage):
"""Empty-string ``model`` / ``judge_model`` body fields don't override
the ConfigStore default — they collapse to ``None`` so the factory
falls back to ``coordinator.model_alias`` / ``judge.model``."""
factory, captured = _capture_factory_pair()
mgr = _build_mgr_with_factory(storage, factory)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
"/v1/api/workstreams/new",
json={"name": "default-coord", "model": " ", "judge_model": ""},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200, resp.text
assert captured == {"model_alias": None, "judge_model": None}
def test_create_503_factory_misconfig_message_is_sanitised(storage):
"""503 response from a factory ``ValueError`` strips ASCII control
chars and caps the echoed alias text — defence-in-depth for the
user-controlled ``body["model"]`` reflection surface. Operators
keep the actionable message in the log; clients see a clean
bounded string."""
def _factory_raises(ui, model_alias=None, ws_id=None, **kw): # type: ignore[no-untyped-def]
# Simulate the registry's actual exception shape, plus a
# control char + a long-tail attacker payload.
raise ValueError("Unknown model alias: \x00\x07attack\x1b[31m" + ("A" * 1000))
mgr = _build_mgr_with_factory(storage, _factory_raises)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
"/v1/api/workstreams/new",
json={"name": "c"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 503
err = resp.json()["error"]
# Cap enforced (hard-capped at _FACTORY_MISCONFIG_MAX_LEN total —
# the truncation reserves one codepoint for the ellipsis).
assert len(err) <= 200
assert "\x00" not in err
assert "\x1b" not in err
assert "Unknown model alias" in err
assert err.endswith("…")
def test_create_non_string_model_fields_collapse_to_none(storage):
"""Non-string ``model`` / ``judge_model`` body fields (e.g. a hostile
dict / list / int) collapse to ``None`` rather than reaching
``.strip()`` and crashing into the lifted handler's generic 500
path. Defense-in-depth — the auth gate already requires
``admin.coordinator``."""
factory, captured = _capture_factory_pair()
mgr = _build_mgr_with_factory(storage, factory)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
"/v1/api/workstreams/new",
json={
"name": "hostile-body",
"model": {"url": "http://evil"},
"judge_model": [1, 2, 3],
},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200, resp.text
assert captured == {"model_alias": None, "judge_model": None}
_PNG_1X1 = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfc\xcf"
b"\xc0\xc0\xc0\x00\x00\x00\x05\x00\x01\xa5\xf6E@\x00\x00\x00\x00IEND"
b"\xaeB`\x82"
)
def test_create_with_multipart_attachments_stages_to_buffer(storage):
"""§ Post-P3 reckoning item #1 regression — coord gains create-time
attachments. In the content-addressed model a multipart create with a
magic-byte-valid PNG *stages* the upload in the per-node buffer (no DB
row); a subsequent ``/send`` resolves it and persists it content-addressed.
No ``initial_message`` here, so the staged upload remains in the buffer
for the workstream after create returns."""
from turnstone.core.attachment_buffer import get_attachment_buffer
mgr = _build_mgr(storage)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
import turnstone.core.storage._registry as _reg
_old_storage = _reg._storage
_reg._storage = storage
try:
resp = client.post(
"/v1/api/workstreams/new",
data={"meta": '{"name": "with-image"}'},
files={"file": ("img.png", _PNG_1X1, "image/png")},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200, resp.text
body = resp.json()
ws_id = body["ws_id"]
assert ws_id
assert len(body["attachment_ids"]) == 1
# Pending upload lives in the buffer, scoped to (ws, user).
staged = get_attachment_buffer().list_for(ws_id=ws_id, user_id="user-1")
assert len(staged) == 1
assert staged[0].kind == "image"
# The id is the content hash (content-addressed).
assert staged[0].attachment_id == hashlib.sha256(_PNG_1X1).hexdigest()
finally:
_reg._storage = _old_storage
def test_create_with_multipart_attachments_and_initial_message_resolves(storage):
"""Coord initial-message + create-time-attachments coordination — when
``initial_message`` is provided alongside multipart uploads, the staged
bytes are resolved onto the dispatched first turn (the committing
``ChatSession.send`` then writes them content-addressed + drains the
buffer; that commit is async and covered synchronously by the session
tests).
Asserts the deterministic surface: the create response carries the
content-addressed id, and the post-install resolved (drained) the staged
upload from the buffer so it isn't left behind for the new workstream."""
mgr = _build_mgr(storage)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
import turnstone.core.storage._registry as _reg
_old_storage = _reg._storage
_reg._storage = storage
try:
resp = client.post(
"/v1/api/workstreams/new",
data={"meta": '{"name": "with-init", "initial_message": "look at this image"}'},
files={"file": ("img.png", _PNG_1X1, "image/png")},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["ws_id"]
attachment_ids = body["attachment_ids"]
assert len(attachment_ids) == 1
assert attachment_ids[0] == hashlib.sha256(_PNG_1X1).hexdigest()
# The initial-message worker resolves (peeks) the staged upload and the
# committing send drains it + writes it content-addressed. That commit
# runs on a background thread, so the create response (the
# content-addressed id) is the deterministic contract asserted here;
# the synchronous commit path is covered by test_session_attachments.
finally:
_reg._storage = _old_storage
def test_create_attachment_failure_no_phantom_create_close_pair(storage):
"""Regression for the ``defer_emit_created`` follow-up — when a
multipart create rolls back on attachment validation failure,
the cluster collector sees zero events: no phantom
``ws_created``, no orphan ``ws_closed``. Pre-fix, coord's
``mgr.create`` fired ``emit_created`` synchronously and the
rollback path called ``mgr.close`` which fired ``emit_closed``,
surfacing a quick create→close pair on the cluster events
stream."""
mgr = _build_mgr(storage)
# Pull the collector MagicMock out of the adapter so we can
# introspect call counts after the failed create.
coord_collector = mgr._adapter._collector # type: ignore[attr-defined]
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
import turnstone.core.storage._registry as _reg
_old_storage = _reg._storage
_reg._storage = storage
try:
# Empty file body → ``validate_and_save_uploaded_files``
# returns the 400 "Empty file" path which the lifted body
# surfaces directly. The rollback path runs after.
resp = client.post(
"/v1/api/workstreams/new",
data={"meta": '{"name": "rollback-target"}'},
files={"file": ("blank.bin", b"", "application/octet-stream")},
headers=_COORD_HEADERS,
)
assert resp.status_code == 400, resp.text
# Manager is empty — slot was discarded.
assert mgr.count == 0
# The collector saw NEITHER create nor close events for any
# ws_id. ``MagicMock`` records every method call, so an empty
# list of calls to these specific methods is the regression
# check.
assert coord_collector.emit_console_ws_created.call_count == 0
assert coord_collector.emit_console_ws_closed.call_count == 0
finally:
_reg._storage = _old_storage
def test_create_rejects_disabled_skill(storage):
"""Coord parity gain — disabled skills now rejected at the lift,
matching interactive's pre-lift behaviour. Pre-lift coord silently
let disabled skills through."""
import turnstone.core.storage._registry as _reg
_old_storage = _reg._storage
_reg._storage = storage
try:
# Save a disabled skill row so ``get_skill_by_name`` resolves
# but the lifted body's enabled check rejects it. Mirrors the
# ``_create_template`` helper in ``tests/test_skills.py``;
# inlined here so this regression test stays self-contained.
storage.create_prompt_template(
template_id="sk-disabled",
name="dormant-skill",
category="general",
content="dormant",
variables="[]",
is_default=False,
org_id="",
created_by="test",
origin="manual",
mcp_server="",
readonly=False,
description="",
tags="[]",
source_url="",
version="1.0.0",
author="",
activation="named",
token_estimate=0,
model="",
auto_approve=False,
temperature=None,
reasoning_effort="",
max_tokens=None,
token_budget=0,
agent_max_turns=None,
notify_on_complete="{}",
enabled=False, # the gate under test
allowed_tools="[]",
priority=0,
)
mgr = _build_mgr(storage)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
"/v1/api/workstreams/new",
json={"name": "x", "skill": "dormant-skill"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 400
assert "dormant-skill" in resp.json()["error"]
finally:
_reg._storage = _old_storage
def test_list_returns_cluster_wide(storage):
# Trusted-team visibility: any caller with admin.coordinator sees
# every active coordinator regardless of owner. ``user_id`` stays
# on the response as metadata.
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/workstreams", headers=_COORD_HEADERS)
assert resp.status_code == 200
body = resp.json()
names = {c["name"] for c in body["workstreams"]}
assert names == {"mine", "theirs"}
def _seed_closed_coord_with_history(
mgr,
storage,
*,
user_id: str,
name: str,
) -> str:
"""Create + close a coordinator and seed one conversation row.
list_workstreams_with_history's WHERE EXISTS guard skips coords with no
messages, so the saved-list endpoint won't surface a freshly-closed
coordinator unless we've stamped at least one conversation row.
"""
ws = mgr.create(user_id=user_id, name=name)
storage.save_message(ws.id, role="user", content="seed")
closed = mgr.close(ws.id)
assert closed
return ws.id
@pytest.fixture
def saved_storage(tmp_path):
"""Storage fixture for saved-coordinator tests.
coordinator_saved goes through ``list_workstreams_with_history``
which calls ``get_storage()`` (the singleton registry), not whatever
backend the manager holds. This fixture initialises the registry to
a fresh SQLite db and yields the same backend so the test can also
seed conversation rows directly.
"""
from turnstone.core.storage import init_storage, reset_storage
db_path = str(tmp_path / "saved.db")
reset_storage()
backend = init_storage("sqlite", path=db_path, run_migrations=False)
try:
yield backend
finally:
reset_storage()
def test_saved_returns_cluster_wide(saved_storage):
# Trusted-team visibility: every ``admin.coordinator`` caller sees
# every closed coordinator.
storage = saved_storage
mgr = _build_mgr(storage)
a = _seed_closed_coord_with_history(mgr, storage, user_id="user-1", name="a")
b = _seed_closed_coord_with_history(mgr, storage, user_id="user-2", name="b")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get("/v1/api/workstreams/saved", headers=_COORD_HEADERS)
assert resp.status_code == 200
assert {c["ws_id"] for c in resp.json()["workstreams"]} == {a, b}
def test_saved_excludes_currently_loaded(saved_storage):
"""A coordinator currently in coord_mgr must NOT appear in saved cards.
Even if its DB row says state='closed' (e.g. mid-restart race), the
in-memory presence wins so the same ws_id can't be in both the
active list and the saved-cards grid simultaneously.
"""
storage = saved_storage
mgr = _build_mgr(storage)
closed_id = _seed_closed_coord_with_history(mgr, storage, user_id="user-1", name="closed")
# Create another coord, leave it loaded — should never appear in saved.
loaded_ws = mgr.create(user_id="user-1", name="loaded")
storage.save_message(loaded_ws.id, role="user", content="seed")
# Force it to state='closed' on disk without removing from memory, to
# exercise the defence-in-depth ``loaded`` filter.
storage.update_workstream_state(loaded_ws.id, "closed")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get("/v1/api/workstreams/saved", headers=_COORD_HEADERS)
assert resp.status_code == 200
saved_ids = {c["ws_id"] for c in resp.json()["workstreams"]}
assert closed_id in saved_ids
assert loaded_ws.id not in saved_ids
def test_saved_excludes_active_state_rows(saved_storage):
"""Only state='closed' rows surface in the saved list.
A coordinator that's idle on disk but not currently loaded into
coord_mgr (e.g. orphaned across a console restart that hasn't
rehydrated yet) is NOT 'saved' — it's just not loaded yet, and the
saved grid is for explicit user-closed sessions.
"""
storage = saved_storage
mgr = _build_mgr(storage)
closed_id = _seed_closed_coord_with_history(mgr, storage, user_id="user-1", name="closed")
# An idle row in storage with no in-memory presence — must not appear.
orphan = mgr.create(user_id="user-1", name="orphan")
storage.save_message(orphan.id, role="user", content="seed")
# Drop from memory without changing state (simulates manager restart).
mgr._workstreams.pop(orphan.id, None)
if orphan.id in mgr._order:
mgr._order.remove(orphan.id)
assert storage.get_workstream(orphan.id)["state"] == "idle"
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get("/v1/api/workstreams/saved", headers=_COORD_HEADERS)
assert resp.status_code == 200
saved_ids = {c["ws_id"] for c in resp.json()["workstreams"]}
assert saved_ids == {closed_id}
def test_send_any_admin_coordinator_caller_can_send(storage):
# Trusted-team model: send is gated on admin.coordinator scope,
# not on per-row ownership. Any caller with the scope can post
# to any coordinator.
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/workstreams/{ws.id}/send",
json={"message": "hi"},
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 200
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/workstreams/{ws.id}/send",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# Dequeue (DELETE /send) — lifted handler wired onto the coord endpoint
# config. Pins the URL/scope contract so a regression in the route table
# (wrong endpoint_config, wrong method, missing gate) trips a test.
# ---------------------------------------------------------------------------
def test_dequeue_removes_queued_coord_message(storage):
"""POST /send while busy → queued; DELETE /send with msg_id → removed."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
# _build_mgr's session_factory returns a MagicMock posing as a
# ChatSession so the test can stub queue_message / dequeue_message.
session = cast("MagicMock", ws.session)
# Force the queue path by marking the worker as already running.
# The lifted send handler hands off to ``session_worker.send`` which
# picks ``enqueue`` over ``run`` when ``ws._worker_running`` is True.
ws._worker_running = True
session.queue_message.return_value = ("hi", "important", "msg-abc")
session.dequeue_message.return_value = True
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
send_resp = client.post(
f"/v1/api/workstreams/{ws.id}/send",
json={"message": "hi"},
headers=_COORD_HEADERS,
)
assert send_resp.status_code == 200, send_resp.text
body = send_resp.json()
assert body["status"] == "queued"
assert body["msg_id"] == "msg-abc"
dequeue_resp = client.request(
"DELETE",
f"/v1/api/workstreams/{ws.id}/send",
json={"msg_id": "msg-abc"},
headers=_COORD_HEADERS,
)
assert dequeue_resp.status_code == 200, dequeue_resp.text
assert dequeue_resp.json() == {"status": "removed"}
session.dequeue_message.assert_called_with("msg-abc")
def test_dequeue_unknown_msg_id_returns_not_found(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
cast("MagicMock", ws.session).dequeue_message.return_value = False
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.request(
"DELETE",
f"/v1/api/workstreams/{ws.id}/send",
json={"msg_id": "missing"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert resp.json() == {"status": "not_found"}
def test_dequeue_requires_msg_id(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.request(
"DELETE",
f"/v1/api/workstreams/{ws.id}/send",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 400
def test_dequeue_unknown_ws_returns_404(storage):
mgr = _build_mgr(storage)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.request(
"DELETE",
"/v1/api/workstreams/0000000000000000000000000000000000000000000000000000000000000000/send",
json={"msg_id": "anything"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 404
assert "coordinator not found" in resp.json()["error"]
def test_dequeue_requires_admin_coordinator_scope(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.request(
"DELETE",
f"/v1/api/workstreams/{ws.id}/send",
json={"msg_id": "msg-abc"},
headers={"X-Test-User": "user-1", "X-Test-Perms": "read"},
)
assert resp.status_code == 403
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/workstreams/{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)
cycle = _seed_pending(ws, "c-1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
json={"approved": True, "always": True, "call_id": "c-1"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["cycle_id"] == cycle.cycle_id
assert cycle.event.is_set()
assert cycle.result == (True, None)
assert "spawn_workstream" in ws.ui.auto_approve_tools
def _seed_pending(ws, *call_ids: str, func_name: str = "spawn_workstream"):
"""Register a live ApprovalCycle on the coord UI the way its
``approve_tools`` gate does, returning the cycle for direct
event/result assertions (the pre-cycle singleton
``_approval_event`` / ``_approval_result`` slots are gone)."""
from turnstone.core.session_ui_base import ApprovalCycle
items = [
{
"call_id": cid,
"func_name": func_name,
"approval_label": func_name,
"needs_approval": True,
}
for cid in call_ids
]
card = {
"type": "approve_request",
"cycle_id": f"cyc-{'-'.join(call_ids)}",
"items": ws.ui._serialize_approval_items(items),
"judge_pending": False,
}
cycle = ApprovalCycle(items, card, None)
ws.ui._register_approval_cycle(cycle)
return cycle
def test_approve_409_on_stale_call_id(storage):
"""Body call_id doesn't match any pending item → 409 with the
current primary call_id + cycle_id so the UI can re-render
against the new round."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
cycle = _seed_pending(ws, "c-current")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
json={"approved": True, "call_id": "c-stale"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 409
body = resp.json()
assert body["error"] == "stale call_id"
assert body["current_call_id"] == "c-current"
assert body["current_cycle_id"] == cycle.cycle_id
# The live cycle must NOT have been resolved.
assert not cycle.event.is_set()
def test_approve_409_when_no_pending_and_call_id_sent(storage):
"""Body sends a call_id but the UI has no live cycle — 409 with
current_call_id=None so the UI knows to clear the row."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
# No cycle registered.
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
json={"approved": True, "call_id": "c-anything"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 409
body = resp.json()
assert body["error"] == "stale call_id"
assert body["current_call_id"] is None
assert body["current_cycle_id"] is None
def test_approve_no_call_id_preserves_backward_compat(storage):
"""Existing clients (CLI, channel adapters) that omit call_id
must still resolve approvals — a selector-less body lands on the
oldest live cycle."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
cycle = _seed_pending(ws, "c-1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
json={"approved": True}, # no call_id
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["cycle_id"] == cycle.cycle_id
assert cycle.event.is_set()
def test_approve_no_call_id_no_pending_resolves_nothing(storage):
"""Legacy clients (no call_id) calling approve with no live cycle:
200 with ``cycle_id: null`` — the handler resolves NOTHING rather
than racing a cycle that registers between its lookup and its
resolve (the client can't have been looking at one)."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
# No cycle registered.
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
json={"approved": True}, # no call_id
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["cycle_id"] is None
def test_approve_call_id_matches_any_item_in_multi_envelope(storage):
"""N>1 envelope: any one of the items' call_ids in the body
is sufficient — server resolves the whole envelope per the
one-boolean semantics of resolve_approval."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
cycle = _seed_pending(ws, "c-1", "c-2", "c-3")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
json={"approved": True, "call_id": "c-2"}, # middle item
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert cycle.event.is_set()
def test_selectorless_always_whitelists_only_the_resolved_oldest_cycle(storage):
"""sweep-3 regression: with several live cycles, a selector-less
"Approve + Always" must whitelist the tools of the cycle it
actually resolved (the oldest) — not a sibling's."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
oldest = _seed_pending(ws, "a-1", func_name="spawn_workstream")
newer = _seed_pending(ws, "b-1", func_name="send_message")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
json={"approved": True, "always": True}, # no selector
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["cycle_id"] == oldest.cycle_id
assert oldest.event.is_set()
assert not newer.event.is_set()
assert "spawn_workstream" in ws.ui.auto_approve_tools
assert "send_message" not in ws.ui.auto_approve_tools
def test_approve_always_skips_whitelist_when_pinned_cycle_lost_the_race(storage):
"""sweep-3 regression: the handler collects always-names from the
cycle its lookup pinned; if that cycle is resolved by someone else
(gate timeout, peer tab) between lookup and resolve, the whitelist
must NOT grow — approving a card that already resolved must not
auto-approve anything."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
_seed_pending(ws, "a-1", func_name="spawn_workstream")
ui = ws.ui
real_find = ui.find_approval_cycle
def racing_find(**kwargs):
card = real_find(**kwargs)
if card is not None:
# A concurrent resolver wins the gap between the handler's
# lookup and its (pinned) resolve.
ui.resolve_approval(False, "raced", cycle_id=card["cycle_id"])
return card
ui.find_approval_cycle = racing_find
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/approve",
json={"approved": True, "always": True},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["cycle_id"] is None
assert "spawn_workstream" not in ws.ui.auto_approve_tools
# ---------------------------------------------------------------------------
# Lazy rehydration
# ---------------------------------------------------------------------------
def test_detail_triggers_lazy_rehydration(storage):
"""GET /v1/api/workstreams/{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/workstreams/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_any_admin_coordinator_caller_can_open(storage):
# Trusted-team model: any ``admin.coordinator`` caller can rehydrate
# any persisted coordinator regardless of owner. ``user_id`` stays
# on the response as metadata.
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/workstreams/coord-x",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 200
assert resp.json()["user_id"] == "owner"
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/workstreams/ws-int", headers=_COORD_HEADERS)
assert resp.status_code == 404
def test_detail_503_on_session_factory_misconfig(storage):
"""``ValueError`` from ``mgr.open`` (e.g. a model alias that no longer
resolves) surfaces as 503 with the factory's remediation text — not
a correlation-id'd 500. Mirrors the open verb lift's contract so
operators can fix the misconfig without grepping logs."""
from unittest.mock import patch
mgr = _build_mgr(storage)
storage.register_workstream("misconfig-coord", kind="coordinator", user_id="user-1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
with patch.object(mgr, "open", side_effect=ValueError("no such model alias")):
resp = client.get("/v1/api/workstreams/misconfig-coord", headers=_COORD_HEADERS)
assert resp.status_code == 503
assert "no such model alias" in resp.json()["error"]
def test_detail_correlation_id_on_unexpected_rehydrate_failure(storage):
"""Bare ``Exception`` from ``mgr.open`` (build_session / resume failure
with no documented spec) surfaces as a correlation-id'd 500 with the
per-kind noun in the user-facing message — not the raw exception
text. Mirrors :func:`make_open_handler`'s leak-prevention contract."""
from unittest.mock import patch
mgr = _build_mgr(storage)
storage.register_workstream("broken-coord", kind="coordinator", user_id="user-1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
with patch.object(mgr, "open", side_effect=RuntimeError("internal stack frame leak")):
resp = client.get("/v1/api/workstreams/broken-coord", headers=_COORD_HEADERS)
assert resp.status_code == 500
body = resp.json()
assert "internal stack frame leak" not in body["error"]
assert "correlation_id=" in body["error"]
# Per-kind noun via cfg.audit_action_prefix — coord wires "coordinator".
assert "coordinator" in body["error"]
# ---------------------------------------------------------------------------
# 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/workstreams/{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_any_admin_coordinator_caller_can_read(storage):
# Trusted-team visibility: history is readable by any
# ``admin.coordinator`` caller.
mgr = _build_mgr(storage)
ws = mgr.create(user_id="owner")
storage.save_message(ws.id, "user", "hello")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(
f"/v1/api/workstreams/{ws.id}/history",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 200
assert resp.json()["ws_id"] == ws.id
def test_history_private_project_hidden_from_non_member(storage):
# admin.coordinator gates the surface, but a coordinator in a private
# project the caller isn't a member of is 404-masked — the conversation
# does not leak to a non-member operator.
storage.create_project("proj-secret", "Secret", "alice")
storage.register_workstream(
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
)
storage.save_message("c" * 32, "user", "secret plan")
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get(
f"/v1/api/workstreams/{'c' * 32}/history",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 404
def test_history_private_project_visible_to_member(storage):
storage.create_project("proj-secret", "Secret", "alice")
storage.add_project_member("proj-secret", "member-bob")
storage.register_workstream(
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
)
storage.save_message("c" * 32, "user", "secret plan")
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get(
f"/v1/api/workstreams/{'c' * 32}/history",
headers={"X-Test-User": "member-bob", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 200
assert any(m.get("content") == "secret plan" for m in resp.json()["messages"])
def test_export_private_project_hidden_from_non_member(storage):
storage.create_project("proj-secret", "Secret", "alice")
storage.register_workstream(
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
)
storage.save_message("c" * 32, "user", "secret plan")
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get(
f"/v1/api/workstreams/{'c' * 32}/export",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 404
def test_children_private_project_hidden_from_non_member(storage):
storage.create_project("proj-secret", "Secret", "alice")
storage.register_workstream(
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
)
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get(
f"/v1/api/workstreams/{'c' * 32}/children",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 404
def test_open_private_project_hidden_from_non_member(storage):
# `open` rehydrates + returns the auto-titled name, so an ungated open is a
# private-project existence/metadata oracle AND an unauthorized resurrection.
# The tenant_check must fire before the already-loaded shortcut and mgr.open.
storage.create_project("proj-secret", "Secret", "alice")
storage.register_workstream(
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
)
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{'c' * 32}/open",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 404
def test_coord_attachments_private_project_hidden_from_non_member(storage):
# Attachment list/serve resolves the owner as the coord owner and only
# enforced cross-kind before — a non-member operator could enumerate and
# download the owner's staged blobs. Now 404-masked by project tenancy.
storage.create_project("proj-secret", "Secret", "alice")
mgr = _build_mgr(storage)
ws = mgr.create(user_id="alice", project_id="proj-secret")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(
f"/v1/api/workstreams/{ws.id}/attachments",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 404
def test_coord_attachments_private_project_visible_to_member(storage):
storage.create_project("proj-secret", "Secret", "alice")
storage.add_project_member("proj-secret", "member-bob")
mgr = _build_mgr(storage)
ws = mgr.create(user_id="alice", project_id="proj-secret")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(
f"/v1/api/workstreams/{ws.id}/attachments",
headers={"X-Test-User": "member-bob", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 200
def test_history_serves_storage_only_workstream(storage):
"""Persisted-but-not-loaded coordinators (closed / evicted) are still
readable via /history without rehydrating. Mirrors the pre-lift
``_resolve_coordinator_or_404`` ladder: storage-row + kind check
is sufficient when ``mgr.get`` returns None."""
mgr = _build_mgr(storage)
storage.register_workstream("storage-only-coord", kind="coordinator", user_id="user-1")
storage.save_message("storage-only-coord", "user", "from cold storage")
# Confirm precondition: row is in storage, NOT in the manager's pool.
assert mgr.get("storage-only-coord") is None
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(
"/v1/api/workstreams/storage-only-coord/history",
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert any(m.get("content") == "from cold storage" for m in resp.json()["messages"])
# History does NOT rehydrate (unlike detail) — pool stays cold.
assert mgr.get("storage-only-coord") is None
def test_history_404_when_kind_interactive(storage):
"""Cross-kind isolation on the storage fallback path: an interactive
ws_id that exists in storage 404s on the coord history endpoint
(mirrors :func:`test_detail_404_when_kind_interactive`). The lifted
factory uses ``cfg.list_kind`` for the kind check; pre-lift coord
used :func:`_resolve_coordinator_or_404`'s explicit kind compare."""
mgr = _build_mgr(storage)
storage.register_workstream("ws-int", kind="interactive", user_id="user-1")
storage.save_message("ws-int", "user", "interactive content")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get("/v1/api/workstreams/ws-int/history", headers=_COORD_HEADERS)
assert resp.status_code == 404
assert "interactive content" not in resp.text
def test_history_swallows_load_messages_exception_returns_empty(storage):
"""``storage.load_messages`` raising mid-call (transient DB outage,
corrupted row, etc.) must not 5xx the page-load handshake — coord
pre-lift logged at debug and returned 200 with ``messages == []``.
The lifted body preserves that contract on both kinds; pin it
explicitly so a future reader doesn't remove the bare-except as
dead code."""
from unittest.mock import patch
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
with patch.object(storage, "load_messages", side_effect=RuntimeError("db gone")):
resp = client.get(f"/v1/api/workstreams/{ws.id}/history", headers=_COORD_HEADERS)
assert resp.status_code == 200
body = resp.json()
assert body["ws_id"] == ws.id
assert body["messages"] == []
def test_history_clamps_limit_query_param(storage):
"""Pre-lift coord clamped ``?limit=`` to [1, 500]. The lifted factory
preserves the same bounds. Out-of-range / unparseable values
fall back to defaults instead of erroring — coord's page-load
handshake should never 4xx on a malformed limit param."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
# Seed enough messages to exercise the upper bound. SQLite's INSERT
# is fast enough that 6 inserts in a tight loop is fine.
for i in range(6):
storage.save_message(ws.id, "user", f"msg-{i}")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
base = f"/v1/api/workstreams/{ws.id}/history"
# No limit → default 100 (returns all 6).
resp = client.get(base, headers=_COORD_HEADERS)
assert resp.status_code == 200
assert len(resp.json()["messages"]) == 6
# limit=2 → only 2 messages returned (storage owns the row
# ordering contract; the factory just threads ``limit`` through).
resp = client.get(base, params={"limit": 2}, headers=_COORD_HEADERS)
assert resp.status_code == 200
assert len(resp.json()["messages"]) == 2
# Negative / zero → clamp to 1 (factory: ``max(1, min(limit, 500))``).
resp = client.get(base, params={"limit": 0}, headers=_COORD_HEADERS)
assert resp.status_code == 200
assert len(resp.json()["messages"]) == 1
# Garbage → falls back to default 100 (still 200, returns all 6).
resp = client.get(base, params={"limit": "garbage"}, headers=_COORD_HEADERS)
assert resp.status_code == 200
assert len(resp.json()["messages"]) == 6
# Above-cap → clamps to 500 (page-load handshake never 4xx's).
resp = client.get(base, params={"limit": 999}, headers=_COORD_HEADERS)
assert resp.status_code == 200
# We only have 6 messages but the response is still 200 — the cap
# is enforced on the SQL LIMIT, not on the row count.
assert len(resp.json()["messages"]) == 6
# ---------------------------------------------------------------------------
# Export (issue #613) — conversation-only, never a zip
# ---------------------------------------------------------------------------
def test_export_happy_path_returns_json_not_zip(storage):
"""A seeded coordinator exports as a JSON conversation envelope —
never a zip. The HTTP surface is conversation-only; the children/zip
capability is admin-CLI-only."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
storage.save_message(ws.id, "user", "coordinate the work")
storage.save_message(ws.id, "assistant", "on it")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(f"/v1/api/workstreams/{ws.id}/export", headers=_COORD_HEADERS)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/json")
assert resp.headers["content-disposition"] == f'attachment; filename="{ws.id}.json"'
# NOT a zip — zip archives start with the "PK" local-file magic.
assert not resp.content.startswith(b"PK")
# Body parses to the OpenAI envelope with the seeded turns.
body = resp.json()
role_contents = [(m.get("role"), m.get("content")) for m in body["messages"]]
assert ("user", "coordinate the work") in role_contents
def test_export_serves_storage_only_coordinator(storage):
"""Closed / evicted coordinators export from storage without
rehydrating, same ladder history uses."""
mgr = _build_mgr(storage)
storage.register_workstream("storage-only-coord", kind="coordinator", user_id="user-1")
storage.save_message("storage-only-coord", "user", "from cold storage")
assert mgr.get("storage-only-coord") is None
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(
"/v1/api/workstreams/storage-only-coord/export",
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
contents = [m.get("content") for m in resp.json()["messages"]]
assert "from cold storage" in contents
# Export does NOT rehydrate — pool stays cold.
assert mgr.get("storage-only-coord") is None
def test_export_404_when_kind_interactive(storage):
"""Cross-kind isolation: an interactive ws_id in shared storage 404s
on the coordinator export endpoint (the handler is built with
``list_kind=COORDINATOR``). Proves the kind gate the same way
:func:`test_history_404_when_kind_interactive` does."""
mgr = _build_mgr(storage)
storage.register_workstream("ws-int", kind="interactive", user_id="user-1")
storage.save_message("ws-int", "user", "interactive content")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get("/v1/api/workstreams/ws-int/export", headers=_COORD_HEADERS)
assert resp.status_code == 404
assert "interactive content" not in resp.text
# ---------------------------------------------------------------------------
# Cancel
# ---------------------------------------------------------------------------
def test_cancel_resolves_pending_approval(storage):
"""Cancel addresses the workstream, not one batch — EVERY live
cycle resolves (parallel task agents can hold several gates)."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
assert isinstance(ws.ui, ConsoleCoordinatorUI)
first = _seed_pending(ws, "c-1")
second = _seed_pending(ws, "c-2")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(f"/v1/api/workstreams/{ws.id}/cancel", headers=_COORD_HEADERS)
assert resp.status_code == 200
assert first.event.is_set()
assert second.event.is_set()
assert first.result == (False, "Cancelled by user")
def test_cancel_response_always_includes_dropped_key(storage):
"""Always-include shape parity: post-P3 verb lift, coord cancel
returns ``{"status": "ok", "dropped": {}}`` regardless of whether
a forensics callable is wired (coord wires ``None``). SDK
consumers don't have to branch on kind to read ``dropped``."""
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/workstreams/{ws.id}/cancel", headers=_COORD_HEADERS)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["dropped"] == {}
def test_cancel_force_flag_abandons_worker_thread_and_emits_stream_end(storage):
"""Parity gain from the verb lift: coord now honours the ``force``
flag the same way interactive does (pre-lift coord ignored it).
Stuck-worker recovery: the abandoned thread is cleared, an
``idle`` state-change is dispatched via the UI, and a
``stream_end`` event lands on the listener queue so the dashboard
recovers without waiting for the daemon thread to exit."""
import threading
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
assert isinstance(ws.ui, ConsoleCoordinatorUI)
# Simulate an in-flight worker the UI is still waiting on.
ws._worker_running = True
ws.worker_thread = threading.Thread(target=lambda: None, daemon=True)
listener = ws.ui._register_listener()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/cancel",
headers=_COORD_HEADERS,
json={"force": True},
)
assert resp.status_code == 200
# Worker thread reference cleared so a follow-up send doesn't
# think a generation is still in flight.
assert ws.worker_thread is None
# ``stream_end`` lands on the listener so SDK consumers bail out
# of the SSE loop instead of hanging on the daemon thread.
seen = []
while not listener.empty():
seen.append(listener.get_nowait().get("type"))
assert "stream_end" in seen
def test_cancel_returns_400_when_session_missing(storage):
"""Pre-lift coord called ``coord_mgr.cancel`` which silently
no-op'd on a placeholder workstream (session=None). The lifted
body 400s for parity with interactive's existing
``"No session"`` branch — surfaces the build-failure state to
the operator instead of swallowing it."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
# Force the placeholder/build-failed shape.
ws.session = None
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(f"/v1/api/workstreams/{ws.id}/cancel", headers=_COORD_HEADERS)
assert resp.status_code == 400
def test_cancel_swallows_forensics_exception(storage):
"""``cancel_forensics`` is observational — a bug in the snapshot
callable must NOT block the actual cancel. The lifted body wraps
the call in try/except + log.debug and falls through with an
empty ``dropped`` dict so the response shape stays consistent."""
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from turnstone.core.session_routes import (
SessionEndpointConfig,
make_cancel_handler,
)
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
def _raises_forensics(session, ui, *, was_running): # noqa: ARG001
raise RuntimeError("forensics blew up")
cfg = SessionEndpointConfig(
permission_gate=_require_admin_coordinator,
manager_lookup=lambda r: (mgr, None),
tenant_check=None,
not_found_label="coordinator not found",
audit_action_prefix="coordinator",
cancel_forensics=_raises_forensics,
)
handler = make_cancel_handler(cfg)
app = Starlette(routes=[Route("/v1/api/workstreams/{ws_id}/cancel", handler, methods=["POST"])])
app.add_middleware(_AuthMiddleware)
client = TestClient(app)
resp = client.post(f"/v1/api/workstreams/{ws.id}/cancel", headers=_COORD_HEADERS)
assert resp.status_code == 200
assert resp.json()["dropped"] == {}
def test_cancel_swallows_audit_emit_exception(storage):
"""``audit_emit`` failures are demoted to ``log.warning`` and the
cancel returns 200. Mirrors the same pattern in ``make_close_handler``
— telemetry bugs must not block recovery verbs."""
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from turnstone.core.session_routes import (
SessionEndpointConfig,
make_cancel_handler,
)
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
def _raises_audit(request, ws_id, ws_obj, force): # noqa: ARG001
raise RuntimeError("audit blew up")
cfg = SessionEndpointConfig(
permission_gate=_require_admin_coordinator,
manager_lookup=lambda r: (mgr, None),
tenant_check=None,
not_found_label="coordinator not found",
audit_action_prefix="coordinator",
)
handler = make_cancel_handler(cfg, audit_emit=_raises_audit)
app = Starlette(routes=[Route("/v1/api/workstreams/{ws_id}/cancel", handler, methods=["POST"])])
app.add_middleware(_AuthMiddleware)
client = TestClient(app)
resp = client.post(f"/v1/api/workstreams/{ws.id}/cancel", headers=_COORD_HEADERS)
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
def test_cancel_idle_workstream_does_not_broadcast_approval_resolved(storage):
"""Bug-1 from /review: the unconditional resolve_approval lift
leaked a stale ``approval_resolved`` SSE event on every idle
cancel. The fix gates the call on ``_pending_approval is not None``
so listeners don't see a phantom resolution. Asserts the
``approval_resolved`` event does NOT land on the listener queue
when no approval is pending."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
assert isinstance(ws.ui, ConsoleCoordinatorUI)
assert ws.ui._pending_approval is None # idle baseline
listener = ws.ui._register_listener()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(f"/v1/api/workstreams/{ws.id}/cancel", headers=_COORD_HEADERS)
assert resp.status_code == 200
seen_types = []
while not listener.empty():
seen_types.append(listener.get_nowait().get("type"))
assert "approval_resolved" not in seen_types
def test_coord_cancel_cascades_to_children(storage):
"""Cancelling a coordinator auto-propagates the cancel down its
spawned subtree (HYPOTHESIS.md cancellation appendix: cancel flows
down the subtree). The ``post_cancel`` hook fans ``coord_client.cancel``
over the direct children after the coordinator's own session is
cancelled."""
import json
from unittest.mock import MagicMock
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from turnstone.console.server import _cascade_cancel_to_children
from turnstone.core.session_routes import SessionEndpointConfig, make_cancel_handler
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
_seed_children(mgr._adapter, coord.id, ["child-1", "child-2"])
coord_client = MagicMock()
coord_client.cancel.return_value = {"status": "ok"}
coord.session = MagicMock()
coord.session._coord_client = coord_client
cfg = SessionEndpointConfig(
permission_gate=_require_admin_coordinator,
manager_lookup=lambda r: (mgr, None),
tenant_check=None,
not_found_label="coordinator not found",
audit_action_prefix="coordinator",
)
handler = make_cancel_handler(cfg, post_cancel=_cascade_cancel_to_children)
app = Starlette(routes=[Route("/v1/api/workstreams/{ws_id}/cancel", handler, methods=["POST"])])
app.state.coord_adapter = mgr._adapter
app.state.auth_storage = storage # for the cascade audit (sec-2)
app.add_middleware(_AuthMiddleware)
client = TestClient(app)
resp = client.post(f"/v1/api/workstreams/{coord.id}/cancel", headers=_COORD_HEADERS)
assert resp.status_code == 200
# The coordinator's own session was cancelled (owner first)...
coord.session.cancel.assert_called_once()
# ...and every direct child received a cancel (subtree propagation). The
# fan-out runs as the response BackgroundTask (perf-1: it does not block
# the cancel response); the TestClient drives it before returning.
cascaded = {c.args[0] for c in coord_client.cancel.call_args_list}
assert cascaded == {"child-1", "child-2"}
# sec-2: the cascade records a forensic audit row with the child lists.
events = [
e for e in storage.list_audit_events() if e["action"] == "coordinator.cancel_cascaded"
]
assert len(events) == 1
assert set(json.loads(events[0]["detail"])["cancelled"]) == {"child-1", "child-2"}
def test_coord_cancel_cascade_denied_for_service_token_without_grant(storage):
"""sec-1 regression: the destructive subtree cascade is gated at
``allow_service_bypass=False`` (the bar the removed stop_cascade held).
A service-scoped token without ``admin.coordinator`` can still cancel the
coordinator's own turn (the cancel route allows the service bypass) but
must NOT trigger the child cascade."""
from unittest.mock import MagicMock
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.server import _cascade_cancel_to_children
from turnstone.core.auth import AuthResult
from turnstone.core.session_routes import SessionEndpointConfig, make_cancel_handler
mgr = _build_mgr(storage)
coord = mgr.create(user_id="svc-user", name="coord-a")
_seed_children(mgr._adapter, coord.id, ["child-1", "child-2"])
coord_client = MagicMock()
coord_client.cancel.return_value = {"status": "ok"}
coord.session = MagicMock()
coord.session._coord_client = coord_client
class _ServiceAuth(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
request.state.auth_result = AuthResult(
user_id="svc-user",
scopes=frozenset({"read", "write", "approve", "service"}),
token_source="test",
permissions=frozenset(), # NO admin.coordinator grant
)
return await call_next(request)
cfg = SessionEndpointConfig(
permission_gate=_require_admin_coordinator,
manager_lookup=lambda r: (mgr, None),
tenant_check=None,
not_found_label="coordinator not found",
audit_action_prefix="coordinator",
)
handler = make_cancel_handler(cfg, post_cancel=_cascade_cancel_to_children)
app = Starlette(
routes=[Route("/v1/api/workstreams/{ws_id}/cancel", handler, methods=["POST"])],
middleware=[Middleware(_ServiceAuth)],
)
app.state.coord_adapter = mgr._adapter
app.state.auth_storage = storage
client = TestClient(app)
resp = client.post(f"/v1/api/workstreams/{coord.id}/cancel", json={})
# Owner's own cancel still succeeds (cancel route allows the service bypass)…
assert resp.status_code == 200
coord.session.cancel.assert_called_once()
# …but the destructive cascade is withheld — no child was cancelled.
assert coord_client.cancel.call_count == 0
def test_coord_cancel_cascade_failure_does_not_fail_owner_cancel(storage):
"""A cascade error must not strand the owner half-cancelled: the
``post_cancel`` exception is swallowed and the owner's cancel still
returns 200 (the owner's own session was already cancelled)."""
from unittest.mock import MagicMock
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from turnstone.core.session_routes import SessionEndpointConfig, make_cancel_handler
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session = MagicMock()
async def _boom(request, ws_id, ws): # noqa: ARG001
raise RuntimeError("cascade blew up")
cfg = SessionEndpointConfig(
permission_gate=_require_admin_coordinator,
manager_lookup=lambda r: (mgr, None),
tenant_check=None,
not_found_label="coordinator not found",
audit_action_prefix="coordinator",
)
handler = make_cancel_handler(cfg, post_cancel=_boom)
app = Starlette(routes=[Route("/v1/api/workstreams/{ws_id}/cancel", handler, methods=["POST"])])
app.add_middleware(_AuthMiddleware)
client = TestClient(app)
resp = client.post(f"/v1/api/workstreams/{coord.id}/cancel", headers=_COORD_HEADERS)
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
coord.session.cancel.assert_called_once()
# ---------------------------------------------------------------------------
# Events (SSE replay shape)
# ---------------------------------------------------------------------------
from tests._replay_helpers import make_replay_mocks as _make_coord_replay_mocks # noqa: E402
def test_coord_events_replay_yields_connected_first():
"""Pre-status-bar coord replay only re-injected pending_approval +
pending_plan_review. Post-status-bar parity with interactive
yields ``connected`` first so the dashboard's status bar populates
the model cell before any history arrives — mirrors the
interactive replay (turnstone/server.py:_interactive_events_replay)."""
from turnstone.console.server import _coord_events_replay
ws, ui, request = _make_coord_replay_mocks()
out = list(_coord_events_replay(ws, ui, request))
assert out[0]["type"] == "connected"
assert out[0]["model"] == "gpt-5"
assert out[0]["model_alias"] == "default"
assert out[0]["skip_permissions"] is False
def test_coord_events_replay_includes_status_only_when_last_usage_present():
"""The ``status`` event populates the per-tab token-usage bar on
resume. Skipped when ``session._last_usage`` is None (a freshly-
created coordinator that hasn't completed a turn) — matches
interactive behaviour."""
from turnstone.console.server import _coord_events_replay
ws, ui, request = _make_coord_replay_mocks()
out = list(_coord_events_replay(ws, ui, request))
assert "status" not in {ev["type"] for ev in out}
def test_coord_events_replay_status_payload_shape():
"""When ``last_usage`` exists, the replayed ``status`` event carries
every field the dashboard's updateStatusBar() reads — same shape
SessionUI.on_status emits live."""
from turnstone.console.server import _coord_events_replay
ws, ui, request = _make_coord_replay_mocks(
last_usage={
"prompt_tokens": 40000,
"completion_tokens": 6310,
"cache_creation_tokens": 100,
"cache_read_tokens": 50,
},
_ws_turn_tool_calls=3,
_ws_messages=7,
)
out = list(_coord_events_replay(ws, ui, request))
status = next(ev for ev in out if ev["type"] == "status")
assert status["prompt_tokens"] == 40000
assert status["completion_tokens"] == 6310
assert status["total_tokens"] == 46310
assert status["context_window"] == 100000
assert status["pct"] == round(46310 / 100000 * 100, 1)
assert status["effort"] == "medium"
assert status["tool_calls_this_turn"] == 3
assert status["turn_count"] == 7
assert status["cache_creation_tokens"] == 100
assert status["cache_read_tokens"] == 50
def test_coord_events_replay_skips_session_block_when_no_session():
"""Detached session (close-then-reopen race) — replay skips the
connected/status preamble and falls through to the pending-prompt
branches. Mirrors interactive's defensive guard at
turnstone/server.py:665."""
from turnstone.console.server import _coord_events_replay
ws, ui, _request = _make_coord_replay_mocks()
ws.session = None
out = list(_coord_events_replay(ws, ui, MagicMock()))
assert out == []
def test_coord_events_replay_yields_pending_approval():
"""The lifted coord ``events_replay`` callback yields, after the
connected preamble, the pending approval (if any). Pre-lift coord
pushed it onto the listener queue via ``put_nowait``; the lift
restructures as a generator the lifted body iterates and yields as
``data:`` lines, but the payload identity is preserved. Pure-read —
never mutates ``ui``."""
from turnstone.console.server import _coord_events_replay
ws, ui, request = _make_coord_replay_mocks(
_pending_approval={"type": "approve_request", "items": []},
)
out = list(_coord_events_replay(ws, ui, request))
types = [ev["type"] for ev in out]
# Status preamble is yielded first (no last_usage → no status); the
# pending-approval re-injection then matches the pre-lift body.
assert types[0] == "connected"
assert "approve_request" in types
def test_coord_events_replay_yields_cached_verdicts_after_pending_approval():
"""When the SSE stream reconnects mid-approval, the verdict-cache
replay must follow the pending_approval re-injection. Without this,
a refreshing tab sees the approve_request prompt but no judge chip
until the operator re-invokes the action — intent_verdict is a
one-shot SSE event with no late-subscriber push. Mirrors the
interactive replay path."""
from turnstone.console.server import _coord_events_replay
ws, ui, request = _make_coord_replay_mocks(
_pending_approval={
"type": "approve_request",
"items": [{"call_id": "c-1"}],
},
_llm_verdicts={
"c-1": {
"verdict_id": "v-1",
"call_id": "c-1",
"recommendation": "deny",
"risk_level": "high",
}
},
)
out = list(_coord_events_replay(ws, ui, request))
types = [ev["type"] for ev in out]
approve_idx = types.index("approve_request")
verdict_idx = types.index("intent_verdict")
assert approve_idx < verdict_idx
verdict = out[verdict_idx]
assert verdict["verdict_id"] == "v-1"
assert verdict["recommendation"] == "deny"
def test_coord_events_replay_skips_verdict_replay_without_pending_approval():
"""Verdict replay rides on top of pending_approval — no prompt,
no chip. Stale verdicts from a previously-resolved round must
not surface on a fresh connect."""
from turnstone.console.server import _coord_events_replay
ws, ui, request = _make_coord_replay_mocks(
_llm_verdicts={"old": {"verdict_id": "stale"}},
)
out = list(_coord_events_replay(ws, ui, request))
types = [ev["type"] for ev in out]
assert "intent_verdict" not in types
assert "approve_request" not in types
assert "plan_review" not in types
def test_coord_events_replay_yields_only_connected_when_no_pending():
"""A workstream with a session but no pending approval / plan
review and no last_usage yields just the ``connected`` preamble.
The lifted body falls through to the live loop immediately after."""
from turnstone.console.server import _coord_events_replay
ws, ui, request = _make_coord_replay_mocks()
out = list(_coord_events_replay(ws, ui, request))
assert [ev["type"] for ev in out] == ["connected"]
def test_coord_events_returns_404_on_missing_ws(storage):
"""Cross-kind / missing ws_id surfaces as 404 with
``cfg.not_found_label`` ("coordinator not found") via the lifted
body's ``mgr.get`` None-return contract."""
mgr = _build_mgr(storage)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get("/v1/api/workstreams/nonexistent-id/events", headers=_COORD_HEADERS)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Open (explicit rehydration)
# ---------------------------------------------------------------------------
def test_open_returns_already_loaded_when_in_memory(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1", name="live")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(f"/v1/api/workstreams/{ws.id}/open", headers=_COORD_HEADERS)
assert resp.status_code == 200
body = resp.json()
assert body["ws_id"] == ws.id
assert body.get("already_loaded") is True
def test_open_any_admin_coordinator_caller_succeeds_in_memory(storage):
# Trusted-team model: open is gated by admin.coordinator scope
# only; any authenticated caller can open any in-memory coordinator.
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/workstreams/{ws.id}/open",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 200
assert resp.json().get("already_loaded") is True
def test_open_rehydrates_when_not_in_memory(storage, monkeypatch):
mgr = _build_mgr(storage)
# The tenancy gate resolves the row from storage before rehydrating, so a
# legitimately-openable coordinator must exist there (it always does in
# production — open rehydrates a persisted row).
storage.register_workstream("coord-rehy", kind="coordinator", user_id="user-1")
rehydrated = MagicMock()
rehydrated.id = "coord-rehy"
rehydrated.name = "rehydrated"
rehydrated.user_id = "user-1"
monkeypatch.setattr(mgr, "open", MagicMock(return_value=rehydrated))
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post("/v1/api/workstreams/coord-rehy/open", headers=_COORD_HEADERS)
assert resp.status_code == 200
body = resp.json()
assert body["ws_id"] == "coord-rehy"
assert body["name"] == "rehydrated"
assert "already_loaded" not in body
# SessionManager.open takes a single positional arg now — no
# per-caller ownership / admin plumbing.
mgr.open.assert_called_once_with("coord-rehy")
def test_open_returns_404_when_unknown_ws_id(storage, monkeypatch):
mgr = _build_mgr(storage)
monkeypatch.setattr(mgr, "open", MagicMock(return_value=None))
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post("/v1/api/workstreams/nonexistent/open", headers=_COORD_HEADERS)
assert resp.status_code == 404
def test_open_503_on_coord_mgr_unavailable(storage):
client = _make_client(storage, coord_mgr=None)
resp = client.post("/v1/api/workstreams/any-ws/open", headers=_COORD_HEADERS)
assert resp.status_code == 503
def test_open_correlation_id_on_factory_failure(storage, monkeypatch):
mgr = _build_mgr(storage)
storage.register_workstream("bad-ws", kind="coordinator", user_id="user-1")
monkeypatch.setattr(mgr, "open", MagicMock(side_effect=RuntimeError("boom")))
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post("/v1/api/workstreams/bad-ws/open", headers=_COORD_HEADERS)
assert resp.status_code == 500
assert "correlation_id=" in resp.json()["error"]
def test_open_503_when_open_raises_value_error(storage, monkeypatch):
"""ValueError from the factory surfaces as 503 with the remediation text."""
mgr = _build_mgr(storage)
storage.register_workstream("bad-ws", kind="coordinator", user_id="user-1")
monkeypatch.setattr(mgr, "open", MagicMock(side_effect=ValueError("coord registry missing")))
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post("/v1/api/workstreams/bad-ws/open", headers=_COORD_HEADERS)
assert resp.status_code == 503
assert "registry missing" in resp.json()["error"]
# ---------------------------------------------------------------------------
# GET /v1/api/workstreams/{ws_id}/children — phase 3 tree view backend
# ---------------------------------------------------------------------------
def _seed_child(storage, parent_ws_id: str, ws_id: str, *, state: str = "idle") -> None:
storage.register_workstream(
ws_id,
node_id="node-a",
user_id="user-1",
name=f"child-{ws_id[:4]}",
kind="interactive",
parent_ws_id=parent_ws_id,
)
if state != "idle":
storage.update_workstream_state(ws_id, state)
def test_children_empty_for_new_coordinator(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(f"/v1/api/workstreams/{ws.id}/children", headers=_COORD_HEADERS)
assert resp.status_code == 200
body = resp.json()
assert body == {"items": [], "truncated": False}
def test_children_returns_interactive_children(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
_seed_child(storage, ws.id, "c" * 32)
_seed_child(storage, ws.id, "d" * 32, state="running")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(f"/v1/api/workstreams/{ws.id}/children", headers=_COORD_HEADERS)
assert resp.status_code == 200
body = resp.json()
assert len(body["items"]) == 2
states = {r["state"] for r in body["items"]}
assert states == {"idle", "running"}
kinds = {r["kind"] for r in body["items"]}
assert kinds == {"interactive"}
def test_children_any_admin_coordinator_caller_sees_subtree(storage):
# Trusted-team visibility: the children subtree is readable by any
# ``admin.coordinator`` caller.
mgr = _build_mgr(storage)
ws = mgr.create(user_id="owner")
_seed_child(storage, ws.id, "a" * 32)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(
f"/v1/api/workstreams/{ws.id}/children",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 200
assert len(resp.json()["items"]) == 1
def test_children_invalid_ws_id_400(storage):
mgr = _build_mgr(storage)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get("/v1/api/workstreams/INVALID-WS-SHOUT/children", headers=_COORD_HEADERS)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# GET /v1/api/workstreams/{ws_id}/tasks — phase 3 task pane backend
# ---------------------------------------------------------------------------
def test_tasks_empty_envelope_for_new_coordinator(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(f"/v1/api/workstreams/{ws.id}/tasks", headers=_COORD_HEADERS)
assert resp.status_code == 200
assert resp.json() == {"version": 1, "tasks": []}
def test_tasks_round_trips_stored_envelope(storage):
import json
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
envelope = {
"version": 1,
"tasks": [
{
"id": "tsk_abc",
"title": "Spawn analyzer",
"status": "in_progress",
"child_ws_id": "",
"created": "2026-04-17T00:00:00+00:00",
"updated": "2026-04-17T00:01:00+00:00",
}
],
}
storage.save_workstream_config(ws.id, {"tasks": json.dumps(envelope)})
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(f"/v1/api/workstreams/{ws.id}/tasks", headers=_COORD_HEADERS)
assert resp.status_code == 200
assert resp.json() == envelope
def test_tasks_corrupt_envelope_returns_empty(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
storage.save_workstream_config(ws.id, {"tasks": "NOT-JSON"})
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(f"/v1/api/workstreams/{ws.id}/tasks", headers=_COORD_HEADERS)
assert resp.status_code == 200
assert resp.json() == {"version": 1, "tasks": []}
def test_tasks_any_admin_coordinator_caller_can_read(storage):
# Trusted-team visibility: any ``admin.coordinator`` caller can
# read the tasks envelope.
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/workstreams/{ws.id}/tasks",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# GET /v1/api/cluster/ws/{ws_id}/detail — cluster-wide live inspect
# ---------------------------------------------------------------------------
_CLUSTER_HEADERS = {"X-Test-User": "user-1", "X-Test-Perms": "admin.cluster.inspect"}
def test_cluster_inspect_requires_permission(storage):
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get(
"/v1/api/cluster/ws/" + ("a" * 32) + "/detail",
headers={"X-Test-User": "u", "X-Test-Perms": "read"},
)
assert resp.status_code == 403
def test_cluster_inspect_unknown_ws_id_404(storage):
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get("/v1/api/cluster/ws/" + ("a" * 32) + "/detail", headers=_CLUSTER_HEADERS)
assert resp.status_code == 404
def test_cluster_inspect_invalid_ws_id_400(storage):
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get("/v1/api/cluster/ws/NOT-HEX/detail", headers=_CLUSTER_HEADERS)
assert resp.status_code == 400
def test_cluster_inspect_any_inspect_caller_sees_detail(storage):
# A project-less workstream has no tenancy to enforce, so any
# admin.cluster.inspect caller sees it (trusted-team default).
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/cluster/ws/{ws.id}/detail",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.cluster.inspect"},
)
assert resp.status_code == 200
assert resp.json()["persisted"]["ws_id"] == ws.id
def test_cluster_inspect_private_project_hidden_from_non_member(storage):
# admin.cluster.inspect gates the surface, but a workstream in a
# private project the caller isn't a member of is masked as 404 —
# no private-project oracle even for a cluster admin.
storage.create_project("proj-secret", "Secret", "alice")
storage.register_workstream(
"c" * 32,
node_id="console",
user_id="alice",
kind="coordinator",
project_id="proj-secret",
)
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get(
f"/v1/api/cluster/ws/{'c' * 32}/detail",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.cluster.inspect"},
)
assert resp.status_code == 404
def test_cluster_inspect_private_project_visible_to_member(storage):
# A project member (even a non-owner) still sees the persisted row.
storage.create_project("proj-secret", "Secret", "alice")
storage.add_project_member("proj-secret", "member-bob")
storage.register_workstream(
"c" * 32,
node_id="console",
user_id="alice",
kind="coordinator",
project_id="proj-secret",
)
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
resp = client.get(
f"/v1/api/cluster/ws/{'c' * 32}/detail",
headers={"X-Test-User": "member-bob", "X-Test-Perms": "admin.cluster.inspect"},
)
assert resp.status_code == 200
assert resp.json()["persisted"]["ws_id"] == "c" * 32
def test_cluster_inspect_coordinator_self_path(storage):
"""A coordinator row returns live from the in-process manager."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(f"/v1/api/cluster/ws/{ws.id}/detail", headers=_CLUSTER_HEADERS)
assert resp.status_code == 200
body = resp.json()
assert body["persisted"]["ws_id"] == ws.id
assert body["persisted"]["kind"] == "coordinator"
# Live is populated from the manager snapshot (pending_approval key signals
# we went through the coordinator branch, not the node-fetch branch).
assert body["live"] is not None
assert "pending_approval" in body["live"]
# Freshly created coordinator has no pending approval — the previous
# implementation read `not _approval_event.is_set()` which fires True
# on any unset event, making this flag spuriously True on every new
# coordinator. Regression guard.
assert body["live"]["pending_approval"] is False
assert body["live"]["activity_state"] == ""
assert isinstance(body["messages"], list)
def test_cluster_inspect_unloaded_coordinator_live_null(storage):
"""A persisted-but-not-loaded coordinator returns live: null, 200."""
mgr = _build_mgr(storage)
# Persist a coordinator row directly without loading into the manager.
storage.register_workstream(
"f" * 32,
node_id="console",
user_id="user-1",
name="offline-coord",
kind="coordinator",
parent_ws_id=None,
)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(f"/v1/api/cluster/ws/{'f' * 32}/detail", headers=_CLUSTER_HEADERS)
assert resp.status_code == 200
body = resp.json()
assert body["live"] is None
assert body["persisted"]["kind"] == "coordinator"
def _install_proxy_client(client: TestClient, transport: httpx.MockTransport) -> None:
"""Attach an httpx.AsyncClient backed by a MockTransport to the app state.
cluster_ws_detail's node-backed branch reads
``request.app.state.proxy_client`` + ``request.app.state.collector``
to fetch a node's dashboard. Both are normally wired in the lifespan;
tests short-circuit by injecting a proxy client here and stubbing
the collector's node lookup with a MagicMock.
"""
client.app.state.proxy_client = httpx.AsyncClient(transport=transport)
def _install_collector_with_node(client: TestClient, node_id: str, server_url: str) -> None:
"""Stub app.state.collector so _get_server_url returns server_url."""
collector = MagicMock()
collector.get_node_detail.return_value = {
"node_id": node_id,
"server_url": server_url,
}
client.app.state.collector = collector
def _seed_node_workstream(storage, *, ws_id: str, node_id: str, user_id: str = "user-1") -> None:
storage.register_workstream(
ws_id,
node_id=node_id,
user_id=user_id,
name=f"child-{ws_id[:4]}",
kind="interactive",
parent_ws_id=None,
)
def test_cluster_inspect_node_backed_success(storage):
"""Node returns a matching workstream entry in /dashboard — cluster_ws_detail
merges its live fields into the `live` block."""
mgr = _build_mgr(storage)
ws_id = "ab" * 16
_seed_node_workstream(storage, ws_id=ws_id, node_id="node-a")
payload = {
"workstreams": [
{
"ws_id": ws_id,
"state": "running",
"tokens": 512,
"context_ratio": 0.25,
"activity": "tool: bash",
"activity_state": "tool",
"tool_calls": 3,
"model": "gpt-5",
"model_alias": "default",
"title": "hello",
"name": "child",
}
]
}
def _handler(req: httpx.Request) -> httpx.Response:
assert req.url.path == "/v1/api/dashboard"
return httpx.Response(200, json=payload)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
_install_collector_with_node(client, "node-a", "http://node-a")
_install_proxy_client(client, httpx.MockTransport(_handler))
resp = client.get(f"/v1/api/cluster/ws/{ws_id}/detail", headers=_CLUSTER_HEADERS)
assert resp.status_code == 200
body = resp.json()
live = body["live"]
assert live is not None
assert live["state"] == "running"
assert live["tokens"] == 512
assert live["tool_calls"] == 3
# pending_approval synthesized from activity_state != "approval"
assert live["pending_approval"] is False
def test_cluster_inspect_node_backed_pending_approval_detail_passes_through(storage):
"""Node /dashboard returns pending_approval_detail → live block carries
it through verbatim. Regression guard for the projection allowlist
`_CLUSTER_WS_LIVE_KEYS`: dropping the key from the tuple silently
breaks inline approve/deny buttons on remote-node child rows even
though coord rows still work via the in-process synthesis branch."""
mgr = _build_mgr(storage)
ws_id = "f0" * 16
_seed_node_workstream(storage, ws_id=ws_id, node_id="node-a")
detail = {
"cycle_id": "cyc-bash",
"call_id": "c-bash",
"judge_pending": False,
"items": [
{
"call_id": "c-bash",
"header": "bash",
"preview": "$ rm -rf /tmp/x",
"func_name": "bash",
"approval_label": "bash",
"needs_approval": True,
"error": None,
"heuristic_verdict": None,
"judge_verdict": {
"recommendation": "deny",
"risk_level": "crit",
"tier": "llm",
},
}
],
}
payload = {
"workstreams": [
{
"ws_id": ws_id,
"state": "attention",
"activity_state": "approval",
"activity": "awaiting approval",
"tokens": 100,
"pending_approval_details": [detail],
}
]
}
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
_install_collector_with_node(client, "node-a", "http://node-a")
_install_proxy_client(client, httpx.MockTransport(lambda r: httpx.Response(200, json=payload)))
resp = client.get(f"/v1/api/cluster/ws/{ws_id}/detail", headers=_CLUSTER_HEADERS)
assert resp.status_code == 200
live = resp.json()["live"]
assert live["pending_approval"] is True # derived bool, existing behavior
assert live["pending_approval_details"] == [detail] # full payload passthrough
def test_cluster_inspect_node_backed_pending_approval_synthesized(storage):
"""activity_state=='approval' from the node synthesizes pending_approval=True."""
mgr = _build_mgr(storage)
ws_id = "cd" * 16
_seed_node_workstream(storage, ws_id=ws_id, node_id="node-a")
payload = {
"workstreams": [
{
"ws_id": ws_id,
"state": "attention",
"activity_state": "approval",
"activity": "awaiting approval",
"tokens": 100,
}
]
}
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
_install_collector_with_node(client, "node-a", "http://node-a")
_install_proxy_client(client, httpx.MockTransport(lambda r: httpx.Response(200, json=payload)))
resp = client.get(f"/v1/api/cluster/ws/{ws_id}/detail", headers=_CLUSTER_HEADERS)
assert resp.status_code == 200
assert resp.json()["live"]["pending_approval"] is True
def test_cluster_inspect_node_unreachable_live_null(storage):
"""httpx connect/timeout error → live: null, status 200."""
mgr = _build_mgr(storage)
ws_id = "de" * 16
_seed_node_workstream(storage, ws_id=ws_id, node_id="node-a")
def _handler(req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("node down")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
_install_collector_with_node(client, "node-a", "http://node-a")
_install_proxy_client(client, httpx.MockTransport(_handler))
resp = client.get(f"/v1/api/cluster/ws/{ws_id}/detail", headers=_CLUSTER_HEADERS)
assert resp.status_code == 200
assert resp.json()["live"] is None
def test_cluster_inspect_node_5xx_live_null(storage):
"""Non-2xx from the node → live: null, status 200."""
mgr = _build_mgr(storage)
ws_id = "ef" * 16
_seed_node_workstream(storage, ws_id=ws_id, node_id="node-a")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
_install_collector_with_node(client, "node-a", "http://node-a")
_install_proxy_client(client, httpx.MockTransport(lambda r: httpx.Response(503, text="down")))
resp = client.get(f"/v1/api/cluster/ws/{ws_id}/detail", headers=_CLUSTER_HEADERS)
assert resp.status_code == 200
assert resp.json()["live"] is None
def test_cluster_inspect_node_missing_entry_live_null(storage):
"""Node returned 200 but the target ws_id is not in its workstream list."""
mgr = _build_mgr(storage)
ws_id = "1a" * 16
_seed_node_workstream(storage, ws_id=ws_id, node_id="node-a")
payload = {
"workstreams": [
{"ws_id": "different-" + "x" * 24, "state": "idle"},
]
}
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
_install_collector_with_node(client, "node-a", "http://node-a")
_install_proxy_client(client, httpx.MockTransport(lambda r: httpx.Response(200, json=payload)))
resp = client.get(f"/v1/api/cluster/ws/{ws_id}/detail", headers=_CLUSTER_HEADERS)
assert resp.status_code == 200
assert resp.json()["live"] is None
def test_cluster_inspect_message_limit_clamped(storage):
"""Seed enough messages that the 200-row clamp must actually
execute, and assert the tail slice is correct — prior version
only checked `<= 200` on a fresh coordinator (0 messages), which
passed even if the clamp were stripped."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
# 250 messages — last 200 must come back, in chronological order.
for i in range(250):
storage.save_message(ws.id, role="user", content=f"msg-{i:04d}")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(
f"/v1/api/cluster/ws/{ws.id}/detail?message_limit=9999",
headers=_CLUSTER_HEADERS,
)
assert resp.status_code == 200
messages = resp.json()["messages"]
# Clamp took effect: exactly 200 rows back.
assert len(messages) == 200
# Chronological order preserved: oldest of the tail-200 first,
# newest last. The tail of 250 inserts is messages 50..249.
contents = [m.get("content") for m in messages]
assert contents[0] == "msg-0050"
assert contents[-1] == "msg-0249"
def test_cluster_inspect_zero_message_limit_returns_empty(storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1")
for i in range(10):
storage.save_message(ws.id, role="user", content=f"msg-{i}")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get(
f"/v1/api/cluster/ws/{ws.id}/detail?message_limit=0",
headers=_CLUSTER_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["messages"] == []
# ---------------------------------------------------------------------------
# _coordinator_rows tenant filter — regression for the cross-tenant leak
# ultrareview flagged in the cluster dashboard path
# ---------------------------------------------------------------------------
def test_coordinator_rows_filters_by_caller_identity(storage):
"""Non-admin callers get only their own coordinators. `list_all` must
never be reached for them — mirrors list_for_user's docstring
invariant, which the phase-3 dashboard merge originally bypassed."""
from unittest.mock import MagicMock
from turnstone.console.server import _coordinator_rows
mgr = _build_mgr(storage)
mgr.create(user_id="alice", name="alice-coord")
mgr.create(user_id="bob", name="bob-coord")
def _request_for(user_id: str, perms: frozenset[str]) -> MagicMock:
request = MagicMock()
request.app.state.coord_mgr = mgr
request.state.auth_result = AuthResult(
user_id=user_id,
scopes=frozenset({"read"}),
token_source="test",
permissions=perms,
)
return request
# Trusted-team visibility: every caller sees every coordinator.
for caller in ("alice", "bob", "admin-1"):
rows = _coordinator_rows(_request_for(caller, frozenset({"read"})))
assert {r["name"] for r in rows} == {"alice-coord", "bob-coord"}
def _persisted_rows_request(storage, mgr, user_id: str, perms: frozenset[str]):
"""Build a _coordinator_rows-shaped request with auth_storage wired
up so the persisted-rows merge path fires."""
from unittest.mock import MagicMock
request = MagicMock()
request.app.state.coord_mgr = mgr
request.app.state.auth_storage = storage
request.state.auth_result = AuthResult(
user_id=user_id,
scopes=frozenset({"read"}),
token_source="test",
permissions=perms,
)
return request
def test_coordinator_rows_surfaces_closed_coordinators_from_storage(storage):
"""Closed coordinators get popped from ``self._workstreams`` but
their persisted row stays in storage with ``state='closed'``. The
landing page polls _coordinator_rows via
/v1/api/cluster/workstreams?node=console — the persisted-rows
merge path surfaces closed rows so the operator can still see
them alongside active ones."""
from turnstone.console.server import _coordinator_rows
from turnstone.core.workstream import WorkstreamKind
mgr = _build_mgr(storage)
# Seed a persisted-but-not-loaded closed coordinator directly —
# register + soft-close via storage primitives.
storage.register_workstream(
"a" * 32,
node_id="console",
user_id="alice",
name="historical-coord",
state="closed",
kind=WorkstreamKind.COORDINATOR,
parent_ws_id=None,
)
# Also seed a live coordinator via the manager to prove merge.
mgr.create(user_id="alice", name="live-coord")
request = _persisted_rows_request(storage, mgr, "alice", frozenset({"read"}))
rows = _coordinator_rows(request)
names = {r["name"] for r in rows}
assert names == {"live-coord", "historical-coord"}
# Closed coord carries its persisted state so the UI can render
# it with the correct state glyph.
closed = next(r for r in rows if r["name"] == "historical-coord")
assert closed["state"] == "closed"
assert closed["kind"] == "coordinator"
def test_coordinator_rows_dedupes_by_ws_id_in_memory_wins(storage):
"""When a coordinator is both in-memory (manager) AND in storage,
_coordinator_rows must prefer the in-memory row so live session
state (model / model_alias / current state) stays authoritative.
The storage row has stale fields after every restart / refresh,
so merging it twice is strictly worse."""
from turnstone.console.server import _coordinator_rows
mgr = _build_mgr(storage)
live = mgr.create(user_id="alice", name="alice-live")
# Persist an explicit storage-only shape for the SAME ws_id —
# mgr.create already did this, but we deliberately corrupt the
# stored row to prove the in-memory row wins. Update the state
# to something the manager would never produce so the dedup check
# is unambiguous.
storage.update_workstream_state(live.id, "error")
request = _persisted_rows_request(storage, mgr, "alice", frozenset({"read"}))
rows = _coordinator_rows(request)
assert len(rows) == 1
assert rows[0]["id"] == live.id
# In-memory WorkstreamState wins over the persisted "error" tweak
# — the manager reports "idle" for a freshly-created coordinator.
assert rows[0]["state"] == "idle"
def test_coordinator_rows_persisted_cluster_wide(storage):
# Trusted-team visibility: every caller sees every persisted row,
# including rows from other identities and orphan (empty-user_id)
# rows. ``user_id`` stays on the response as metadata.
from turnstone.console.server import _coordinator_rows
from turnstone.core.workstream import WorkstreamKind
mgr = _build_mgr(storage)
storage.register_workstream(
"a" * 32,
node_id="console",
user_id="alice",
name="alice-closed",
state="closed",
kind=WorkstreamKind.COORDINATOR,
parent_ws_id=None,
)
storage.register_workstream(
"b" * 32,
node_id="console",
user_id="bob",
name="bob-closed",
state="closed",
kind=WorkstreamKind.COORDINATOR,
parent_ws_id=None,
)
storage.register_workstream(
"c" * 32,
node_id="console",
user_id="", # orphan / system row
name="orphan-closed",
state="closed",
kind=WorkstreamKind.COORDINATOR,
parent_ws_id=None,
)
for caller, perms in (
("alice", frozenset({"read"})),
("bob", frozenset({"read"})),
("admin-1", frozenset({"read", "admin.users"})),
):
request = _persisted_rows_request(storage, mgr, caller, perms)
rows = _coordinator_rows(request)
assert {r["name"] for r in rows} == {"alice-closed", "bob-closed", "orphan-closed"}
def test_coordinator_rows_surface_persisted_title(storage):
"""Regression for the coordinator-title-persistence bug.
The LLM auto-title (``update_workstream_title``) and the user alias
(``set_workstream_alias``) live only in ``workstreams.title`` /
``workstreams.alias``. ``_coordinator_rows`` must resolve the
display name ``alias > title > name`` from the persisted row for BOTH
lanes — the in-memory ``ws.name`` is the synthetic ``ws-xxxx``
placeholder. Before the fix the read path hardcoded ``title=""`` and
used ``ws.name`` / the ``name`` column, so a generated title was
written but never read back: it reverted to ``ws-xxxx`` on every
dashboard refresh."""
from turnstone.console.server import _coordinator_rows
from turnstone.core.workstream import WorkstreamKind
mgr = _build_mgr(storage)
# In-memory lane: a LIVE coordinator titled after creation. The
# manager assigned the placeholder ``ws.name``; the title is in the DB.
live = mgr.create(user_id="alice", name="ws-abcd")
storage.update_workstream_title(live.id, "Refactor the auth layer")
# Persisted lane: a closed coordinator (evicted from the manager)
# carrying BOTH a title and a user alias — the alias must win.
storage.register_workstream(
"f" * 32,
node_id="console",
user_id="bob",
name="ws-f0f0",
state="closed",
kind=WorkstreamKind.COORDINATOR,
parent_ws_id=None,
)
storage.update_workstream_title("f" * 32, "auto-generated title")
assert storage.set_workstream_alias("f" * 32, "Bob's pinned name")
request = _persisted_rows_request(storage, mgr, "alice", frozenset({"read"}))
rows = {r["id"]: r for r in _coordinator_rows(request)}
live_row = rows[live.id]
assert live_row["name"] == "Refactor the auth layer"
assert live_row["title"] == "Refactor the auth layer"
closed_row = rows["f" * 32]
assert closed_row["name"] == "Bob's pinned name" # alias > title > name
assert closed_row["title"] == "auto-generated title"
# ---------------------------------------------------------------------------
# Stage 2 P1.5 — coord attachment surface parity with interactive
# ---------------------------------------------------------------------------
class TestCoordinatorAttachments:
"""The lifted ``make_attachment_handlers`` factory exposes
upload / list / get_content / delete on coord workstreams using
the same kind-agnostic storage layer interactive uses. These
tests exercise the surface end-to-end via TestClient."""
def _upload(self, client, ws_id, *, name="hello.md", body=b"hi", mime="text/markdown"):
files = {"file": (name, body, mime)}
resp = client.post(
f"/v1/api/workstreams/{ws_id}/attachments",
files=files,
headers=_COORD_HEADERS,
)
assert resp.status_code == 200, resp.text
return resp.json()
def test_upload_round_trip_lists_pending(self, storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1", name="c1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
info = self._upload(client, ws.id, name="note.md", body=b"hello world")
assert info["filename"] == "note.md"
assert info["kind"] == "text"
assert info["size_bytes"] == len(b"hello world")
listing = client.get(f"/v1/api/workstreams/{ws.id}/attachments", headers=_COORD_HEADERS)
assert listing.status_code == 200
ids = [a["attachment_id"] for a in listing.json()["attachments"]]
assert info["attachment_id"] in ids
def test_get_content_returns_raw_bytes(self, storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1", name="c1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
info = self._upload(
client, ws.id, name="data.json", body=b'{"k":1}', mime="application/json"
)
resp = client.get(
f"/v1/api/workstreams/{ws.id}/attachments/{info['attachment_id']}/content",
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
# Text kinds force text/plain to avoid same-origin HTML/SVG rendering.
assert resp.headers["content-type"].startswith("text/plain")
assert resp.content == b'{"k":1}'
def test_delete_removes_pending(self, storage):
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1", name="c1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
info = self._upload(client, ws.id)
resp = client.delete(
f"/v1/api/workstreams/{ws.id}/attachments/{info['attachment_id']}",
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert resp.json() == {"status": "deleted"}
listing = client.get(f"/v1/api/workstreams/{ws.id}/attachments", headers=_COORD_HEADERS)
ids = [a["attachment_id"] for a in listing.json()["attachments"]]
assert info["attachment_id"] not in ids
def test_send_with_attachment_ids_consumes_pending(self, storage):
"""End-to-end: stage an attachment, then ``coord_send`` it. The send
resolves the staged upload from the buffer (and the committing session
writes it content-addressed); the response carries the attached id."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1", name="c1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
info = self._upload(client, ws.id)
resp = client.post(
f"/v1/api/workstreams/{ws.id}/send",
json={"message": "hi", "attachment_ids": [info["attachment_id"]]},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
# Surfacing parity with interactive: response carries the
# attached / dropped lists even when no drops occurred.
assert body["attached_ids"] == [info["attachment_id"]]
assert body["dropped_attachment_ids"] == []
def test_send_response_includes_attached_ids_field_when_no_attachments(self, storage):
"""The unified response shape always carries ``attached_ids`` /
``dropped_attachment_ids`` so SDK consumers don't have to
branch on whether attachments were involved."""
mgr = _build_mgr(storage)
ws = mgr.create(user_id="user-1", name="c1")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{ws.id}/send",
json={"message": "no attachments here"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["attached_ids"] == []
assert body["dropped_attachment_ids"] == []
def test_coord_attachment_endpoints_404_on_interactive_ws_id(self, storage):
"""Security regression: an ``admin.coordinator``-scoped caller
must NOT be able to read or mutate attachments on
**interactive** workstreams via the coord attachment surface.
The kind-strict resolver returns 404 (no storage fallback) so
a cross-kind ws_id never resolves to its owner."""
from turnstone.core.workstream import WorkstreamKind
# Persist an interactive workstream row directly — never loaded
# into the coord_mgr.
interactive_ws_id = "i" * 32
storage.register_workstream(
interactive_ws_id,
node_id="some-node",
user_id="alice",
name="alice-interactive",
kind=WorkstreamKind.INTERACTIVE,
parent_ws_id=None,
)
mgr = _build_mgr(storage)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
# Upload attempt — must 404, not 200.
files = {"file": ("note.md", b"sneaky", "text/markdown")}
resp = client.post(
f"/v1/api/workstreams/{interactive_ws_id}/attachments",
files=files,
headers=_COORD_HEADERS,
)
assert resp.status_code == 404, resp.text
# List, get-content, delete — same kind-strict 404 behaviour.
resp = client.get(
f"/v1/api/workstreams/{interactive_ws_id}/attachments",
headers=_COORD_HEADERS,
)
assert resp.status_code == 404
resp = client.get(
f"/v1/api/workstreams/{interactive_ws_id}/attachments/anything/content",
headers=_COORD_HEADERS,
)
assert resp.status_code == 404
resp = client.delete(
f"/v1/api/workstreams/{interactive_ws_id}/attachments/anything",
headers=_COORD_HEADERS,
)
assert resp.status_code == 404