refactor(server): mount coord-only verbs through register_coord_verbs

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.
This commit is contained in:
Patrick Buckley
2026-04-24 15:05:28 -07:00
committed by Patrick Buckley
parent e1ee84af42
commit 377bd58b67
3 changed files with 136 additions and 0 deletions
+41
View File
@@ -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
+18
View File
@@ -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=[
+77
View File
@@ -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"],
)
)