From 377bd58b679e431eded545463275ab6f9c3f6c78 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Fri, 24 Apr 2026 15:05:28 -0700 Subject: [PATCH] refactor(server): mount coord-only verbs through register_coord_verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 Priority 0 Step 0.3 — adds ``CoordVerbHandlers`` + ``register_coord_verbs`` to ``turnstone.core.session_routes`` and wires the seven coord-only verbs (``children`` / ``tasks`` / ``metrics`` / ``trust`` / ``restrict`` / ``stop_cascade`` / ``close_all_children``) through it on the console. These verbs are legitimately kind-specific — they read or mutate state (children registry, parent quota, trust / restrict policy, cascade controls) that doesn't exist on interactive workstreams — so they live on a Protocol distinct from ``SessionRouteHandlers``. The unified URL prefix ``/api/workstreams/{ws_id}/`` is shared with the session verbs; the separate registrar call keeps the kind separation explicit at the wiring site. Legacy ``/api/coordinator/{ws_id}/{verb}`` paths stay live during the transition; both URL shapes resolve to the same handler function. Step 0.4 deletes the legacy shape. The route-table walk in ``test_session_routes`` now covers all eighteen verb pairs (eleven session + seven coord-only) so a future drift between legacy and unified handlers fails CI. --- tests/test_session_routes.py | 41 +++++++++++++++++ turnstone/console/server.py | 18 ++++++++ turnstone/core/session_routes.py | 77 ++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) diff --git a/tests/test_session_routes.py b/tests/test_session_routes.py index 7a313f58..65deb59e 100644 --- a/tests/test_session_routes.py +++ b/tests/test_session_routes.py @@ -22,8 +22,10 @@ from starlette.responses import JSONResponse from starlette.routing import Route from turnstone.core.session_routes import ( + CoordVerbHandlers, SessionRouteConfig, SessionRouteHandlers, + register_coord_verbs, register_session_routes, ) @@ -202,6 +204,35 @@ def test_specific_verbs_register_before_bare_detail() -> None: assert paths.index("/api/workstreams/{ws_id}/events") < detail_idx +def test_register_coord_verbs_mounts_expected_paths() -> None: + """``register_coord_verbs`` mounts the seven coord-only verbs + at the unified prefix.""" + routes: list[Any] = [] + register_coord_verbs( + routes, + prefix="/api/workstreams", + handlers=CoordVerbHandlers( + children=_stub, + tasks=_stub, + metrics=_stub, + trust=_stub, + restrict=_stub, + stop_cascade=_stub, + close_all_children=_stub, + ), + ) + paths = {(p, m) for p, m in _route_paths(routes)} + assert paths == { + ("/api/workstreams/{ws_id}/children", frozenset({"GET", "HEAD"})), + ("/api/workstreams/{ws_id}/tasks", frozenset({"GET", "HEAD"})), + ("/api/workstreams/{ws_id}/metrics", frozenset({"GET", "HEAD"})), + ("/api/workstreams/{ws_id}/trust", frozenset({"POST"})), + ("/api/workstreams/{ws_id}/restrict", frozenset({"POST"})), + ("/api/workstreams/{ws_id}/stop_cascade", frozenset({"POST"})), + ("/api/workstreams/{ws_id}/close_all_children", frozenset({"POST"})), + } + + def test_console_create_app_exposes_unified_workstream_paths() -> None: """The console's ``create_app`` mounts coord verbs at both the legacy ``/api/coordinator/`` shape and the unified @@ -274,6 +305,16 @@ def test_console_unified_paths_route_to_legacy_handlers() -> None: "/api/coordinator/{ws_id}/events": "/api/workstreams/{ws_id}/events", "/api/coordinator/{ws_id}/history": "/api/workstreams/{ws_id}/history", "/api/coordinator/{ws_id}": "/api/workstreams/{ws_id}", + # Step 0.3: coord-only verbs. + "/api/coordinator/{ws_id}/children": "/api/workstreams/{ws_id}/children", + "/api/coordinator/{ws_id}/tasks": "/api/workstreams/{ws_id}/tasks", + "/api/coordinator/{ws_id}/metrics": "/api/workstreams/{ws_id}/metrics", + "/api/coordinator/{ws_id}/trust": "/api/workstreams/{ws_id}/trust", + "/api/coordinator/{ws_id}/restrict": "/api/workstreams/{ws_id}/restrict", + "/api/coordinator/{ws_id}/stop_cascade": "/api/workstreams/{ws_id}/stop_cascade", + "/api/coordinator/{ws_id}/close_all_children": ( + "/api/workstreams/{ws_id}/close_all_children" + ), } # Walked Route paths are relative to their parent Mount; the # ``/v1`` prefix is applied at request time, not stored on the diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 1b444154..6e6dc0d2 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -55,8 +55,10 @@ from turnstone.core.auth import ( ) from turnstone.core.rendezvous import NoAvailableNodeError from turnstone.core.session_routes import ( + CoordVerbHandlers, SessionRouteConfig, SessionRouteHandlers, + register_coord_verbs, register_session_routes, ) from turnstone.core.skill_kind import SkillKind @@ -10171,6 +10173,22 @@ def create_app( history=coordinator_history, ), ) + # Step 0.3: coord-only verbs (children registry, quotas, trust / + # restrict policy, cascade controls) mount alongside under the + # same unified prefix. + register_coord_verbs( + coord_workstream_routes, + prefix="/api/workstreams", + handlers=CoordVerbHandlers( + children=coordinator_children, + tasks=coordinator_tasks, + metrics=coordinator_metrics, + trust=coordinator_trust, + restrict=coordinator_restrict, + stop_cascade=coordinator_stop_cascade, + close_all_children=coordinator_close_all_children, + ), + ) app = Starlette( routes=[ diff --git a/turnstone/core/session_routes.py b/turnstone/core/session_routes.py index 254575bb..2b558959 100644 --- a/turnstone/core/session_routes.py +++ b/turnstone/core/session_routes.py @@ -255,3 +255,80 @@ def register_session_routes( # suffixed patterns above win for ``{ws_id}/...`` paths. if handlers.detail is not None: routes.append(Route(f"{p}/{{ws_id}}", handlers.detail, methods=["GET"])) + + +@dataclass(frozen=True) +class CoordVerbHandlers: + """Bundle of coord-only HTTP handler callables. + + These verbs are legitimately kind-specific (they read or mutate + coord-only state — children registry, parent quota, trust / + restrict policy, cascade controls) so they live on a separate + Protocol from :class:`SessionRouteHandlers`. Mounted alongside + the shared session verbs at the same ``/api/workstreams/{ws_id}/`` + prefix so the URL surface stays unified, but registered through + a distinct call so the kind separation is explicit at the wiring + site. + + Step 0.3 placeholder: handlers live in + ``turnstone/console/server.py`` and are passed in here; the + body-convergence follow-on lifts them into a coord-specific + module. + """ + + children: Handler # GET {prefix}/{ws_id}/children + tasks: Handler # GET {prefix}/{ws_id}/tasks + metrics: Handler # GET {prefix}/{ws_id}/metrics + trust: Handler # POST {prefix}/{ws_id}/trust + restrict: Handler # POST {prefix}/{ws_id}/restrict + stop_cascade: Handler # POST {prefix}/{ws_id}/stop_cascade + close_all_children: Handler # POST {prefix}/{ws_id}/close_all_children + + +def register_coord_verbs( + routes: list[BaseRoute], + *, + prefix: str, + mgr: SessionManager | None = None, + handlers: CoordVerbHandlers, +) -> None: + """Mount coord-only verbs at the unified ``{prefix}/{ws_id}/...`` shape. + + Call ordering vs :func:`register_session_routes` doesn't matter + in practice — Starlette's default ``str`` path converter is + single-segment, so ``{ws_id}/{verb}`` patterns can never collide + with the bare ``{ws_id}`` detail GET registered by + ``register_session_routes``. The body-convergence follow-on will + additionally have these handlers 404 on non-coord ws_ids via the + manager's kind check; today the legacy ``admin.coordinator`` + permission gate and ``_require_coord_mgr`` 503 are the + enforcement. + + Args: + routes: list to extend; typically the inner ``Mount`` route + list a Starlette app uses. + prefix: URL prefix relative to the mount, e.g. + ``"/api/workstreams"``. + mgr: the coord session manager when available at + app-construction time. Optional today because the console + builds its coord manager in the lifespan; becomes required + in the body-convergence follow-on. + handlers: bundle of handler callables for the coord-only + verbs. + """ + _ = mgr # forward-wired; see :func:`register_session_routes` + + p = prefix.rstrip("/") + routes.append(Route(f"{p}/{{ws_id}}/children", handlers.children, methods=["GET"])) + routes.append(Route(f"{p}/{{ws_id}}/tasks", handlers.tasks, methods=["GET"])) + routes.append(Route(f"{p}/{{ws_id}}/metrics", handlers.metrics, methods=["GET"])) + routes.append(Route(f"{p}/{{ws_id}}/trust", handlers.trust, methods=["POST"])) + routes.append(Route(f"{p}/{{ws_id}}/restrict", handlers.restrict, methods=["POST"])) + routes.append(Route(f"{p}/{{ws_id}}/stop_cascade", handlers.stop_cascade, methods=["POST"])) + routes.append( + Route( + f"{p}/{{ws_id}}/close_all_children", + handlers.close_all_children, + methods=["POST"], + ) + )