refactor(routes): wire DELETE on path-keyed workstreams/{ws_id}/send

Pre-flight for the legacy URL adapter removal: the path-keyed
`/v1/api/workstreams/{ws_id}/send` route only mounted POST today;
the dequeue handler was reachable only via the legacy
`DELETE /v1/api/send` body-keyed URL through `_make_method_dispatch`.

Add a new `dequeue: Handler | None = None` slot on
`SharedSessionVerbHandlers` next to `send`, mounted as a second
`Route` on the same path with `methods=["DELETE"]` (two distinct
Routes rather than collapsing methods on one Route — different
handler callables, and collapsing would force the same
method-dispatch wrapper this cleanup is tearing out).

Wire `dequeue=dequeue_handler` in `turnstone/server.py`'s
`SharedSessionVerbHandlers(...)` call so DELETE on the path-keyed
shape works in the same merge as the legacy mount removal.

Adds a regression-locking test covering both the POST+DELETE and
the dequeue-alone cases.
This commit is contained in:
Patrick Buckley
2026-04-26 20:17:52 -07:00
parent fef266dbd9
commit ae4a84e552
3 changed files with 32 additions and 0 deletions
+28
View File
@@ -119,6 +119,34 @@ def test_attachment_routes_mount_when_quartet_provided() -> None:
) in paths
def test_send_mounts_post_and_delete_when_dequeue_provided() -> None:
"""``handlers.send`` mounts POST {prefix}/{ws_id}/send and
``handlers.dequeue`` mounts DELETE on the same path. The two
routes register as separate ``Route`` entries with disjoint
method sets — Starlette dispatches by (path, method)."""
routes: list[Any] = []
register_session_routes(
routes,
prefix="/api/workstreams",
handlers=SharedSessionVerbHandlers(send=_stub, dequeue=_stub),
)
paths = {(p, m) for p, m in _route_paths(routes)}
assert ("/api/workstreams/{ws_id}/send", frozenset({"POST"})) in paths
assert ("/api/workstreams/{ws_id}/send", frozenset({"DELETE"})) in paths
# ``dequeue`` is independent of ``send`` — providing it alone
# mounts only the DELETE half (no POST regression).
routes_dequeue_only: list[Any] = []
register_session_routes(
routes_dequeue_only,
prefix="/api/workstreams",
handlers=SharedSessionVerbHandlers(dequeue=_stub),
)
paths_dequeue_only = {(p, m) for p, m in _route_paths(routes_dequeue_only)}
assert ("/api/workstreams/{ws_id}/send", frozenset({"DELETE"})) in paths_dequeue_only
assert ("/api/workstreams/{ws_id}/send", frozenset({"POST"})) not in paths_dequeue_only
def test_close_legacy_mounts_when_handler_provided() -> None:
"""The legacy body-keyed close (``POST {prefix}/close``) mounts
when ``handlers.close_legacy`` is non-``None`` — there is no
+3
View File
@@ -482,6 +482,7 @@ class SharedSessionVerbHandlers:
# Per-``{ws_id}`` interaction (coord shape today; interactive
# adopts these in Priority 1's worker dispatch unification)
send: Handler | None = None # POST {prefix}/{ws_id}/send
dequeue: Handler | None = None # DELETE {prefix}/{ws_id}/send
approve: Handler | None = None # POST {prefix}/{ws_id}/approve
plan: Handler | None = None # POST {prefix}/{ws_id}/plan
cancel: Handler | None = None # POST {prefix}/{ws_id}/cancel
@@ -566,6 +567,8 @@ def register_session_routes(
routes.append(Route(f"{p}/{{ws_id}}/title", handlers.set_title, methods=["POST"]))
if handlers.send is not None:
routes.append(Route(f"{p}/{{ws_id}}/send", handlers.send, methods=["POST"]))
if handlers.dequeue is not None:
routes.append(Route(f"{p}/{{ws_id}}/send", handlers.dequeue, methods=["DELETE"]))
if handlers.approve is not None:
routes.append(Route(f"{p}/{{ws_id}}/approve", handlers.approve, methods=["POST"]))
if handlers.plan is not None:
+1
View File
@@ -3556,6 +3556,7 @@ def create_app(
refresh_title=refresh_workstream_title,
set_title=set_workstream_title,
send=send_handler, # lifted: shared body (P1.5)
dequeue=dequeue_handler, # lifted (P1.5) — DELETE /send
approve=approve_handler, # lifted: shared body
cancel=cancel_handler, # lifted: shared body
events=events_handler, # lifted: shared body