mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
edf52016ac
New ``make_list_handler(cfg)`` and ``make_saved_handler(cfg)``
factories in ``turnstone/core/session_routes.py`` replace four
pre-lift bodies (interactive ``list_workstreams`` +
``list_saved_workstreams``; coord ``coordinator_list`` +
``coordinator_saved``). Same factory + capability-flag pattern as
the merged cancel / open / events / create lifts.
Four new ``SessionEndpointConfig`` fields:
- ``list_resolve_titles: ListResolveTitles | None`` — bulk lookup
``(ws_ids) -> {ws_id: title-or-None}``. Interactive wires
``get_workstream_display_names`` (new bulk helper added on the
storage layer + memory.py); the lifted body resolves every active
row in ONE ``SELECT ... WHERE ws_id IN (...)`` instead of the
pre-lift N+1 (one SELECT per row).
- ``list_kind: WorkstreamKind | None`` — explicit kind classifier
for the saved-list storage filter. Replaces the initial draft's
``audit_action_prefix == "coordinator"`` string compare which
would have silently leaked INTERACTIVE rows for any future kind
whose audit prefix didn't match. Required when a kind mounts
list/saved; misconfig surfaces as a 500 with a clear log line.
- ``saved_state_filter: str | None`` — coord wires ``"closed"``;
interactive wires ``None``.
- ``saved_loaded_lookup: SavedLoadedLookup | None`` — coord-only
defence-in-depth filter that excludes ws_ids in the warm pool.
Behaviour changes (all observable in CHANGELOG):
- **Active-list row shape converges on always-include** ``{ws_id,
name, state, kind, parent_ws_id, user_id}``. Interactive renames
``id`` → ``ws_id``; both kinds populate every field (coord adds
kind + parent_ws_id; interactive adds user_id).
- **Top-level response key converges on ``"workstreams"``** on
both endpoints. Coord ``coordinators`` key removed — coord is a
1.5.0aN-only surface (never shipped stable) so the convergence
has no compat shim; SDK / frontend consumers swap once.
- **Storage + manager-lock work moved off the event loop on
interactive**. ``list_workstreams_with_history`` runs through
``asyncio.to_thread`` on both kinds (matches coord's pre-existing
perf-2 pattern from the saved-coordinators review); ``mgr.list_all``
+ per-row work also offloaded.
- **N+1 storage round-trips on /v1/api/workstreams eliminated**.
Pre-lift interactive resolved the alias for every active row in a
separate SELECT (up to 50 round-trips per dashboard refresh on a
saturated node). Lifted body issues one bulk SELECT.
Pydantic schemas: ``WorkstreamInfo.id`` renamed → ``ws_id``,
``WorkstreamInfo.user_id`` field added. ``CoordinatorInfo`` and
``CoordinatorListResponse`` removed (folded into the unified
``WorkstreamInfo`` / ``ListWorkstreamsResponse``). OpenAPI spec
snapshots regenerated. TS SDK types updated (``WorkstreamInfo``
interface gains ws_id + the always-include fields); TS test
mock + assertion updated to match.
``GET /v1/api/dashboard`` is intentionally NOT in this PR's scope
and still returns rows keyed on ``id``. Tracked as a separate
cleanup PR (tombstone-note added at the dashboard handler).
/review pipeline run; the four Major findings + one Minor + six
nits all addressed in the same commit:
- M1: TS SDK ``WorkstreamInfo`` interface stale (id: string) →
renamed + fields added.
- M2: TS SDK test masked the type-mismatch with stale mock → updated.
- M3: N+1 alias resolution on active list → bulk
``get_workstream_display_names`` helper + ``list_resolve_titles``
bulk cfg hook.
- M4: Missing interactive parity regression test for unified row
shape → mirror of coord's added in test_server_authz.py.
- Mi1: ``audit_action_prefix`` string-compare deriving kind →
explicit ``cfg.list_kind: WorkstreamKind`` field.
- Six nits: redundant inner asyncio import, forward-ref quotes on
Awaitable, duplicated frontend comments, dashboard ``id`` field
has no tombstone-note, empty-coord_mgr short-circuit on
``saved_loaded_lookup``.
4512 tests passing; ruff + mypy clean.
152 lines
4.5 KiB
Python
152 lines
4.5 KiB
Python
"""Tests for synchronous SDK wrappers (TurnstoneServer, TurnstoneConsole)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
|
|
from turnstone.sdk._sync import _SyncRunner
|
|
from turnstone.sdk.console import AsyncTurnstoneConsole, TurnstoneConsole
|
|
from turnstone.sdk.server import AsyncTurnstoneServer, TurnstoneServer
|
|
|
|
|
|
def _json_response(data: dict, status: int = 200) -> httpx.Response:
|
|
return httpx.Response(status, json=data)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _SyncRunner
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sync_runner_basic():
|
|
"""_SyncRunner can execute a simple async coroutine."""
|
|
runner = _SyncRunner()
|
|
try:
|
|
import asyncio
|
|
|
|
async def _add(a: int, b: int) -> int:
|
|
await asyncio.sleep(0)
|
|
return a + b
|
|
|
|
result = runner.run(_add(1, 2))
|
|
assert result == 3
|
|
finally:
|
|
runner.close()
|
|
|
|
|
|
def test_sync_runner_iter():
|
|
"""_SyncRunner.run_iter iterates over an async generator."""
|
|
runner = _SyncRunner()
|
|
try:
|
|
|
|
async def _gen():
|
|
for i in range(3):
|
|
yield i
|
|
|
|
items = list(runner.run_iter(_gen()))
|
|
assert items == [0, 1, 2]
|
|
finally:
|
|
runner.close()
|
|
|
|
|
|
def test_sync_runner_iter_empty():
|
|
"""_SyncRunner.run_iter handles empty async generator via sentinel."""
|
|
runner = _SyncRunner()
|
|
try:
|
|
|
|
async def _empty():
|
|
return
|
|
yield # pragma: no cover # makes this an async generator
|
|
|
|
items = list(runner.run_iter(_empty()))
|
|
assert items == []
|
|
finally:
|
|
runner.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TurnstoneServer (sync)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sync_server_list_workstreams():
|
|
"""Sync server client delegates to async and returns correct model."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return _json_response({"workstreams": [{"ws_id": "ws1", "name": "test", "state": "idle"}]})
|
|
|
|
# We need to create the async client with a mock transport,
|
|
# then wrap it in the sync client
|
|
transport = httpx.MockTransport(handler)
|
|
hc = httpx.AsyncClient(transport=transport, base_url="http://test")
|
|
async_client = AsyncTurnstoneServer(httpx_client=hc)
|
|
|
|
server = TurnstoneServer.__new__(TurnstoneServer)
|
|
server._runner = _SyncRunner()
|
|
server._async = async_client
|
|
|
|
try:
|
|
resp = server.list_workstreams()
|
|
assert len(resp.workstreams) == 1
|
|
# Row key renamed id → ws_id in the Stage 2 list-verb lift.
|
|
assert resp.workstreams[0].ws_id == "ws1"
|
|
finally:
|
|
server.close()
|
|
|
|
|
|
def test_sync_server_context_manager():
|
|
"""TurnstoneServer can be used as a context manager."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return _json_response(
|
|
{"status": "ok", "version": "0.3.0", "uptime_seconds": 1.0, "model": "gpt-5"}
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
hc = httpx.AsyncClient(transport=transport, base_url="http://test")
|
|
async_client = AsyncTurnstoneServer(httpx_client=hc)
|
|
|
|
server = TurnstoneServer.__new__(TurnstoneServer)
|
|
server._runner = _SyncRunner()
|
|
server._async = async_client
|
|
|
|
with server as s:
|
|
resp = s.health()
|
|
assert resp.status == "ok"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TurnstoneConsole (sync)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sync_console_overview():
|
|
"""Sync console client delegates to async and returns correct model."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return _json_response(
|
|
{
|
|
"nodes": 1,
|
|
"workstreams": 3,
|
|
"states": {"idle": 3},
|
|
"aggregate": {"total_tokens": 100, "total_tool_calls": 0},
|
|
"version_drift": False,
|
|
"versions": ["0.3.0"],
|
|
}
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
hc = httpx.AsyncClient(transport=transport, base_url="http://test")
|
|
async_client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
|
|
console = TurnstoneConsole.__new__(TurnstoneConsole)
|
|
console._runner = _SyncRunner()
|
|
console._async = async_client
|
|
|
|
try:
|
|
resp = console.overview()
|
|
assert resp.nodes == 1
|
|
assert resp.workstreams == 3
|
|
finally:
|
|
console.close()
|