mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
d7331ae18b
Wire create_gate_require_project=True on coord_endpoint_config: a projectless coordinator create on the console is refused with the same coded 400 as interactive creates. Operator tokens get no exemption; the sessions a coordinator spawns remain exempt via the token_source branch in require_project_denies_create (child spawns, a different seam). The gate predicate now reads "no project" the way the create path actually persists it — a non-string body value (int/bool/list/dict) is coerced to absent, matching _coord_create_build_kwargs and the interactive create — so a truthy non-string like project_id:123 cannot stringify past the gate and mint a projectless session. Without this the three sites disagreed: the old str(project_id or "") stringified a number to a truthy value and waved it through while build_kwargs stored None. Interactive was unaffected (its validator stringifies and 400s first); the fix is at the shared predicate as defense-in-depth for both. The console launcher's project picker mirrors the interactive strict treatment when the flag is on — the seeded placeholder retitles to "Select a project…" (or "No projects available") via setOptionPlaceholder, computed before the + New project… sentinel is appended; the server's coded 400 stays the enforcement. Settings label and help text updated to say coordinators are covered and only coordinator-SPAWNED sessions are exempt. Real-mount wiring tests drive the mounted console endpoint end to end (the synthetic-cfg tests can't catch a mis-wire on the actual mount), including a non-string-project_id bypass regression, with an operator token that carries admin.coordinator without the service scope.
5204 lines
246 KiB
Python
5204 lines
246 KiB
Python
"""Shared HTTP route registrar for workstream-shaped sessions.
|
||
|
||
Both node and console processes mount the workstream HTTP tree at
|
||
``/v1/api/workstreams/`` via this registrar against their own
|
||
:class:`~turnstone.core.session_manager.SessionManager` (interactive
|
||
on the node, coordinator on the console). One URL shape, two
|
||
processes, kind-specific policy in :class:`SessionEndpointConfig`
|
||
captured by closure when the handler factory is called at app
|
||
construction.
|
||
|
||
Three registrar functions:
|
||
|
||
- :func:`register_session_routes` — verbs both kinds expose
|
||
(``new``, ``close``, ``open``, ``delete``, ``send``, ``approve``,
|
||
``cancel``, ``events``, ``history``, ``detail``, ...).
|
||
All handlers in :class:`SharedSessionVerbHandlers` are optional;
|
||
``None`` skips the route, so one bundle describes either kind.
|
||
- :func:`register_coord_verbs` — coord-only verbs (``trust``,
|
||
``restrict``, ``close_all_children``, ``children``, ``tasks``,
|
||
``metrics``) that read or mutate state that doesn't exist on
|
||
interactive workstreams.
|
||
|
||
Some verbs in :class:`SharedSessionVerbHandlers` ship as factory-
|
||
returned closures (e.g. :func:`make_approve_handler`,
|
||
:func:`make_close_handler`) that bake their
|
||
:class:`SessionEndpointConfig` (and any verb-specific args like
|
||
``audit_emit``) in at app-construction time. Both node and console
|
||
call the factory during startup and pass the result as
|
||
``handlers.approve`` / ``handlers.close``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Awaitable, Callable, Iterable
|
||
from dataclasses import dataclass
|
||
from typing import TYPE_CHECKING, Any, Protocol, cast
|
||
|
||
from starlette.responses import JSONResponse
|
||
from starlette.routing import Route
|
||
|
||
from turnstone.core.log import get_logger
|
||
from turnstone.core.session_ui_base import AutoApproveReason
|
||
from turnstone.core.workstream import (
|
||
INTERJECTION_CAP_CHARS,
|
||
PENDING_SENDS_MAX,
|
||
_PendingSend,
|
||
)
|
||
|
||
if TYPE_CHECKING:
|
||
import threading
|
||
|
||
from starlette.background import BackgroundTask
|
||
from starlette.requests import Request
|
||
from starlette.responses import Response
|
||
from starlette.routing import BaseRoute
|
||
|
||
from turnstone.core.attachments import UploadRejection
|
||
from turnstone.core.session import ChatSession
|
||
from turnstone.core.session_manager import SessionManager
|
||
from turnstone.core.session_ui_base import SessionUIBase
|
||
from turnstone.core.workstream import Workstream, WorkstreamKind
|
||
|
||
log = get_logger(__name__)
|
||
|
||
|
||
# Cap echoed factory-misconfig messages. ``ValueError`` from the
|
||
# session factory carries an operator-actionable remediation hint
|
||
# (``"Unknown model alias: <alias>"`` etc.) that the lifted handlers
|
||
# surface as a 503 — but the alias portion is user-controlled on the
|
||
# create path (body ``model`` / ``judge_model`` fields) so a raw echo
|
||
# reflects arbitrary input back into anything that renders the JSON
|
||
# error verbatim. Length cap + control-char strip keep the message
|
||
# actionable for legit alias typos while neutralising hostile payloads.
|
||
_FACTORY_MISCONFIG_MAX_LEN = 200
|
||
|
||
|
||
def _safe_factory_misconfig_message(exc: BaseException) -> str:
|
||
"""Sanitise a factory-misconfig ``ValueError`` for echo in a 503 body.
|
||
|
||
Strips ASCII control characters (``\\x00``-``\\x1f`` + ``\\x7f``)
|
||
and truncates to :data:`_FACTORY_MISCONFIG_MAX_LEN`. Empty after
|
||
sanitisation falls back to a fixed generic message so a control-
|
||
char-only payload doesn't surface as ``"error": ""``.
|
||
"""
|
||
text = str(exc)
|
||
cleaned = "".join(ch for ch in text if ch.isprintable())
|
||
if not cleaned:
|
||
return "session factory misconfigured"
|
||
if len(cleaned) > _FACTORY_MISCONFIG_MAX_LEN:
|
||
# Reserve one codepoint for the ellipsis so the returned string
|
||
# is hard-capped at _FACTORY_MISCONFIG_MAX_LEN total, not
|
||
# MAX_LEN+1.
|
||
cleaned = cleaned[: _FACTORY_MISCONFIG_MAX_LEN - 1] + "…"
|
||
return cleaned
|
||
|
||
|
||
Handler = Callable[["Request"], Awaitable["Response"]]
|
||
PermissionGate = Callable[["Request"], "JSONResponse | None"]
|
||
ManagerLookup = Callable[["Request"], tuple["SessionManager | None", "JSONResponse | None"]]
|
||
TenantCheck = Callable[
|
||
["Request", str, "SessionManager"],
|
||
"JSONResponse | None",
|
||
]
|
||
# (request, ws_id, mgr) -> (owner_user_id, error_response). Owner is
|
||
# the user_id attachments are filed under; error is a 404 when the ws
|
||
# doesn't exist anywhere (memory or storage).
|
||
AttachmentOwnerResolver = Callable[
|
||
["Request", str, "SessionManager"],
|
||
tuple[str, "JSONResponse | None"],
|
||
]
|
||
# (ui) — kind's spawn-time bookkeeping. Interactive bumps
|
||
# ``_metrics.record_message_sent`` + per-UI message counters; coord
|
||
# wires its per-UI-counter analog. Deliberately request-free: the
|
||
# pending-send drain dispatches deferred entries with no live request,
|
||
# and no impl ever needed one.
|
||
SpawnMetricsHook = Callable[[Any], None]
|
||
|
||
|
||
class CancelForensics(Protocol):
|
||
"""Pure-read snapshot the lifted ``cancel`` body surfaces as ``dropped``.
|
||
|
||
Returns a dict with whatever in-flight session / UI state the
|
||
kind wants to expose to the caller (pending-approval tool names,
|
||
queued-message count + preview, etc.). Kinds that don't need a
|
||
forensic snapshot wire ``None`` on the cfg and the lifted body
|
||
returns an empty ``dropped`` dict in the response.
|
||
|
||
Protocol-typed (rather than ``Callable``) because the keyword-only
|
||
``was_running`` argument the lifted body passes can't be expressed
|
||
by a plain ``Callable`` type alias.
|
||
"""
|
||
|
||
def __call__(self, session: Any, ui: Any, *, was_running: bool) -> dict[str, Any]:
|
||
"""Return the ``dropped`` snapshot for the cancel response."""
|
||
|
||
|
||
# (alias_or_id) -> canonical_id_or_None. Interactive's lifted
|
||
# ``open`` body (:func:`make_open_handler`) pre-resolves user-friendly
|
||
# aliases to the canonical hex ws_id via
|
||
# :func:`turnstone.core.memory.resolve_workstream` so callers can
|
||
# pass either shape. Coord wires ``None`` (coord workstreams are
|
||
# addressed by hex id only).
|
||
AliasResolver = Callable[[str], str | None]
|
||
# (request, ws) -> None. Optional kind-specific post-load callback
|
||
# the lifted ``open`` body fires after the workstream is loaded into
|
||
# the manager. Interactive uses it to push a ``clear_ui`` + history
|
||
# replay onto the UI listener queue and to enqueue a handler-side
|
||
# ``ws_created`` event onto the global SSE queue (the global-queue
|
||
# emission stays out of band on interactive — see
|
||
# :class:`SessionKindAdapter` docstring for the asymmetry rationale).
|
||
# Coord wires ``None`` and relies on the cluster collector fan-out
|
||
# triggered by ``CoordinatorAdapter.emit_rehydrated``.
|
||
OpenPostLoad = Callable[["Request", "Workstream"], None]
|
||
# (request, ws) -> None. Optional audit emitter for the ``open``
|
||
# event. Same shape as ``CloseAuditEmitter``'s leading args. Coord
|
||
# wires ``None`` (coord doesn't audit open today).
|
||
OpenAuditEmitter = Callable[["Request", "Workstream"], None]
|
||
|
||
|
||
class EventsReplay(Protocol):
|
||
"""Pure-read generator of the per-kind initial SSE replay payload.
|
||
|
||
The lifted ``events`` body calls this once per SSE connection
|
||
*after* the per-UI listener queue is registered, but *before* the
|
||
live event loop starts. Each yielded dict gets JSON-serialised
|
||
and sent as a single ``data:`` line to the client.
|
||
|
||
Interactive yields four things on connect: ``connected`` (model +
|
||
skip_permissions), ``status`` (token usage + context %, only when
|
||
``session._last_usage`` exists), ``history`` (replayed conversation),
|
||
and ``pending_approval`` + cached intent verdicts. Coord yields
|
||
just one: ``pending_approval`` (the rest aren't needed because
|
||
coord's dashboard fetches history via a separate ``/history``
|
||
endpoint and doesn't render the per-tab status bar). Kinds that
|
||
don't need any pre-replay wire ``None`` and the live loop starts
|
||
immediately.
|
||
"""
|
||
|
||
def __call__(self, ws: Workstream, ui: Any, request: Request) -> Iterable[dict[str, Any]]:
|
||
"""Return the iterable of initial replay events."""
|
||
|
||
|
||
# (request) -> Executor for the SSE live-loop's blocking `queue.get`
|
||
# wait. Interactive returns the dedicated ``sse_executor``
|
||
# (200-thread pool created at lifespan setup) so the SSE poll path
|
||
# stays isolated from every other ``asyncio.to_thread`` caller in
|
||
# the process (storage, router, audit). Coord returns ``None`` and
|
||
# the lifted body falls through to ``asyncio.to_thread`` (default
|
||
# executor, capped at ``min(32, os.cpu_count() + 4)`` workers) —
|
||
# coord's per-process SSE concurrency stays well under that ceiling
|
||
# and adding a dedicated pool would over-engineer for the LOC win.
|
||
SseExecutorLookup = Callable[["Request"], Any]
|
||
|
||
|
||
# (request, body, uid, uploaded_files) -> JSONResponse | None.
|
||
# Optional kind-specific gate the lifted ``create`` body fires after
|
||
# body parsing + uid resolution but before skill resolution and
|
||
# ``mgr.create``. Returns ``None`` to continue, or a 4xx response
|
||
# to short-circuit. Interactive wires gates for ws_id format,
|
||
# kind=INTERACTIVE, parent_ws_id ownership, attachments+resume_ws
|
||
# combo. Coord wires a 401-on-empty-uid (admin tokens always carry a
|
||
# uid in practice; the gate is defensive). Mostly read-only — the
|
||
# parent_ws_id ownership gate does a single storage lookup but
|
||
# doesn't mutate anything.
|
||
CreateRequestValidator = Callable[
|
||
["Request", dict[str, Any], str, list[tuple[str, str, bytes]]],
|
||
"Awaitable[JSONResponse | None]",
|
||
]
|
||
# (request, body, uid, skill_data, skill_id, applied_skill_version) -> kwargs.
|
||
# Builds the kwargs dict for ``mgr.create``. Both kinds call
|
||
# ``mgr.create`` with the same callable shape, but the kwargs they
|
||
# pass differ (interactive threads model + judge_model + client_type +
|
||
# parent_ws_id + ws_id; coord threads only the smaller subset).
|
||
# Captured in a per-kind callable rather than a flag-soup so the
|
||
# kwargs dict construction stays readable at the wire-up site.
|
||
CreateKwargsBuilder = Callable[
|
||
["Request", dict[str, Any], str, dict[str, Any] | None, str, int],
|
||
dict[str, Any],
|
||
]
|
||
# (request, ws, body, uid, skill_data, applied_skill_version, attachment_ids) ->
|
||
# extra response fields. Kind-specific tail end the lifted ``create``
|
||
# body fires after the workstream is built, attachments are saved,
|
||
# and audit is emitted. Returns extra fields to merge into the
|
||
# response (e.g. interactive returns ``{resumed, message_count}``;
|
||
# coord returns ``{}``). May spawn worker threads / register watch
|
||
# runners / persist skill session config / dispatch initial messages
|
||
# / pin routing. The factory does NOT wrap the call in try/except:
|
||
# post-install failures should surface to the caller as 5xx so the
|
||
# operator sees the misconfig instead of a half-built workstream.
|
||
CreatePostInstall = Callable[
|
||
[
|
||
"Request",
|
||
"Workstream",
|
||
dict[str, Any],
|
||
str,
|
||
dict[str, Any] | None,
|
||
int,
|
||
list[str],
|
||
],
|
||
"Awaitable[dict[str, Any]]",
|
||
]
|
||
# (request, ws, body, uid) -> None. Audit emitter for the create
|
||
# event. Interactive emits ``workstream.created`` with
|
||
# ``{kind, parent_ws_id}`` detail; coord emits ``coordinator.create``
|
||
# with ``{coord_ws_id, src, name}`` detail. Wrapped in try/except by
|
||
# the factory — audit-write failures shouldn't surface as HTTP 500
|
||
# (mirrors the close / cancel / open lift contracts).
|
||
CreateAuditEmitter = Callable[
|
||
["Request", "Workstream", dict[str, Any], str],
|
||
None,
|
||
]
|
||
|
||
|
||
# (ws_ids) -> {ws_id: title-or-None} bulk lookup. Interactive wires
|
||
# :func:`turnstone.core.memory.get_workstream_display_names` so the
|
||
# active-list endpoint resolves every alias in one storage round-trip
|
||
# instead of the pre-lift N+1 (one SELECT per row). Coord wires
|
||
# ``None`` (coord doesn't have an alias surface today) and the lifted
|
||
# body uses ``ws.name`` directly. Returns a dict keyed on every
|
||
# requested ws_id; missing rows map to ``None``, and the caller
|
||
# falls back to ``ws.name`` per-row.
|
||
ListResolveTitles = Callable[[list[str]], dict[str, str | None]]
|
||
# (request) -> set of ws_ids currently held in memory by the kind's
|
||
# manager. Coord wires a callable that returns
|
||
# ``{ws.id for ws in coord_mgr.list_all()}`` so the saved-card list
|
||
# can defence-in-depth filter out coordinators currently in the warm
|
||
# pool (a coord can be ``state='closed'`` on disk briefly while the
|
||
# close-emit sequence races the in-memory pop). Interactive wires
|
||
# ``None``: an interactive workstream that's both saved and loaded
|
||
# is a normal display state, not a race the saved card needs to
|
||
# hide. Async because the coord-side implementation runs through
|
||
# ``asyncio.to_thread`` (the manager lock is acquired in
|
||
# ``coord_mgr.list_all``).
|
||
SavedLoadedLookup = Callable[["Request"], Awaitable[set[str]]]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AttachmentUploadHelpers:
|
||
"""Process-local hooks the lifted attachment factories call into.
|
||
|
||
The classification helpers are pure but defined in the kind's owning
|
||
module, so they don't belong on the (frozen)
|
||
:class:`SessionEndpointConfig` directly. Bundling them on a separate
|
||
dataclass keeps the cfg declarative and lets callers share one helper
|
||
instance across kinds if the policies converge later. (The old
|
||
``upload_lock`` hook is gone — uploads now stage into the thread-safe,
|
||
content-addressed per-node buffer, so there's no DB count-check to
|
||
serialize.)
|
||
"""
|
||
|
||
classify_upload: Callable[
|
||
[str, str, bytes],
|
||
tuple[str | None, str | None, UploadRejection | None],
|
||
]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SessionEndpointConfig:
|
||
"""Per-kind policy the lifted handler bodies consult at request time.
|
||
|
||
Instantiated once per process during app construction and passed
|
||
to the verb factory (e.g. :func:`make_approve_handler`,
|
||
:func:`make_close_handler`, :func:`make_send_handler`), which
|
||
captures it via closure. The request-time handler reads ``cfg``
|
||
from the closure rather than ``app.state`` so the dependency is
|
||
visible at the wire-up site.
|
||
|
||
- ``permission_gate``: kind's pre-handler permission check
|
||
(e.g. ``admin.coordinator`` for coord, ``None`` for interactive
|
||
which has no per-handler scope check beyond auth middleware).
|
||
Returns the rejection response when the gate fails, ``None``
|
||
when the request passes.
|
||
- ``manager_lookup``: returns ``(SessionManager, None)`` when the
|
||
kind's manager is loaded, or ``(None, JSONResponse)`` with a
|
||
503 when the subsystem isn't available (coord on a console
|
||
without configured models). For interactive the lookup just
|
||
returns ``(app.state.workstreams, None)``.
|
||
- ``tenant_check``: per-``ws_id`` existence + access gate.
|
||
Interactive wires :func:`_require_ws_access` (which 404s when
|
||
the workstream doesn't exist; row-level ownership is NOT
|
||
enforced — turnstone is a trusted-team tool, ``admin.workstreams``
|
||
scope is the cluster-wide gate). Coord sets this to ``None``
|
||
and relies on ``admin.coordinator`` from ``permission_gate``
|
||
plus an in-memory ``coord_mgr`` lookup at handler time.
|
||
Always invoked via ``await asyncio.to_thread(...)`` at handler
|
||
sites: the interactive resolver short-circuits on
|
||
``mgr.get(ws_id)`` for warm cache but falls through to a
|
||
synchronous storage read (:func:`get_workstream_owner`) on a
|
||
manager-cache miss, so offloading keeps the event loop free
|
||
during cold-cache lookups.
|
||
- ``not_found_label``: the message body for the 404 returned when
|
||
the manager has no such ws_id ("Workstream not found" for
|
||
interactive; "coordinator not found" for coord).
|
||
- ``audit_action_prefix``: the dot-namespaced prefix the kind
|
||
uses for its audit actions ("workstream" → ``workstream.cancel``;
|
||
"coordinator" → ``coordinator.cancel``).
|
||
|
||
Capability flags (added with the P1.5 ``send`` body lift):
|
||
|
||
- ``supports_attachments``: when ``True``, the lifted ``send``
|
||
handler resolves attachment_ids from the per-node upload buffer
|
||
and threads them through ``ChatSession.send`` /
|
||
``ChatSession.queue_message``. Both kinds wire ``True`` post-P1.5
|
||
(the storage layer was always kind-agnostic; the gate stays
|
||
around so a kind that hasn't lit up its UI surface yet can
|
||
defer the verb body changes).
|
||
- ``attachment_owner_resolver``: resolves the ``user_id`` to scope
|
||
attachments under for a given request + ws_id. Required when
|
||
``supports_attachments`` is ``True``.
|
||
- ``spawn_metrics``: optional bookkeeping hook fired once per
|
||
``send`` that spawns a fresh worker (queue-reuse path skips it).
|
||
Interactive wires its WebUI per-conversation counters; coord
|
||
wires its per-UI-counter analog.
|
||
- ``emit_message_queued``: when ``True`` and the dispatcher takes
|
||
the live-worker enqueue path, the lifted body emits a
|
||
``message_queued`` event onto the workstream's listener queue
|
||
via ``ui._enqueue``. Both kinds wire ``True`` since both UIs
|
||
have a listener queue.
|
||
"""
|
||
|
||
permission_gate: PermissionGate | None
|
||
manager_lookup: ManagerLookup
|
||
tenant_check: TenantCheck | None
|
||
not_found_label: str
|
||
audit_action_prefix: str
|
||
supports_attachments: bool = False
|
||
attachment_owner_resolver: AttachmentOwnerResolver | None = None
|
||
attachment_helpers: AttachmentUploadHelpers | None = None
|
||
spawn_metrics: SpawnMetricsHook | None = None
|
||
emit_message_queued: bool = True
|
||
# (session, ui, *, was_running) -> dict. When set, the lifted
|
||
# ``cancel`` body calls this and surfaces the result as the
|
||
# ``dropped`` key on the response. Interactive wires
|
||
# ``_capture_cancel_forensics`` so the model-invoked
|
||
# ``cancel_workstream`` tool can tell operators what got killed
|
||
# (pending-approval tool names, queued-message count + preview).
|
||
# Coord wires ``None`` — no forensic surface today; the lifted
|
||
# body still returns ``dropped: {}`` for response-shape parity.
|
||
cancel_forensics: CancelForensics | None = None
|
||
# (alias_or_id) -> canonical_id_or_None. When set, the lifted
|
||
# ``open`` body resolves the path-param ws_id through this
|
||
# callable before any storage lookup. Interactive wires
|
||
# :func:`turnstone.core.memory.resolve_workstream` so user-friendly
|
||
# aliases ("my-debug-ws") map to canonical hex ids. Coord wires
|
||
# ``None`` — coord uses hex ids only.
|
||
open_resolve_alias: AliasResolver | None = None
|
||
# (request, ws) -> None. Kind-specific post-load callback fired
|
||
# by the lifted ``open`` body after ``mgr.open(ws_id)`` returns
|
||
# the workstream. Interactive uses it to send the UI-replay
|
||
# events (``clear_ui`` + history) and to enqueue a handler-side
|
||
# ``ws_created`` onto the global SSE queue (out-of-band path —
|
||
# see :class:`SessionKindAdapter` docstring for why interactive's
|
||
# creation events stay outside the manager's emit_*). Coord
|
||
# wires ``None`` and lets the cluster collector handle the
|
||
# transition via ``CoordinatorAdapter.emit_rehydrated``.
|
||
open_post_load: OpenPostLoad | None = None
|
||
# (ws, ui, request) -> Iterable[dict]. Kind-specific initial
|
||
# SSE replay payload the lifted ``events`` body yields after
|
||
# registering the per-UI listener queue but before the live
|
||
# event loop. Interactive replays connected + status + history
|
||
# + pending_approval (with cached intent verdicts). Coord replays
|
||
# just pending_approval (its dashboard fetches history via a
|
||
# separate ``/history`` endpoint and doesn't render the per-tab
|
||
# status bar). Kinds that don't need pre-replay wire ``None``.
|
||
events_replay: EventsReplay | None = None
|
||
# (request) -> Executor for the SSE live-loop's blocking
|
||
# ``queue.get`` wait. Interactive returns the dedicated
|
||
# ``request.app.state.sse_executor`` (200-thread pool) so SSE
|
||
# polling stays isolated from every other ``asyncio.to_thread``
|
||
# caller in the process; coord wires ``None`` and the lifted
|
||
# body falls through to the default executor. See
|
||
# :data:`SseExecutorLookup` docstring above.
|
||
sse_executor_lookup: SseExecutorLookup | None = None
|
||
# When ``True``, the lifted ``create`` body parses
|
||
# ``multipart/form-data`` (with one ``meta`` JSON field + zero or
|
||
# more ``file`` parts) in addition to plain ``application/json``.
|
||
# Both kinds wire ``True`` post-create-lift — coord gains
|
||
# create-time attachments here (§ Post-P3 reckoning item #1).
|
||
# The actual attachment validation+save+rollback always uses the
|
||
# storage layer (kind-agnostic since P1.5); this flag only
|
||
# toggles whether the multipart parse is attempted at all.
|
||
create_supports_attachments: bool = False
|
||
# When ``True``, the lifted ``create`` body honours a ``user_id``
|
||
# field in the request body if the caller's auth token comes from
|
||
# a trusted service (currently just ``"console"``). Interactive
|
||
# wires ``True`` so console-proxied creates can carry the real
|
||
# end user's identity through to the workstream owner. Coord
|
||
# wires ``False`` — coord create runs only on the console process
|
||
# and the operator's auth result is the source of truth.
|
||
create_supports_user_id_override: bool = False
|
||
# When ``True`` the shared create handler applies the ``server.require_project``
|
||
# gate (refuse a projectless create). Declarative per-mount capability —
|
||
# both current kinds wire ``True`` (interactive on the node mount,
|
||
# coordinator on the console mount); a future kind opts out by leaving the
|
||
# default. Sessions a coordinator spawns stay exempt inside
|
||
# ``require_project_denies_create`` (token_source), not via this flag. A
|
||
# cfg flag rather than a hardcoded kind literal, matching the
|
||
# ``create_supports_*`` idiom.
|
||
create_gate_require_project: bool = False
|
||
# (request, body, uid, uploaded_files) -> JSONResponse | None.
|
||
# Per-kind pre-create gate (ws_id format, parent ownership, kind
|
||
# validation, etc. on interactive; 401-on-empty-uid on coord).
|
||
# ``None`` skips the gate entirely.
|
||
create_validate_request: CreateRequestValidator | None = None
|
||
# (request, body, uid, skill_data, skill_id, applied_skill_version)
|
||
# -> kwargs for ``mgr.create``. Required when the kind mounts a
|
||
# ``create`` handler — the lifted body has no opinion on the
|
||
# kind-specific kwarg shape and threads whatever this returns
|
||
# straight through to ``await asyncio.to_thread(mgr.create, **kwargs)``.
|
||
create_build_kwargs: CreateKwargsBuilder | None = None
|
||
# (request, ws, body, uid, skill_data, applied_skill_version,
|
||
# attachment_ids) -> extra response fields. Kind-specific tail
|
||
# end fired after attachments save + audit. Interactive returns
|
||
# ``{resumed, message_count}`` and spawns the initial-message
|
||
# worker thread; coord returns ``{}`` and dispatches via
|
||
# ``coord_adapter.send`` when an initial_message is provided.
|
||
# ``None`` skips the post-install entirely (response is just
|
||
# ``{ws_id, name, ...}`` with empty parity fields).
|
||
create_post_install: CreatePostInstall | None = None
|
||
# (ws_ids) -> {ws_id: title-or-None} — bulk title lookup for the
|
||
# active-list endpoint. Interactive wires
|
||
# ``get_workstream_display_names`` so every row's alias resolves
|
||
# in one storage round-trip; coord wires ``None`` (no alias
|
||
# surface today). See :data:`ListResolveTitles`.
|
||
list_resolve_titles: ListResolveTitles | None = None
|
||
# Kind classifier for the lifted ``list``/``saved`` factories'
|
||
# storage filter. Required when a kind mounts either handler —
|
||
# the factories pass it straight through to
|
||
# ``list_workstreams_with_history(kind=...)``. Distinct from
|
||
# ``audit_action_prefix`` (audit-action namespacing) so adding a
|
||
# third kind doesn't have to overload the audit prefix as a
|
||
# filter. ``None`` is allowed for kinds that don't mount a
|
||
# list/saved handler.
|
||
list_kind: WorkstreamKind | None = None
|
||
# Storage-side state filter for the saved-list endpoint. Interactive
|
||
# wires ``None`` — saved sidebar shows every persisted interactive
|
||
# workstream regardless of state. This is safe because delete is a
|
||
# HARD delete (``session_manager.delete`` -> ``sa.delete(workstreams)``)
|
||
# and no ``state='deleted'`` tombstone is ever written (WorkstreamState
|
||
# has no DELETED member); there is NO storage-side state filter to lean
|
||
# on, so if a soft-delete tombstone is ever introduced this list must
|
||
# add an explicit ``state != 'deleted'`` guard. Coord wires ``"closed"``
|
||
# so only explicitly-closed coordinators surface in the saved-card grid;
|
||
# active / in-flight rows live in the active list.
|
||
saved_state_filter: str | None = None
|
||
# (request) -> set of ws_ids in the kind's in-memory pool. Coord
|
||
# wires a coroutine that returns ``{ws.id for ws in
|
||
# coord_mgr.list_all()}`` (defence-in-depth filter — see
|
||
# :data:`SavedLoadedLookup`). Interactive wires ``None``.
|
||
saved_loaded_lookup: SavedLoadedLookup | None = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AttachmentHandlers:
|
||
"""The four-handler quartet for the per-workstream attachment surface.
|
||
|
||
Grouped so the type system enforces that you can't mount
|
||
upload-without-delete or list-without-content (a half-mounted
|
||
surface leaves broken frontend flows). Set
|
||
:attr:`SharedSessionVerbHandlers.attachments` to ``None`` for
|
||
kinds that don't expose attachments yet.
|
||
"""
|
||
|
||
upload: Handler # POST {prefix}/{ws_id}/attachments
|
||
list: Handler # GET {prefix}/{ws_id}/attachments
|
||
get_content: Handler # GET {prefix}/{ws_id}/attachments/{attachment_id}/content
|
||
thumbnail: Handler # GET {prefix}/{ws_id}/attachments/{attachment_id}/thumbnail
|
||
preview: Handler # GET {prefix}/{ws_id}/attachments/{attachment_id}/preview
|
||
delete: Handler # DELETE {prefix}/{ws_id}/attachments/{attachment_id}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SharedSessionVerbHandlers:
|
||
"""Bundle of HTTP handler callables for verbs both kinds expose.
|
||
|
||
All handlers are optional; ``None`` skips that route. One bundle
|
||
describes either kind — coord omits ``delete``; interactive
|
||
populates every interaction verb post-Stage-2.
|
||
"""
|
||
|
||
# Listing
|
||
list_workstreams: Handler | None = None # GET {prefix}
|
||
list_saved: Handler | None = None # GET {prefix}/saved
|
||
|
||
# Create
|
||
create: Handler | None = None # POST {prefix}/new
|
||
|
||
# Per-``{ws_id}`` lifecycle
|
||
detail: Handler | None = None # GET {prefix}/{ws_id}
|
||
delete: Handler | None = None # POST {prefix}/{ws_id}/delete
|
||
open: Handler | None = None # POST {prefix}/{ws_id}/open
|
||
close: Handler | None = None # POST {prefix}/{ws_id}/close
|
||
refresh_title: Handler | None = None # POST {prefix}/{ws_id}/refresh-title
|
||
set_title: Handler | None = None # POST {prefix}/{ws_id}/title
|
||
|
||
# Per-``{ws_id}`` interaction
|
||
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
|
||
rewind: Handler | None = None # POST {prefix}/{ws_id}/rewind
|
||
retry: Handler | None = None # POST {prefix}/{ws_id}/retry
|
||
events: Handler | None = None # GET {prefix}/{ws_id}/events (SSE)
|
||
history: Handler | None = None # GET {prefix}/{ws_id}/history
|
||
export: Handler | None = None # GET {prefix}/{ws_id}/export
|
||
|
||
# Attachments — the four handlers come together or not at all.
|
||
attachments: AttachmentHandlers | None = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CoordOnlyVerbHandlers:
|
||
"""Bundle of coord-only HTTP handler callables.
|
||
|
||
These verbs read or mutate state that doesn't exist on interactive
|
||
workstreams — children registry, parent quota, trust / restrict
|
||
policy, cascade controls — so they live on a Protocol distinct
|
||
from :class:`SharedSessionVerbHandlers`. Mounted at the same
|
||
``/api/workstreams/{ws_id}/`` prefix so the URL surface stays
|
||
unified, but registered through a separate call so the kind
|
||
separation is explicit at the wiring site.
|
||
"""
|
||
|
||
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
|
||
close_all_children: Handler # POST {prefix}/{ws_id}/close_all_children
|
||
|
||
|
||
def register_session_routes(
|
||
routes: list[BaseRoute],
|
||
*,
|
||
prefix: str,
|
||
handlers: SharedSessionVerbHandlers,
|
||
) -> None:
|
||
"""Append the shared workstream HTTP route table to ``routes`` at ``prefix``.
|
||
|
||
Mounts every verb whose handler is non-``None``. Routes register
|
||
in an order that respects Starlette's first-match semantics:
|
||
literal subpaths (``saved``, ``new``) before the per-``{ws_id}``
|
||
patterns; per-``{ws_id}/{verb}`` patterns before the bare
|
||
``{ws_id}`` detail GET.
|
||
|
||
``prefix`` is the URL prefix relative to the mount, e.g.
|
||
``"/api/workstreams"``.
|
||
"""
|
||
p = prefix.rstrip("/")
|
||
|
||
# --- Listing endpoints ----------------------------------------------
|
||
if handlers.list_workstreams is not None:
|
||
routes.append(Route(p, handlers.list_workstreams))
|
||
# Literal ``saved`` must register BEFORE the bare ``{ws_id}``
|
||
# detail GET below so Starlette doesn't match "saved" as a ws_id.
|
||
if handlers.list_saved is not None:
|
||
routes.append(Route(f"{p}/saved", handlers.list_saved))
|
||
|
||
# --- Lifecycle: create -----------------------------------------------
|
||
if handlers.create is not None:
|
||
routes.append(Route(f"{p}/new", handlers.create, methods=["POST"]))
|
||
|
||
# --- Per-``{ws_id}`` verbs (specific verbs first) -------------------
|
||
if handlers.delete is not None:
|
||
routes.append(Route(f"{p}/{{ws_id}}/delete", handlers.delete, methods=["POST"]))
|
||
if handlers.open is not None:
|
||
routes.append(Route(f"{p}/{{ws_id}}/open", handlers.open, methods=["POST"]))
|
||
if handlers.close is not None:
|
||
routes.append(Route(f"{p}/{{ws_id}}/close", handlers.close, methods=["POST"]))
|
||
if handlers.refresh_title is not None:
|
||
routes.append(
|
||
Route(
|
||
f"{p}/{{ws_id}}/refresh-title",
|
||
handlers.refresh_title,
|
||
methods=["POST"],
|
||
)
|
||
)
|
||
if handlers.set_title is not None:
|
||
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:
|
||
routes.append(Route(f"{p}/{{ws_id}}/plan", handlers.plan, methods=["POST"]))
|
||
if handlers.cancel is not None:
|
||
routes.append(Route(f"{p}/{{ws_id}}/cancel", handlers.cancel, methods=["POST"]))
|
||
if handlers.rewind is not None:
|
||
routes.append(Route(f"{p}/{{ws_id}}/rewind", handlers.rewind, methods=["POST"]))
|
||
if handlers.retry is not None:
|
||
routes.append(Route(f"{p}/{{ws_id}}/retry", handlers.retry, methods=["POST"]))
|
||
if handlers.events is not None:
|
||
routes.append(Route(f"{p}/{{ws_id}}/events", handlers.events, methods=["GET"]))
|
||
if handlers.history is not None:
|
||
routes.append(Route(f"{p}/{{ws_id}}/history", handlers.history, methods=["GET"]))
|
||
if handlers.export is not None:
|
||
routes.append(Route(f"{p}/{{ws_id}}/export", handlers.export, methods=["GET"]))
|
||
|
||
# --- Attachments (the quartet comes together or not at all) ---------
|
||
if handlers.attachments is not None:
|
||
a = handlers.attachments
|
||
routes.append(Route(f"{p}/{{ws_id}}/attachments", a.upload, methods=["POST"]))
|
||
routes.append(Route(f"{p}/{{ws_id}}/attachments", a.list, methods=["GET"]))
|
||
routes.append(
|
||
Route(
|
||
f"{p}/{{ws_id}}/attachments/{{attachment_id}}/content",
|
||
a.get_content,
|
||
methods=["GET"],
|
||
)
|
||
)
|
||
routes.append(
|
||
Route(
|
||
f"{p}/{{ws_id}}/attachments/{{attachment_id}}/thumbnail",
|
||
a.thumbnail,
|
||
methods=["GET"],
|
||
)
|
||
)
|
||
routes.append(
|
||
Route(
|
||
f"{p}/{{ws_id}}/attachments/{{attachment_id}}/preview",
|
||
a.preview,
|
||
methods=["GET"],
|
||
)
|
||
)
|
||
routes.append(
|
||
Route(
|
||
f"{p}/{{ws_id}}/attachments/{{attachment_id}}",
|
||
a.delete,
|
||
methods=["DELETE"],
|
||
)
|
||
)
|
||
|
||
# --- Bare ``{ws_id}`` detail (GET) registers LAST so the verb-
|
||
# 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"]))
|
||
|
||
|
||
def register_coord_verbs(
|
||
routes: list[BaseRoute],
|
||
*,
|
||
prefix: str,
|
||
handlers: CoordOnlyVerbHandlers,
|
||
) -> 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``.
|
||
"""
|
||
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}}/close_all_children",
|
||
handlers.close_all_children,
|
||
methods=["POST"],
|
||
)
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Lifted handler bodies — Stage 2 Priority 0 body-convergence
|
||
#
|
||
# Each verb here was previously implemented twice (once in
|
||
# ``turnstone/server.py`` for interactive, once in
|
||
# ``turnstone/console/server.py`` for coord). The lifted body
|
||
# branches on the kind-specific :class:`SessionEndpointConfig` the
|
||
# factory captured at app-construction time.
|
||
#
|
||
# Verbs not lifted yet (intentional — bodies have substantive
|
||
# behavior divergence that needs SessionManager-side refactoring,
|
||
# not just kind branching): send (worker dispatch — Priority 1
|
||
# territory), cancel (interactive does inline forensics + force-
|
||
# cancel ws._lock manipulation), open (interactive resume vs coord
|
||
# rehydrate), events (different SSE replay shapes), create
|
||
# (interactive attachments vs coord initial_message), list / saved
|
||
# (different response keys: ``workstreams`` vs ``coordinators``).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def make_approve_handler(
|
||
cfg: SessionEndpointConfig,
|
||
*,
|
||
accepted_permissions: tuple[str, ...] = (),
|
||
) -> Handler:
|
||
"""Lifted body for ``POST {prefix}/{ws_id}/approve``.
|
||
|
||
Resolves ONE pending approval cycle on the workstream's UI. Both
|
||
kinds expose the same approve / feedback / always / call_id /
|
||
cycle_id body shape and the same cycle-routed
|
||
``ui.resolve_approval(...)`` mechanic; differences are auth scope,
|
||
manager lookup, and the ``__budget_override__`` filter
|
||
(interactive-only — coord workstreams don't have the
|
||
budget-override pseudo-tool). With parallel task agents a
|
||
workstream can hold several cycles; a body without a selector
|
||
resolves the oldest.
|
||
|
||
``accepted_permissions`` is OR-checked via :func:`require_any_permission`
|
||
only when ``cfg.permission_gate`` is ``None`` — i.e. for the
|
||
interactive kind, where it IS the primary gate (not a fallback to
|
||
something else). Coord's ``permission_gate`` already takes
|
||
precedence so admin-coordinator users don't also need
|
||
``tools.approve`` to act on their own coord workstreams. Pass
|
||
``admin.coordinator`` alongside ``tools.approve`` for endpoints
|
||
reachable by coord sessions spawning interactive children.
|
||
"""
|
||
from turnstone.core.auth import require_any_permission
|
||
from turnstone.core.web_helpers import read_json_or_400
|
||
|
||
async def approve(request: Request) -> Response:
|
||
import asyncio
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
elif accepted_permissions:
|
||
err = require_any_permission(request, accepted_permissions)
|
||
if err is not None:
|
||
return err
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
# ``manager_lookup`` returns ``(None, JSONResponse)`` when the
|
||
# subsystem is unavailable (returned above) or
|
||
# ``(SessionManager, None)`` otherwise; ``cast`` makes the
|
||
# type-checker-only narrowing explicit and survives ``python -O``.
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
body = await read_json_or_400(request)
|
||
if isinstance(body, JSONResponse):
|
||
return body
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
approved = bool(body.get("approved", False))
|
||
feedback = body.get("feedback")
|
||
always = bool(body.get("always", False))
|
||
if cfg.tenant_check is not None:
|
||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||
if err_tenant is not None:
|
||
return err_tenant
|
||
ws = mgr.get(ws_id)
|
||
if ws is None:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
ui = ws.ui
|
||
if ui is None or not hasattr(ui, "resolve_approval"):
|
||
return JSONResponse(
|
||
{"error": "session UI does not support approval"},
|
||
status_code=409,
|
||
)
|
||
auto_approve_tools = getattr(ui, "auto_approve_tools", None)
|
||
# Cycle routing — with parallel task agents a workstream can
|
||
# have SEVERAL approval cycles live at once, each its own
|
||
# prompt. A decision addresses exactly one:
|
||
# - ``cycle_id`` (new clients) selects it directly;
|
||
# - ``call_id`` (coord tree rows, channel adapters) selects
|
||
# the cycle containing that call — and doubles as the
|
||
# legacy stale-guard: a click on a row whose round was
|
||
# already replaced 409s instead of silently resolving an
|
||
# unrelated batch;
|
||
# - neither (CLI wrappers, old tabs) → the OLDEST live cycle,
|
||
# matching the order the prompts were issued.
|
||
body_call_id_raw = body.get("call_id", "")
|
||
body_call_id = body_call_id_raw.strip() if isinstance(body_call_id_raw, str) else ""
|
||
body_cycle_id_raw = body.get("cycle_id", "")
|
||
body_cycle_id = body_cycle_id_raw.strip() if isinstance(body_cycle_id_raw, str) else ""
|
||
find_cycle = getattr(ui, "find_approval_cycle", None)
|
||
target_card: dict[str, Any] | None = None
|
||
pinned_cycle_id: str | None = None
|
||
if find_cycle is not None:
|
||
target_card = find_cycle(cycle_id=body_cycle_id or None, call_id=body_call_id or None)
|
||
if target_card is None and (body_cycle_id or body_call_id):
|
||
# Selector given but nothing matched: the round was
|
||
# resolved/replaced after this client rendered it.
|
||
# Report the CURRENT oldest cycle (first entry of
|
||
# ``serialize_pending_approval_details``) so the client
|
||
# can re-render against what the server thinks is live.
|
||
current = find_cycle()
|
||
current_items = (current or {}).get("items") or []
|
||
primary = next(
|
||
(item.get("call_id", "") for item in current_items if item.get("call_id")),
|
||
None,
|
||
)
|
||
return JSONResponse(
|
||
{
|
||
"error": ("stale call_id" if body_call_id else "stale cycle_id"),
|
||
"current_call_id": primary,
|
||
"current_cycle_id": (current or {}).get("cycle_id"),
|
||
},
|
||
status_code=409,
|
||
)
|
||
# Pin the resolution to the exact cycle the lookup returned.
|
||
# For selector-less bodies the lookup and the resolve would
|
||
# otherwise EACH independently pick "the oldest" — a cycle
|
||
# resolving in the gap (gate timeout, peer tab, smart
|
||
# approval) silently retargets the resolve at the next
|
||
# cycle while the always-names below were collected from
|
||
# the previous one, whitelisting a batch the operator never
|
||
# looked at.
|
||
pinned_cycle_id = (target_card or {}).get("cycle_id") or None
|
||
else:
|
||
# Legacy/stub UI (tests, external SessionUI impls): fall back
|
||
# to the single-slot view for the always-names read below.
|
||
target_card = getattr(ui, "_pending_approval", None)
|
||
if body_call_id:
|
||
if target_card is None:
|
||
return JSONResponse(
|
||
{"error": "no pending approval", "current_call_id": None},
|
||
status_code=409,
|
||
)
|
||
legacy_ids = {
|
||
item.get("call_id", "")
|
||
for item in target_card.get("items") or []
|
||
if item.get("call_id")
|
||
}
|
||
if body_call_id not in legacy_ids:
|
||
primary = next(iter(sorted(legacy_ids)), None)
|
||
return JSONResponse(
|
||
{"error": "stale call_id", "current_call_id": primary},
|
||
status_code=409,
|
||
)
|
||
# Resolve FIRST, then whitelist: the "Approve + Always" names
|
||
# must describe the cycle that actually resolved. On the cycle
|
||
# path the resolve is pinned to the lookup's cycle_id, so the
|
||
# only race left is that cycle resolving in the gap — then
|
||
# ``resolved_cycle`` comes back ``None`` and the whitelist below
|
||
# is skipped (approving a card someone else already resolved
|
||
# must not grow the auto-approve set). ``always`` still rides
|
||
# the ``approval_resolved`` SSE event so peer tabs that didn't
|
||
# click can render the right status pill ("✓ approved · always"
|
||
# vs plain "✓ approved") without a side-channel broadcast.
|
||
try:
|
||
if find_cycle is not None:
|
||
if pinned_cycle_id is not None:
|
||
resolved_cycle = ui.resolve_approval(
|
||
approved,
|
||
feedback,
|
||
always=always,
|
||
cycle_id=pinned_cycle_id,
|
||
)
|
||
elif body_call_id or body_cycle_id:
|
||
# Lookup matched a card that carries no cycle_id
|
||
# (custom registrations outside ``approve_tools``):
|
||
# honor the client's own selector.
|
||
resolved_cycle = ui.resolve_approval(
|
||
approved,
|
||
feedback,
|
||
always=always,
|
||
call_id=body_call_id or None,
|
||
cycle_id=body_cycle_id or None,
|
||
)
|
||
else:
|
||
# No selector AND nothing pending at lookup time:
|
||
# resolve nothing rather than racing a cycle that
|
||
# registered in the gap — the client can't have
|
||
# been looking at it.
|
||
resolved_cycle = None
|
||
else:
|
||
resolved_cycle = ui.resolve_approval(
|
||
approved,
|
||
feedback,
|
||
always=always,
|
||
call_id=body_call_id or None,
|
||
cycle_id=body_cycle_id or None,
|
||
)
|
||
except TypeError:
|
||
# Pre-cycle SessionUI impls (external/custom) without the
|
||
# selector kwargs.
|
||
resolved_cycle = ui.resolve_approval(approved, feedback, always=always)
|
||
if (
|
||
always
|
||
and approved
|
||
and target_card
|
||
and auto_approve_tools is not None
|
||
# Cycle-registry UIs: whitelist only when OUR resolve landed
|
||
# on the pinned cycle (non-None return). Stub/legacy UIs
|
||
# (no registry) keep the unconditional legacy behavior —
|
||
# their resolve's return value carries no cycle contract to
|
||
# gate on.
|
||
and (find_cycle is None or resolved_cycle is not None)
|
||
):
|
||
tool_names: set[str] = {
|
||
it.get("approval_label", "") or it.get("func_name", "")
|
||
for it in target_card.get("items", [])
|
||
if it.get("needs_approval") and it.get("func_name") and not it.get("error")
|
||
}
|
||
tool_names.discard("")
|
||
# Budget-override is an interactive-only pseudo-tool that
|
||
# must never be added to the auto-approve set — discarding
|
||
# unconditionally is safe (no-op for coord).
|
||
tool_names.discard("__budget_override__")
|
||
if tool_names:
|
||
auto_approve_tools.update(tool_names)
|
||
# Tag the source so /dashboard pills can distinguish
|
||
# an explicit "Approve + Always" click from the
|
||
# skill-template path (which the user may have set
|
||
# up months ago). Defensive ``getattr`` because the
|
||
# source map landed alongside this fix; pre-fix
|
||
# workstreams would lack it during a hot-deploy.
|
||
source_map = getattr(ui, "_auto_approve_tools_source", None)
|
||
if source_map is not None:
|
||
for t in tool_names:
|
||
source_map[t] = AutoApproveReason.ALWAYS
|
||
return JSONResponse({"status": "ok", "cycle_id": resolved_cycle})
|
||
|
||
return approve
|
||
|
||
|
||
CloseAuditEmitter = Callable[
|
||
["Request", str, "Workstream", str],
|
||
None,
|
||
]
|
||
|
||
|
||
def make_close_handler(
|
||
cfg: SessionEndpointConfig,
|
||
*,
|
||
audit_emit: CloseAuditEmitter | None = None,
|
||
supports_close_reason: bool = False,
|
||
accepted_permissions: tuple[str, ...] = (),
|
||
) -> Handler:
|
||
"""Lifted body for ``POST {prefix}/{ws_id}/close``.
|
||
|
||
Closes the workstream's session (unloads from memory; storage row
|
||
survives so the session can be re-opened later). Both kinds share
|
||
the same auth → mgr → ws-lookup → ``mgr.close()`` → audit
|
||
sequence; per-kind divergence is in the audit detail shape and
|
||
whether a request body ``reason`` is read / capped / persisted on
|
||
the workstream's config row.
|
||
|
||
Args:
|
||
cfg: per-kind policy bundle (auth, manager lookup, tenant
|
||
check, error labels). Captured by closure so the request-
|
||
time handler doesn't reach into ``app.state``.
|
||
audit_emit: kind's audit emitter for the close event.
|
||
Receives ``(request, ws_id, ws_before, reason)``; ``reason``
|
||
is the empty string when ``supports_close_reason`` is
|
||
``False`` or no reason was provided. ``None`` skips the
|
||
audit entirely (only valid when neither kind cares).
|
||
supports_close_reason: when ``True``, the handler reads a
|
||
``reason`` field from the JSON body, caps it at 512 UTF-8
|
||
bytes, redacts credentials, persists it via
|
||
``storage.save_workstream_config(ws_id, {"close_reason": ...})``,
|
||
and threads it through to ``audit_emit``. The cap protects
|
||
``workstream_config`` from unbounded growth on a model-
|
||
generated dump; the redact protects audit logs from
|
||
captured-secret leakage under prompt injection.
|
||
|
||
Behavior change vs the pre-lift handlers:
|
||
|
||
- The interactive handler previously let ``record_audit`` failures
|
||
surface as HTTP 500 (no try/except). The lifted body wraps
|
||
``audit_emit`` in try/except and demotes failures to a
|
||
``warning`` log, returning 200 to the caller. Coord previously
|
||
already swallowed; convergence is intentional — operators
|
||
monitor the audit-fail log line in both kinds the same way.
|
||
- The coord ``mgr.close()`` race-loss returned 500; standardized
|
||
to 404 ("popped between ``.get()`` and ``.close()``" is a
|
||
not-found semantic, not a server error).
|
||
"""
|
||
|
||
async def close(request: Request) -> Response:
|
||
import asyncio
|
||
|
||
from turnstone.core.auth import require_any_permission
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
elif accepted_permissions:
|
||
err = require_any_permission(request, accepted_permissions)
|
||
if err is not None:
|
||
return err
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
# See ``make_approve_handler`` for the cast rationale.
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
|
||
reason = ""
|
||
if supports_close_reason:
|
||
from turnstone.core.output_guard import redact_credentials
|
||
from turnstone.core.web_helpers import read_json_or_400
|
||
|
||
body = await read_json_or_400(request)
|
||
if isinstance(body, JSONResponse):
|
||
return body
|
||
raw_reason = body.get("reason", "")
|
||
if isinstance(raw_reason, str):
|
||
# Cap on UTF-8 bytes (not code points) so a CJK / emoji
|
||
# payload can't sneak past at 3-4x the documented budget.
|
||
# ``errors="ignore"`` drops any partial code point left
|
||
# at the truncation boundary.
|
||
capped = raw_reason.strip().encode("utf-8")[:512].decode("utf-8", errors="ignore")
|
||
reason = redact_credentials(capped)
|
||
|
||
if cfg.tenant_check is not None:
|
||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||
if err_tenant is not None:
|
||
return err_tenant
|
||
|
||
ws_before = mgr.get(ws_id)
|
||
if ws_before is None:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
if not mgr.close(ws_id):
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
|
||
storage = getattr(request.app.state, "auth_storage", None)
|
||
if supports_close_reason and reason and storage is not None:
|
||
try:
|
||
storage.save_workstream_config(ws_id, {"close_reason": reason})
|
||
except Exception:
|
||
log.warning(
|
||
"ws.close.reason_persist_failed ws=%s",
|
||
ws_id[:8] if ws_id else "",
|
||
exc_info=True,
|
||
)
|
||
|
||
if audit_emit is not None and storage is not None:
|
||
try:
|
||
audit_emit(request, ws_id, ws_before, reason)
|
||
except Exception:
|
||
# Audit-write failure is a compliance signal —
|
||
# ``warning`` so it surfaces in ops logs. Behavior change
|
||
# vs the original interactive handler (which would have
|
||
# 500'd here); see the function docstring.
|
||
log.warning(
|
||
"ws.close.audit_failed ws=%s",
|
||
ws_id[:8] if ws_id else "",
|
||
exc_info=True,
|
||
)
|
||
|
||
return JSONResponse({"status": "ok"})
|
||
|
||
return close
|
||
|
||
|
||
def make_refresh_title_handler(cfg: SessionEndpointConfig) -> Handler:
|
||
"""Lifted body for ``POST {prefix}/{ws_id}/refresh-title``.
|
||
|
||
Regenerates the workstream title via a background LLM call
|
||
(:meth:`ChatSession.request_title_refresh`). Both kinds share the
|
||
auth → mgr → ws-lookup → request sequence; the session must be live
|
||
in memory (``mgr.get``, not ``open``) since the refresh runs on the
|
||
loaded :class:`ChatSession`. The current display name is passed so
|
||
the generator is steered toward a *different* title on a manual
|
||
refresh.
|
||
"""
|
||
|
||
async def refresh_title(request: Request) -> Response:
|
||
import asyncio
|
||
|
||
from turnstone.core.memory import get_workstream_display_name
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
# See ``make_approve_handler`` for the cast rationale.
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
|
||
if cfg.tenant_check is not None:
|
||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||
if err_tenant is not None:
|
||
return err_tenant
|
||
|
||
ws = mgr.get(ws_id)
|
||
if ws is None or ws.session is None:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
|
||
current_title = await asyncio.to_thread(get_workstream_display_name, ws_id) or ""
|
||
ws.session.request_title_refresh(current_title)
|
||
return JSONResponse({"status": "ok"})
|
||
|
||
return refresh_title
|
||
|
||
|
||
def make_set_title_handler(cfg: SessionEndpointConfig) -> Handler:
|
||
"""Lifted body for ``POST {prefix}/{ws_id}/title``.
|
||
|
||
Sets a user-chosen title manually. Stored as the workstream *alias*
|
||
so it outranks the LLM auto-title in the display fallback chain
|
||
(``alias > title > name``). Both kinds share the auth → validate →
|
||
``set_workstream_alias`` → ``on_rename`` sequence. Returns 409 when
|
||
the name collides with another workstream's alias.
|
||
|
||
Behavior matches the pre-lift interactive handler: the alias is set
|
||
against storage regardless of whether the session is loaded (so a
|
||
saved/closed workstream can still be renamed), and the live
|
||
``on_rename`` broadcast fires only when the session is in memory.
|
||
"""
|
||
|
||
async def set_title(request: Request) -> Response:
|
||
import asyncio
|
||
|
||
from turnstone.core.memory import set_workstream_alias
|
||
from turnstone.core.web_helpers import read_json_or_400
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
if not ws_id:
|
||
return JSONResponse({"error": "ws_id is required"}, status_code=400)
|
||
|
||
if cfg.tenant_check is not None:
|
||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||
if err_tenant is not None:
|
||
return err_tenant
|
||
|
||
# Resolve the workstream BEFORE writing the alias. ``set_workstream_alias``
|
||
# is a global, kind-unscoped UPDATE keyed on ``ws_id`` alone (it returns
|
||
# True even on a 0-row match), so a kind that has no ``tenant_check``
|
||
# storage gate (coord — the in-memory manager is its existence + kind
|
||
# authority) must 404 here, or an operator could rename a workstream this
|
||
# manager doesn't own (e.g. an interactive ws via the coord route) and a
|
||
# bogus id would silently 200. Interactive keeps ``tenant_check`` as its
|
||
# existence gate, so this stays skipped there and a non-loaded
|
||
# saved/closed ws still renames.
|
||
ws = mgr.get(ws_id)
|
||
if cfg.tenant_check is None and ws is None:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
|
||
body = await read_json_or_400(request)
|
||
if isinstance(body, JSONResponse):
|
||
return body
|
||
title = str(body.get("title", "")).strip()
|
||
if not title:
|
||
return JSONResponse({"error": "title is required"}, status_code=400)
|
||
title = title[:80]
|
||
|
||
if not await asyncio.to_thread(set_workstream_alias, ws_id, title):
|
||
return JSONResponse(
|
||
{"error": "That name is already used by another workstream"},
|
||
status_code=409,
|
||
)
|
||
|
||
if ws is not None and ws.session is not None and ws.session.ui is not None:
|
||
ws.session.ui.on_rename(title)
|
||
return JSONResponse({"status": "ok", "title": title})
|
||
|
||
return set_title
|
||
|
||
|
||
CancelAuditEmitter = Callable[
|
||
["Request", str, "Workstream", bool],
|
||
None,
|
||
]
|
||
|
||
# Optional async step run AFTER the workstream's own cancel sequence
|
||
# (session.cancel + approval resolution + force/SSE + audit), receiving
|
||
# ``(request, ws_id, ws)``. Coordinator wires this to authorize + prepare the
|
||
# cancel fan-out to its spawned children (auto-propagation down the subtree);
|
||
# interactive wires ``None`` and the handler is unchanged. It RETURNS a
|
||
# ``BackgroundTask`` (or ``None``) which the handler attaches to its response,
|
||
# so the actual per-child fan-out runs AFTER the 200 is sent — the owner's
|
||
# cancel is never blocked on child HTTP. Errors are logged, never surfaced —
|
||
# a cascade failure must not fail the owner's own cancel.
|
||
PostCancelHook = Callable[["Request", str, "Workstream"], Awaitable["BackgroundTask | None"]]
|
||
|
||
|
||
def make_cancel_handler(
|
||
cfg: SessionEndpointConfig,
|
||
*,
|
||
audit_emit: CancelAuditEmitter | None = None,
|
||
post_cancel: PostCancelHook | None = None,
|
||
) -> Handler:
|
||
"""Lifted body for ``POST {prefix}/{ws_id}/cancel``.
|
||
|
||
Cancels in-flight generation on a workstream. Sets the cooperative
|
||
cancel flag on the session, unblocks any pending approval, and
|
||
(when the request body asks for it) force-abandons a stuck worker
|
||
thread so the UI recovers immediately.
|
||
|
||
Both kinds share the cancel sequence (``session.cancel`` →
|
||
``ui.resolve_approval(False)``).
|
||
Per-kind divergence captured via the cfg + ``audit_emit``:
|
||
|
||
- ``cancel_forensics`` (cfg) — when set, the lifted body calls
|
||
it with ``(session, ui, was_running=...)`` and surfaces the
|
||
result as the response's ``dropped`` key. Interactive wires
|
||
``_capture_cancel_forensics`` so the model-invoked
|
||
``cancel_workstream`` tool can tell operators what got killed
|
||
(pending-approval tool names, queued-message preview); coord
|
||
wires ``None`` and the response's ``dropped`` is ``{}``.
|
||
- ``audit_emit`` — receives ``(request, ws_id, ws, force)``.
|
||
Coord wires its ``coordinator.cancel`` audit hook; interactive
|
||
wires ``None`` (cancel isn't audited on interactive today —
|
||
preserved for behavioural parity with the pre-lift handler).
|
||
|
||
Args:
|
||
cfg: per-kind policy bundle (auth, manager lookup, tenant
|
||
check, error labels, ``cancel_forensics``).
|
||
audit_emit: kind's audit emitter for the cancel event.
|
||
``None`` skips the audit entirely.
|
||
|
||
Behavior changes vs the pre-lift handlers:
|
||
|
||
- **Coord gains the ``force`` flag.** Pre-lift coord ignored
|
||
``force``; the lifted body honours it on both kinds (parity
|
||
gain — coord workers can hang the same way interactive's can,
|
||
and operators benefit from the same recovery path).
|
||
- **Coord response shape now includes ``dropped: {}``.**
|
||
Pre-lift coord returned bare ``{"status": "ok"}``; the unified
|
||
shape always carries ``dropped`` so SDK consumers don't have
|
||
to branch on kind. Coord's ``dropped`` is ``{}`` until coord
|
||
grows its own forensic capture.
|
||
- **Coord cancel returns 400 when ``ws.session is None``** (the
|
||
placeholder/build-failed path). Pre-lift coord called
|
||
``coord_mgr.cancel`` which silently no-op'd on a placeholder;
|
||
the lifted body 400s for parity with interactive's existing
|
||
"No session" branch.
|
||
- Pending approvals are denied via ``resolve_all_approvals`` —
|
||
cancel addresses the workstream, so EVERY live cycle (parallel
|
||
task agents can park several gates at once) wakes with its own
|
||
denied result. The sweep is a no-op when nothing is pending
|
||
(no stale ``approval_resolved`` broadcast on idle cancels);
|
||
legacy/stub UIs without it fall back to the old single-slot
|
||
``resolve_approval`` gated on ``_pending_approval``.
|
||
"""
|
||
|
||
async def cancel(request: Request) -> Response:
|
||
import asyncio
|
||
|
||
from turnstone.core.web_helpers import read_json_or_400
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
# See ``make_approve_handler`` for the cast rationale.
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
|
||
# Body is optional — only ``force`` is read. An empty body is
|
||
# a valid cancel request (the original coord URL took no body
|
||
# at all; preserve that ergonomic). Malformed JSON is treated
|
||
# as no body rather than 400'd: cancel is a recovery verb and
|
||
# should work even when the caller's JSON is junk.
|
||
force = False
|
||
try:
|
||
body = await read_json_or_400(request)
|
||
except Exception:
|
||
body = None
|
||
if isinstance(body, dict):
|
||
force = body.get("force", False) is True
|
||
|
||
if cfg.tenant_check is not None:
|
||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||
if err_tenant is not None:
|
||
return err_tenant
|
||
|
||
ws = mgr.get(ws_id)
|
||
if ws is None:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
session = ws.session
|
||
ui = ws.ui
|
||
if session is None or ui is None:
|
||
return JSONResponse({"error": "No session"}, status_code=400)
|
||
|
||
was_running = bool(getattr(ws, "_worker_running", False))
|
||
dropped: dict[str, Any] = {}
|
||
if cfg.cancel_forensics is not None:
|
||
try:
|
||
dropped = cfg.cancel_forensics(session, ui, was_running=was_running)
|
||
except Exception:
|
||
# Forensics is observational — never let a snapshot
|
||
# bug block the actual cancel. Log and proceed with
|
||
# an empty dropped dict.
|
||
log.debug("ws.cancel.forensics_failed ws=%s", ws_id[:8], exc_info=True)
|
||
dropped = {}
|
||
|
||
# Always set the cooperative cancel flag — cheap, no harm if
|
||
# nothing's running. Cancel addresses the WORKSTREAM, so it
|
||
# denies EVERY live approval cycle: with parallel task agents
|
||
# several gate threads can be parked at once and each must wake
|
||
# with its own (denied) result. ``resolve_all_approvals`` is a
|
||
# no-op with no live cycles (returns 0, no SSE broadcast), so
|
||
# idle cancels stay silent — the same property the old
|
||
# pending-slot gate provided; the recovery semantics for a
|
||
# stuck approval-pending state are preserved because a stuck
|
||
# cycle IS a live cycle. Legacy/stub UIs without the sweep
|
||
# keep the old single-slot fallback.
|
||
try:
|
||
session.cancel()
|
||
except Exception:
|
||
log.debug("ws.cancel.session_failed ws=%s", ws_id[:8], exc_info=True)
|
||
try:
|
||
if hasattr(ui, "resolve_all_approvals"):
|
||
ui.resolve_all_approvals(False, "Cancelled by user")
|
||
elif (
|
||
hasattr(ui, "resolve_approval")
|
||
and getattr(ui, "_pending_approval", None) is not None
|
||
):
|
||
ui.resolve_approval(False, "Cancelled by user")
|
||
except Exception:
|
||
log.debug(
|
||
"ws.cancel.resolve_approval_failed ws=%s",
|
||
ws_id[:8],
|
||
exc_info=True,
|
||
)
|
||
|
||
# The remaining steps only matter when a worker is actually
|
||
# running: force-recovery has nothing to recover otherwise,
|
||
# and the SSE ``cancelled`` event would mislead consumers that
|
||
# have no in-flight generation to cancel.
|
||
if was_running:
|
||
if force:
|
||
# Force cancel: abandon the stuck worker thread (daemon,
|
||
# will die on process exit or stream timeout) and emit
|
||
# stream_end so the UI and session recover immediately.
|
||
# The per-generation cancel flag stays set so the
|
||
# abandoned thread still kills subprocesses at its next
|
||
# checkpoint. Clear ``_worker_running`` alongside
|
||
# ``worker_thread`` so a follow-up send doesn't see the
|
||
# ``(_worker_running=True, worker_thread=None)``
|
||
# half-state and route through ``enqueue()`` to the
|
||
# abandoned worker's queue (which won't drain — the
|
||
# cancel flag short-circuits the abandoned thread
|
||
# before it reaches the queue-drain seam, leaving the
|
||
# queued message orphaned until the next spawn).
|
||
# ``session_worker.send`` documents this invariant:
|
||
# "readers gating on either flag see a coherent
|
||
# (worker_thread, _worker_running) pair."
|
||
#
|
||
# Documented bet — force-cancelling a wedged QUICK command
|
||
# (worker_kind == "command", e.g. /resume stuck in storage
|
||
# I/O): clearing the flag releases the pending-send
|
||
# drain's park (_drain_pending_sends polls the same
|
||
# (_worker_running, worker_kind) pair the parked /send
|
||
# used to), so a deferred message's fresh worker can then
|
||
# run while the abandoned command thread finishes its
|
||
# in-place mutation — quick commands have no generation
|
||
# checkpoints to retire them (compact_now does). Same
|
||
# blast radius as force-abandoning a send worker
|
||
# mid-tool; accepted because force-cancel is the operator
|
||
# escape hatch for an already-wedged session, not a
|
||
# routine path. Revisit if commands ever gain generation
|
||
# discipline.
|
||
with ws._lock:
|
||
ws.worker_thread = None
|
||
ws._worker_running = False
|
||
if hasattr(ui, "_enqueue"):
|
||
try:
|
||
ui._enqueue({"type": "stream_end"})
|
||
except Exception:
|
||
log.debug(
|
||
"ws.cancel.stream_end_failed ws=%s",
|
||
ws_id[:8],
|
||
exc_info=True,
|
||
)
|
||
if hasattr(ui, "on_state_change"):
|
||
try:
|
||
ui.on_state_change("idle")
|
||
except Exception:
|
||
log.debug(
|
||
"ws.cancel.idle_state_failed ws=%s",
|
||
ws_id[:8],
|
||
exc_info=True,
|
||
)
|
||
elif hasattr(ui, "_enqueue"):
|
||
try:
|
||
ui._enqueue({"type": "cancelled"})
|
||
except Exception:
|
||
log.debug(
|
||
"ws.cancel.cancelled_event_failed ws=%s",
|
||
ws_id[:8],
|
||
exc_info=True,
|
||
)
|
||
|
||
if audit_emit is not None:
|
||
try:
|
||
audit_emit(request, ws_id, ws, force)
|
||
except Exception:
|
||
# Mirrors make_close_handler — audit-write failures
|
||
# shouldn't surface as HTTP 500. Log + continue.
|
||
log.warning(
|
||
"ws.cancel.audit_failed ws=%s",
|
||
ws_id[:8] if ws_id else "",
|
||
exc_info=True,
|
||
)
|
||
|
||
# Propagate the cancel down the subtree (coordinator only). Runs
|
||
# after the owner's own cancel is fully recorded so a cascade error
|
||
# can't strand the owner half-cancelled. ``post_cancel`` authorizes
|
||
# and prepares the fan-out, returning a BackgroundTask we attach to the
|
||
# response: the per-child dispatch runs AFTER the 200 is sent, so the
|
||
# owner's cancel never blocks on child HTTP (and we don't drain).
|
||
cancel_background: BackgroundTask | None = None
|
||
if post_cancel is not None:
|
||
try:
|
||
cancel_background = await post_cancel(request, ws_id, ws)
|
||
except Exception:
|
||
log.warning(
|
||
"ws.cancel.cascade_failed ws=%s",
|
||
ws_id[:8] if ws_id else "",
|
||
exc_info=True,
|
||
)
|
||
|
||
return JSONResponse({"status": "ok", "dropped": dropped}, background=cancel_background)
|
||
|
||
return cancel
|
||
|
||
|
||
RewindAuditEmitter = Callable[
|
||
["Request", str, "Workstream", int],
|
||
None,
|
||
]
|
||
RetryAuditEmitter = Callable[
|
||
["Request", str, "Workstream"],
|
||
None,
|
||
]
|
||
# (ws, user_msg) -> None. Re-sends ``user_msg`` on ``ws`` via the kind's
|
||
# worker dispatch (driving :func:`turnstone.core.session_worker.send`
|
||
# with the kind's own run / enqueue callbacks). The retry handler calls
|
||
# it after :meth:`ChatSession.retry` truncates the last turn.
|
||
RetryDispatcher = Callable[
|
||
["Workstream", str],
|
||
None,
|
||
]
|
||
|
||
|
||
def make_rewind_handler(
|
||
cfg: SessionEndpointConfig,
|
||
*,
|
||
audit_emit: RewindAuditEmitter | None = None,
|
||
accepted_permissions: tuple[str, ...] = (),
|
||
) -> Handler:
|
||
"""Lifted body for ``POST {prefix}/{ws_id}/rewind`` (body ``{"turns": N}``).
|
||
|
||
Drops the last ``N`` conversation turns via :meth:`ChatSession.rewind`
|
||
(kind-agnostic: mutates ``messages`` + ``_msg_tokens`` + storage, with
|
||
attachment / FTS cleanup riding the app-level cascade inside
|
||
``delete_messages_after`` — which is exactly why both kinds reuse
|
||
``rewind()`` rather than bespoke SQL). Both kinds share the auth →
|
||
mgr → ws-lookup → busy-gate → ``rewind`` → ``clear_ui`` → audit
|
||
sequence.
|
||
|
||
Unlike :func:`make_close_handler`, the body emits a ``clear_ui`` event
|
||
after the mutation — **always, including a rewind to zero messages**.
|
||
The frontend keys its REST ``/history`` refetch (and any queued
|
||
edit-and-resend) off this signal, not an inline history payload; the
|
||
unconditional emit carries the PR #503 fix (an ``if history:`` guard
|
||
once froze the composer on rewind-to-zero).
|
||
|
||
Args:
|
||
cfg: per-kind policy bundle (auth, manager lookup, tenant check,
|
||
error labels). Captured by closure.
|
||
audit_emit: kind's audit emitter; receives
|
||
``(request, ws_id, ws, turns)``. Wrapped in try/except — an
|
||
audit-write failure logs a warning, never an HTTP 500.
|
||
**Both kinds hardcode the ``conversation.rewind`` action**
|
||
(NOT ``cfg.audit_action_prefix`` — there is deliberately no
|
||
``coordinator.rewind`` split).
|
||
accepted_permissions: fallback scope check used only when
|
||
``cfg.permission_gate`` is ``None``. Interactive wires
|
||
``("conversation.modify",)``; coord leaves it empty and
|
||
relies on its ``admin.coordinator`` ``permission_gate``.
|
||
"""
|
||
|
||
async def rewind(request: Request) -> Response:
|
||
import asyncio
|
||
|
||
from turnstone.core.auth import require_any_permission
|
||
from turnstone.core.web_helpers import read_json_or_400
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
elif accepted_permissions:
|
||
err = require_any_permission(request, accepted_permissions)
|
||
if err is not None:
|
||
return err
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
# See ``make_approve_handler`` for the cast rationale.
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
|
||
body = await read_json_or_400(request)
|
||
if isinstance(body, JSONResponse):
|
||
return body
|
||
raw_turns = body.get("turns")
|
||
# ``bool`` is an ``int`` subclass — reject it explicitly so
|
||
# ``{"turns": true}`` can't sneak through as "rewind 1".
|
||
if not isinstance(raw_turns, int) or isinstance(raw_turns, bool) or raw_turns < 1:
|
||
return JSONResponse(
|
||
{"error": "turns must be a positive integer"},
|
||
status_code=400,
|
||
)
|
||
|
||
if cfg.tenant_check is not None:
|
||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||
if err_tenant is not None:
|
||
return err_tenant
|
||
|
||
ws = mgr.get(ws_id)
|
||
if ws is None:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
session = ws.session
|
||
ui = ws.ui
|
||
if session is None or ui is None:
|
||
return JSONResponse({"error": "No session"}, status_code=400)
|
||
|
||
# Reject rewind while a generation is in flight — mutating
|
||
# ``messages`` under a running worker corrupts history / cursors.
|
||
# Gate on ``_worker_running`` (not ``worker_thread.is_alive()``)
|
||
# for parity with session_worker.send.
|
||
with ws._lock:
|
||
if ws._worker_running:
|
||
if hasattr(ui, "_enqueue"):
|
||
ui._enqueue(
|
||
{"type": "busy_error", "message": "Cannot rewind while processing."}
|
||
)
|
||
return JSONResponse({"status": "busy"})
|
||
|
||
removed = session.rewind(raw_turns)
|
||
|
||
if hasattr(ui, "_enqueue"):
|
||
ui._enqueue({"type": "clear_ui"})
|
||
|
||
if audit_emit is not None:
|
||
try:
|
||
audit_emit(request, ws_id, ws, raw_turns)
|
||
except Exception:
|
||
log.warning(
|
||
"ws.rewind.audit_failed ws=%s",
|
||
ws_id[:8] if ws_id else "",
|
||
exc_info=True,
|
||
)
|
||
|
||
return JSONResponse({"status": "ok", "removed": removed})
|
||
|
||
return rewind
|
||
|
||
|
||
def make_retry_handler(
|
||
cfg: SessionEndpointConfig,
|
||
*,
|
||
dispatch_retry: RetryDispatcher,
|
||
audit_emit: RetryAuditEmitter | None = None,
|
||
accepted_permissions: tuple[str, ...] = (),
|
||
) -> Handler:
|
||
"""Lifted body for ``POST {prefix}/{ws_id}/retry`` (no body).
|
||
|
||
Drops the last assistant response via :meth:`ChatSession.retry` and
|
||
re-sends the last user message for a fresh generation. Shares the
|
||
auth → mgr → ws-lookup → busy-gate → ``retry`` → ``clear_ui`` →
|
||
audit → re-dispatch sequence across kinds.
|
||
|
||
The re-send goes through ``dispatch_retry`` (a per-kind closure that
|
||
drives :func:`turnstone.core.session_worker.send` with the kind's own
|
||
``run`` / ``enqueue`` callbacks) rather than a hand-rolled thread, so
|
||
both kinds converge on the shared worker-dispatch primitive instead
|
||
of open-coding a third copy. A retry issued while busy is rejected up
|
||
front by the busy-gate below; the dispatcher's ``enqueue`` callback
|
||
hard-rejects (rather than queues) so the rare check-then-dispatch
|
||
race can't silently defer the resend behind the in-flight turn.
|
||
|
||
``clear_ui`` fires after ``retry()`` regardless of whether anything
|
||
was dropped (idempotent REST refetch on the frontend), matching the
|
||
pre-lift interactive handler.
|
||
|
||
Args:
|
||
cfg: per-kind policy bundle.
|
||
dispatch_retry: ``(ws, user_msg) -> None`` re-send closure.
|
||
Required — retry is meaningless without it.
|
||
audit_emit: kind's audit emitter; receives ``(request, ws_id,
|
||
ws)``. **Both kinds hardcode the ``conversation.retry``
|
||
action.** Wrapped in try/except.
|
||
accepted_permissions: fallback scope check used only when
|
||
``cfg.permission_gate`` is ``None`` (interactive wires
|
||
``("conversation.modify",)``).
|
||
"""
|
||
|
||
async def retry(request: Request) -> Response:
|
||
import asyncio
|
||
|
||
from turnstone.core.auth import require_any_permission
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
elif accepted_permissions:
|
||
err = require_any_permission(request, accepted_permissions)
|
||
if err is not None:
|
||
return err
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
|
||
if cfg.tenant_check is not None:
|
||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||
if err_tenant is not None:
|
||
return err_tenant
|
||
|
||
ws = mgr.get(ws_id)
|
||
if ws is None:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
session = ws.session
|
||
ui = ws.ui
|
||
if session is None or ui is None:
|
||
return JSONResponse({"error": "No session"}, status_code=400)
|
||
|
||
with ws._lock:
|
||
if ws._worker_running:
|
||
if hasattr(ui, "_enqueue"):
|
||
ui._enqueue({"type": "busy_error", "message": "Cannot retry while processing."})
|
||
return JSONResponse({"status": "busy"})
|
||
|
||
# A retry is a fresh turn initiated by the authenticated caller —
|
||
# rebind per-user MCP credential resolution to them before the
|
||
# re-send dispatches (the per-kind ``dispatch_retry`` closure
|
||
# calls ``send()`` without identity kwargs). getattr-guarded so
|
||
# per-kind session stubs without the method keep working.
|
||
from turnstone.core.web_helpers import auth_user_id
|
||
|
||
acting_uid = auth_user_id(request)
|
||
bind_acting = getattr(session, "bind_acting_user", None)
|
||
if acting_uid and callable(bind_acting):
|
||
bind_acting(acting_uid)
|
||
|
||
retry_msg = session.retry()
|
||
|
||
if hasattr(ui, "_enqueue"):
|
||
ui._enqueue({"type": "clear_ui"})
|
||
|
||
if audit_emit is not None:
|
||
try:
|
||
audit_emit(request, ws_id, ws)
|
||
except Exception:
|
||
log.warning(
|
||
"ws.retry.audit_failed ws=%s",
|
||
ws_id[:8] if ws_id else "",
|
||
exc_info=True,
|
||
)
|
||
|
||
retried = retry_msg is not None
|
||
if retry_msg is not None:
|
||
dispatch_retry(ws, retry_msg)
|
||
|
||
return JSONResponse({"status": "ok", "retried": retried})
|
||
|
||
return retry
|
||
|
||
|
||
def make_open_handler(
|
||
cfg: SessionEndpointConfig,
|
||
*,
|
||
audit_emit: OpenAuditEmitter | None = None,
|
||
) -> Handler:
|
||
"""Lifted body for ``POST {prefix}/{ws_id}/open``.
|
||
|
||
Loads a persisted workstream into memory under its original
|
||
ws_id (vs ``resume`` which forks into a fresh ws_id). Both kinds
|
||
share the auth → mgr → already-loaded shortcut → ``mgr.open()``
|
||
→ 404-on-miss sequence; per-kind divergence captured by the
|
||
cfg + ``audit_emit``:
|
||
|
||
- ``cfg.open_resolve_alias`` — interactive wires
|
||
:func:`turnstone.core.memory.resolve_workstream` so callers
|
||
can pass user-friendly aliases ("my-debug-ws") in the path
|
||
param. Coord wires ``None`` (hex ids only).
|
||
- ``cfg.open_post_load`` — interactive uses it for UI-replay
|
||
events (``clear_ui`` + history) plus a handler-side
|
||
``ws_created`` enqueue onto the global SSE queue. Coord wires
|
||
``None`` and relies on the cluster collector fan-out triggered
|
||
by ``CoordinatorAdapter.emit_rehydrated``.
|
||
- ``audit_emit`` — kind's audit hook for the ``open`` event.
|
||
Interactive wires ``workstream.opened``; coord wires ``None``
|
||
(coord doesn't audit open today).
|
||
|
||
Pre-lift behaviour preserved on both kinds with one important
|
||
fix: **interactive previously called ``mgr.create(ws_id=...)``
|
||
+ ``ws.session.resume(...)`` to rehydrate, bypassing
|
||
``mgr.open()`` entirely**. After this lift both kinds route
|
||
through ``mgr.open()`` — which makes ``emit_rehydrated``
|
||
reachable on interactive (it had been dead-by-routing pre-lift)
|
||
and gives the manager a single rehydrate code path to maintain.
|
||
See § Post-P3 reckoning item #3 in
|
||
``1.5.0-session-manager-stage-2.md`` for the design history.
|
||
|
||
Args:
|
||
cfg: per-kind policy bundle.
|
||
audit_emit: kind's audit hook. ``None`` skips the audit.
|
||
"""
|
||
|
||
async def open_ws(request: Request) -> Response:
|
||
import asyncio
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
# See ``make_approve_handler`` for the cast rationale.
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
if not ws_id:
|
||
return JSONResponse({"error": "ws_id is required"}, status_code=400)
|
||
|
||
# Optional alias resolution. Interactive lets callers pass
|
||
# user-friendly aliases; coord skips this entirely.
|
||
if cfg.open_resolve_alias is not None:
|
||
resolved = cfg.open_resolve_alias(ws_id)
|
||
if not resolved:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
ws_id = resolved
|
||
|
||
# Tenancy gate BEFORE the already-loaded shortcut and before
|
||
# ``mgr.open`` rehydrates — otherwise ``open`` is a private-project
|
||
# existence/metadata oracle (it returns the auto-titled name) and an
|
||
# unauthorized resurrection of a closed private workstream into the
|
||
# pool. Interactive wires ownership, coord wires project tenancy.
|
||
if cfg.tenant_check is not None:
|
||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||
if err_tenant is not None:
|
||
return err_tenant
|
||
|
||
# Already-loaded shortcut — both kinds return the same
|
||
# ``{ws_id, name, already_loaded: true}`` shape.
|
||
existing = mgr.get(ws_id)
|
||
if existing is not None:
|
||
return JSONResponse(
|
||
{
|
||
"ws_id": existing.id,
|
||
"name": existing.name,
|
||
"already_loaded": True,
|
||
}
|
||
)
|
||
|
||
try:
|
||
ws = mgr.open(ws_id)
|
||
except ValueError as exc:
|
||
# Session factory misconfig (e.g., a model alias that
|
||
# no longer exists). Surface the factory's remediation
|
||
# text as a 503 so the operator can fix it without
|
||
# digging through stack traces. Same shape coord used
|
||
# pre-lift; standardised across both kinds here.
|
||
log.warning("ws.open.factory_misconfig ws_id=%s exc=%r", ws_id[:8], exc)
|
||
return JSONResponse({"error": _safe_factory_misconfig_message(exc)}, status_code=503)
|
||
except Exception:
|
||
# Bare ``Exception`` is intentional: ``mgr.open`` can
|
||
# raise from ``adapter.build_session`` (no documented
|
||
# exception spec — depends on the kind's session factory)
|
||
# or from ``ChatSession.resume`` propagating a partial-
|
||
# restore failure (corrupted workstream_config row,
|
||
# model-registry mismatch on saved alias, etc.). Either
|
||
# way the workstream isn't loadable; the operator needs
|
||
# the correlation-id'd log entry to diagnose.
|
||
#
|
||
# Don't echo the exception text — it can leak internal
|
||
# paths / frame names. Log with a correlation id and
|
||
# return that to the client so support can match a
|
||
# report to the log line. Mirrors coord's pre-lift
|
||
# ``coordinator_open`` 500 path.
|
||
import secrets
|
||
|
||
correlation_id = secrets.token_hex(4)
|
||
log.warning(
|
||
"ws.open.rehydrate_failed correlation_id=%s ws_id=%s",
|
||
correlation_id,
|
||
ws_id[:8] if ws_id else "",
|
||
exc_info=True,
|
||
)
|
||
# Per-kind noun in the user-facing error so coord callers
|
||
# see "failed to open coordinator" and interactive callers
|
||
# see "failed to open workstream" (matching the pre-lift
|
||
# ``coordinator_open`` / ``open_workstream`` wording on
|
||
# both sides). ``audit_action_prefix`` is the existing
|
||
# per-kind label both lifespans already construct
|
||
# ("workstream" / "coordinator"); reusing it here gives
|
||
# the cfg field its first runtime reader.
|
||
kind_noun = cfg.audit_action_prefix or "workstream"
|
||
return JSONResponse(
|
||
{
|
||
"error": (
|
||
f"failed to open {kind_noun} (internal error). "
|
||
f"correlation_id={correlation_id}"
|
||
)
|
||
},
|
||
status_code=500,
|
||
)
|
||
|
||
# Both except branches above ``return``; ``ws`` is bound here.
|
||
if ws is None:
|
||
# ``mgr.open`` returns None for missing rows, kind
|
||
# mismatch, and tombstoned rows — all surface as 404
|
||
# for the caller (the kind-specific failure mode is
|
||
# internal detail).
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
|
||
# Kind-specific post-load action (interactive: UI replay +
|
||
# handler-side ws_created enqueue; coord: None and the
|
||
# cluster collector handles the fan-out via the adapter's
|
||
# emit_rehydrated path).
|
||
if cfg.open_post_load is not None:
|
||
try:
|
||
# Off-loop: interactive's post_load does blocking
|
||
# storage I/O (a workstream display-name lookup) that
|
||
# would otherwise stall the event loop on every
|
||
# workstream open.
|
||
await asyncio.to_thread(cfg.open_post_load, request, ws)
|
||
except Exception:
|
||
# Post-load is observational — never let a hook bug
|
||
# block the open. Log + continue.
|
||
log.debug(
|
||
"ws.open.post_load_failed ws=%s",
|
||
ws.id[:8],
|
||
exc_info=True,
|
||
)
|
||
|
||
if audit_emit is not None:
|
||
try:
|
||
audit_emit(request, ws)
|
||
except Exception:
|
||
# Mirrors make_close_handler / make_cancel_handler
|
||
# — audit-write failures shouldn't surface as HTTP
|
||
# 500. Log + continue.
|
||
log.warning(
|
||
"ws.open.audit_failed ws=%s",
|
||
ws.id[:8],
|
||
exc_info=True,
|
||
)
|
||
|
||
return JSONResponse({"ws_id": ws.id, "name": ws.name})
|
||
|
||
return open_ws
|
||
|
||
|
||
def make_events_handler(cfg: SessionEndpointConfig) -> Handler:
|
||
"""Lifted body for ``GET {prefix}/{ws_id}/events`` — per-workstream SSE.
|
||
|
||
Both kinds share the SSE plumbing: register the per-UI listener
|
||
queue, run the kind-specific initial replay (``cfg.events_replay``,
|
||
typically ``connected`` + ``status`` + ``history`` + pending
|
||
approval / plan on interactive; just pending approval / plan on
|
||
coord), then drain the queue forever until either the workstream
|
||
closes (``ws_closed`` event) or the client disconnects.
|
||
|
||
The kind-specific divergence is captured entirely by
|
||
``cfg.events_replay``. The live-loop body, the listener
|
||
registration, the ``ws_closed`` exit, the disconnect detection,
|
||
and the SSE-connect/disconnect metric recording are uniform.
|
||
|
||
Pre-lift behaviour preserved on both kinds with two small
|
||
convergence wins:
|
||
|
||
- **Coord gains SSE connect/disconnect metrics.** Pre-lift coord
|
||
did no metric recording on its events stream; the lifted body
|
||
always calls ``metrics.record_sse_connect()`` / ``...disconnect()``,
|
||
which gives the cluster dashboard the same per-stream
|
||
observability interactive's had since 1.0.
|
||
- **Both kinds now check ``request.is_disconnected()`` between
|
||
polls AND the ``ws_closed`` event.** Pre-lift interactive
|
||
relied solely on ``ws_closed`` to terminate (which never fires
|
||
if the client just goes away without a proper close); pre-lift
|
||
coord relied solely on ``is_disconnected``. The lifted body
|
||
uses both — whichever fires first wins.
|
||
|
||
Args:
|
||
cfg: per-kind policy bundle. ``events_replay`` is the only
|
||
field the events body reads beyond the standard
|
||
permission_gate / manager_lookup / tenant_check prelude.
|
||
"""
|
||
# Lazy-imported at factory call time so the metrics module isn't
|
||
# dragged into ``session_routes.py``'s top-level import graph
|
||
# (which is consumed by the ``client_type="chat"`` channel
|
||
# gateway, where the metrics collector is irrelevant).
|
||
from turnstone.core.metrics import metrics as _metrics
|
||
|
||
async def events(request: Request) -> Response:
|
||
import asyncio
|
||
import json
|
||
import queue
|
||
|
||
from sse_starlette import EventSourceResponse
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
# See ``make_approve_handler`` for the cast rationale.
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
if not ws_id:
|
||
return JSONResponse({"error": "ws_id is required"}, status_code=400)
|
||
|
||
if cfg.tenant_check is not None:
|
||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||
if err_tenant is not None:
|
||
return err_tenant
|
||
|
||
ws = mgr.get(ws_id)
|
||
if ws is None:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
ui = ws.ui
|
||
# The listener-queue methods aren't on the ``SessionUI``
|
||
# Protocol surface (they live on ``SessionUIBase``), so
|
||
# extract via ``getattr`` after presence checks. Both kinds'
|
||
# production UIs subclass ``SessionUIBase``; the placeholder /
|
||
# build-failed UI path may have neither.
|
||
register = getattr(ui, "_register_listener", None) if ui is not None else None
|
||
unregister = getattr(ui, "_unregister_listener", None) if ui is not None else None
|
||
if ui is None or register is None or unregister is None:
|
||
# Placeholder / build-failed UI — there's no listener
|
||
# queue to attach to. 409 (not 404) because the
|
||
# workstream EXISTS in the manager but its UI is half-built.
|
||
# Pre-lift coord returned 409 for this case; pre-lift
|
||
# interactive 404'd. Lifted converges on 409 across
|
||
# kinds — more accurate for the workstream-exists-but-
|
||
# half-built shape.
|
||
return JSONResponse({"error": "session has no UI"}, status_code=409)
|
||
|
||
# ``Last-Event-ID`` resume: native EventSource auto-reconnect
|
||
# sends the header; the manual-reconnect path (which uses
|
||
# ``new EventSource(url)`` and can't set custom headers) sends
|
||
# ``?last_event_id=N``. Accept both; malformed values fall
|
||
# back to fresh-connect semantics so a broken intermediary
|
||
# can't break replay for a client that genuinely lost no
|
||
# events.
|
||
last_event_id_raw = request.headers.get("Last-Event-ID") or request.query_params.get(
|
||
"last_event_id"
|
||
)
|
||
last_event_id: int | None
|
||
try:
|
||
last_event_id = int(last_event_id_raw) if last_event_id_raw else None
|
||
except (TypeError, ValueError):
|
||
last_event_id = None
|
||
|
||
# Three replay shapes:
|
||
# - ``last_event_id is None`` → ``"fresh"`` (today's behaviour):
|
||
# replay_cb + state_change + in_progress_snapshot + live.
|
||
# - ``last_event_id`` + buffer covers gap → ``"replay_ok"``:
|
||
# emit buffered events past the id, SKIP replay_cb /
|
||
# state_change / in_progress_snapshot (the buffered stream
|
||
# already contains them), then live drain.
|
||
# - ``last_event_id`` + buffer too short → ``"truncated"``:
|
||
# emit a ``replay_truncated`` envelope so the client knows
|
||
# it lost live ticks, then fall through to the fresh
|
||
# replay (history / state_change / in_progress_snapshot)
|
||
# as the recovery floor.
|
||
# The placeholder-UI guard above (which 409s when
|
||
# ``_register_listener`` is missing) already proves that
|
||
# ``ui`` is a ``SessionUIBase`` subclass, so the cast is
|
||
# tightening the type, not weakening it.
|
||
ui_base = cast("SessionUIBase", ui)
|
||
replay_status: str
|
||
replay_events: list[dict[str, Any]] = []
|
||
lost_count = 0
|
||
earliest_available_id = 0
|
||
in_progress_snap: dict[str, Any]
|
||
snap_seq: int = 0
|
||
if last_event_id is None:
|
||
replay_status = "fresh"
|
||
client_queue, in_progress_snap = ui_base.register_listener_with_in_progress_snapshot()
|
||
snap_seq = in_progress_snap["seq"]
|
||
else:
|
||
(
|
||
client_queue,
|
||
replay_events,
|
||
replay_status,
|
||
lost_count,
|
||
earliest_available_id,
|
||
snapshot,
|
||
) = ui_base.register_listener_with_replay(last_event_id)
|
||
if replay_status == "truncated":
|
||
# Truncated → emit ``replay_truncated`` envelope, then
|
||
# the snapshot is the recovery floor. ``snap_seq``
|
||
# MUST come from the snapshot capture (not 0), because
|
||
# writers can race between
|
||
# ``register_listener_with_replay`` returning and our
|
||
# first live-drain read: any token event landing in
|
||
# the listener queue between registration and the
|
||
# captured ``_event_id`` is ALSO covered by the
|
||
# snapshot's content/reasoning text, and would
|
||
# double-render without the ``_seq <= snap_seq`` dedup
|
||
# filter on the live path. The helper captured both
|
||
# under the same nested-lock acquire, so this
|
||
# ``snap_seq`` is exactly the high-water mark
|
||
# corresponding to the snapshot text.
|
||
in_progress_snap = snapshot
|
||
snap_seq = snapshot["seq"]
|
||
else:
|
||
# ``replay_ok``: the buffered events ARE the partial
|
||
# token stream (no separate snapshot needed); the
|
||
# synthetic snapshot/state_change/history emission is
|
||
# skipped by the events handler. No live-dedup
|
||
# filtering required because the buffered events
|
||
# themselves are the cutoff — anything past the last
|
||
# replayed event id is genuinely new live traffic
|
||
# that lands in the listener queue after the buffer
|
||
# snapshot was taken (atomic-against-writers under
|
||
# the registration's nested locks).
|
||
in_progress_snap = {"content": "", "reasoning": "", "seq": 0}
|
||
|
||
# Per-kind executor for the blocking ``client_queue.get``
|
||
# wait. Interactive returns its dedicated 200-thread
|
||
# ``sse_executor`` so SSE polling stays isolated from every
|
||
# other ``asyncio.to_thread`` caller in the process; coord
|
||
# returns ``None`` and the lifted body falls back to the
|
||
# default executor (capped at ``min(32, cpu+4)``). Pre-lift
|
||
# interactive used the dedicated pool too — the lookup
|
||
# restores that isolation under the lifted contract.
|
||
live_executor = (
|
||
cfg.sse_executor_lookup(request) if cfg.sse_executor_lookup is not None else None
|
||
)
|
||
# Capture the replay callback in a local so the inner
|
||
# generator's closure doesn't have to re-read the cfg field.
|
||
replay_cb = cfg.events_replay
|
||
|
||
async def event_generator() -> Any:
|
||
import functools
|
||
import random
|
||
|
||
_metrics.record_sse_connect()
|
||
loop = asyncio.get_running_loop()
|
||
|
||
def _format_event(event: dict[str, Any]) -> dict[str, str]:
|
||
"""Strip internal plumbing fields, attach SSE ``id:`` if present.
|
||
|
||
Shallow-copies the dict before any mutation because
|
||
``_enqueue`` puts ONE reference into every listener
|
||
queue (no per-listener copy) and stores the SAME
|
||
reference in the per-ws ring buffer. Without a
|
||
shallow copy here, listener A's pop of ``_event_id``
|
||
would silently strip the field from listener B's view
|
||
AND from the buffer's view, breaking the replay
|
||
guarantee for a later-arriving subscriber.
|
||
"""
|
||
ev_copy = dict(event)
|
||
eid = ev_copy.pop("_event_id", None)
|
||
# Strip ``_seq`` here too — it's internal plumbing for
|
||
# the snapshot dedup; clients never need to see it on
|
||
# the wire. The fresh-path live drain filters on
|
||
# ``_seq`` BEFORE calling this helper.
|
||
ev_copy.pop("_seq", None)
|
||
out: dict[str, str] = {"data": json.dumps(ev_copy)}
|
||
if eid is not None:
|
||
out["id"] = str(eid)
|
||
return out
|
||
|
||
try:
|
||
# Per-stream reconnect interval jitter. Without this,
|
||
# all panes on a workstream disconnect together and
|
||
# reconnect in lockstep at the same backoff intervals
|
||
# (EventSource's default ~3 s with no jitter, or
|
||
# whatever ``retry:`` value the server last sent).
|
||
# 2.5 – 4.5 s spread keeps the average reconnect rate
|
||
# below today's ping cadence while staggering peaks.
|
||
yield {"retry": random.randint(2500, 4500)}
|
||
|
||
if replay_status == "replay_ok":
|
||
# Buffered events already cover everything since
|
||
# the client's ``Last-Event-ID`` — skip the
|
||
# synthetic replay (history / state_change /
|
||
# in_progress_snapshot) which would otherwise
|
||
# double-render content the buffer already
|
||
# contains. Yield buffered events in order with
|
||
# their ``_event_id`` as SSE ``id:`` so a
|
||
# disconnect mid-replay resumes from the latest
|
||
# buffered id, not the original ``last_event_id``.
|
||
for ev in replay_events:
|
||
yield _format_event(ev)
|
||
else:
|
||
# ``fresh`` or ``truncated`` — both run the
|
||
# synthetic replay (kind-specific replay_cb +
|
||
# state_change + in_progress_snapshot). On
|
||
# ``truncated`` we emit the explicit envelope
|
||
# first so the client knows the buffer couldn't
|
||
# cover the gap and treats the snapshot below as
|
||
# the recovery floor.
|
||
if replay_status == "truncated":
|
||
yield {
|
||
"data": json.dumps(
|
||
{
|
||
"type": "replay_truncated",
|
||
"ws_id": ws_id,
|
||
"lost_count": lost_count,
|
||
"earliest_available_id": earliest_available_id,
|
||
}
|
||
)
|
||
}
|
||
|
||
# Replay phase — stream the kind-specific initial
|
||
# payload one event at a time so the client sees
|
||
# the first byte immediately. Pre-building into
|
||
# a list would block time-to-first-byte until the
|
||
# entire replay materialized AND let the listener
|
||
# queue accumulate (potentially over its 500-slot
|
||
# cap on a chatty mid-generation workstream)
|
||
# while replay was being built. Synthetic
|
||
# events carry no ``_event_id`` — they intentionally
|
||
# don't advance the client's ``lastEventId``, so
|
||
# a mid-replay disconnect reconnects with the
|
||
# last BUFFERED id (or none on truly-fresh
|
||
# connect), which is what the server can replay.
|
||
if replay_cb is not None:
|
||
try:
|
||
for ev in replay_cb(ws, ui, request):
|
||
yield {"data": json.dumps(ev)}
|
||
except Exception:
|
||
# Replay is observational — never let a
|
||
# snapshot bug block the live stream.
|
||
log.debug(
|
||
"ws.events.replay_failed ws=%s",
|
||
ws_id[:8],
|
||
exc_info=True,
|
||
)
|
||
# Refresh-resume tail: emit the current
|
||
# workstream state and the in-progress snapshot.
|
||
# Both are best-effort — a ws.state read failure
|
||
# or empty buffers just yields nothing extra.
|
||
try:
|
||
cur_state = getattr(ws.state, "value", None)
|
||
if isinstance(cur_state, str) and cur_state:
|
||
state_evt: dict[str, Any] = {
|
||
"type": "state_change",
|
||
"state": cur_state,
|
||
"ws_id": ws_id,
|
||
}
|
||
# A client connecting mid-turn learns who holds it,
|
||
# so it can gate its send button (matches the live
|
||
# state_change emitted from server.WebUI).
|
||
sess = getattr(ws, "session", None)
|
||
acting = (
|
||
getattr(sess, "_acting_user_id", "")
|
||
or getattr(sess, "_user_id", "")
|
||
if sess is not None
|
||
else ""
|
||
)
|
||
if acting:
|
||
state_evt["acting_user_id"] = acting
|
||
yield {"data": json.dumps(state_evt)}
|
||
except Exception:
|
||
log.debug(
|
||
"ws.events.state_change_replay_failed ws=%s",
|
||
ws_id[:8],
|
||
exc_info=True,
|
||
)
|
||
if in_progress_snap["content"] or in_progress_snap["reasoning"]:
|
||
yield {
|
||
"data": json.dumps(
|
||
{
|
||
"type": "in_progress_snapshot",
|
||
"content": in_progress_snap["content"],
|
||
"reasoning": in_progress_snap["reasoning"],
|
||
"ws_id": ws_id,
|
||
}
|
||
)
|
||
}
|
||
# Surface the persisted ``last_error`` so a fresh
|
||
# connect to a workstream sitting in the error state
|
||
# shows WHY it failed (the ``error`` text bubble), not
|
||
# just the bare error state + retry affordance.
|
||
# ``on_error`` is never persisted as a message, so
|
||
# ``/history`` can't rebuild it — the ``last_error``
|
||
# config row (set by ``_record_fatal_error``, cleared
|
||
# on recovery) is the only durable source. Gated on
|
||
# the error state so a healthy ws skips the storage
|
||
# read, and confined to this fresh/truncated path —
|
||
# the ``replay_ok`` branch's ring buffer already
|
||
# carries the original ``error`` event.
|
||
try:
|
||
if getattr(ws.state, "value", None) == "error":
|
||
from turnstone.core.memory import load_last_error
|
||
|
||
last_err = await asyncio.to_thread(load_last_error, ws_id)
|
||
if last_err:
|
||
# Carry the SSE ``id:`` (registration-time
|
||
# buffer position) so the client's
|
||
# ``lastEventId`` advances past this surface.
|
||
# Unlike the idempotent ``state_change`` /
|
||
# ``in_progress_snapshot`` above, the client
|
||
# APPENDS this ``error`` bubble (non-idempotent),
|
||
# and a terminal-errored idle ws emits no live
|
||
# event to set a cursor — so without an ``id:``
|
||
# a native EventSource reconnect would send no
|
||
# ``Last-Event-ID``, re-run this fresh path, and
|
||
# append a DUPLICATE bubble on every reconnect
|
||
# cycle. With it, the reconnect resumes via
|
||
# ``replay_ok`` (nothing buffered past
|
||
# ``snap_seq`` on an idle ws) and skips this
|
||
# surface.
|
||
yield {
|
||
"id": str(snap_seq),
|
||
"data": json.dumps(
|
||
{
|
||
"type": "error",
|
||
"message": last_err,
|
||
"ws_id": ws_id,
|
||
}
|
||
),
|
||
}
|
||
except Exception:
|
||
log.debug(
|
||
"ws.events.last_error_replay_failed ws=%s",
|
||
ws_id[:8],
|
||
exc_info=True,
|
||
)
|
||
# Live phase — drain the per-UI listener queue
|
||
# until either the workstream closes or the client
|
||
# disconnects. 5s poll matches pre-lift interactive
|
||
# (the ``is_disconnected`` probe between polls covers
|
||
# cancel-detection latency the timeout would
|
||
# otherwise gate; shortening to 1s 5x'd the wakeup
|
||
# rate without any client-observable benefit).
|
||
#
|
||
# ``_seq`` filter: token events are tagged with the
|
||
# per-ws event counter at enqueue time. On the
|
||
# ``fresh`` and ``truncated`` paths, events whose seq
|
||
# is already covered by the snapshot we just yielded
|
||
# get dropped to avoid double-rendering. On the
|
||
# ``replay_ok`` path ``snap_seq`` is 0 so no live
|
||
# event is filtered — the replayed buffer slice has
|
||
# already established the cutoff.
|
||
while True:
|
||
if await request.is_disconnected():
|
||
return
|
||
if getattr(client_queue, "poisoned", False):
|
||
# The queue overflowed: it latched ``poisoned``
|
||
# at the FIRST rejected put, freezing its
|
||
# contents as a contiguous prefix (see
|
||
# ``_ListenerQueue``).
|
||
if getattr(client_queue, "closing", False):
|
||
# ws teardown raced the overflow: the queue is
|
||
# poisoned AND its ws is closing. Unwind as a
|
||
# CLEAN close — no ``stream_overflow`` frame,
|
||
# which would otherwise pollute the client's
|
||
# drop-vs-wedge instrumentation and trip its
|
||
# reconnect limiter on a ws that is simply gone
|
||
# (the poisoned queue can't accept the in-band
|
||
# ``ws_closed`` sentinel, so ``closing`` is the
|
||
# only close signal it will ever see). Recovery
|
||
# of the frozen tail is the ``/history`` reload,
|
||
# not a reconnect — the ws is gone.
|
||
return
|
||
# Genuine slow-consumer overflow (ws still live).
|
||
# Close now — the queued backlog is discarded,
|
||
# because the ring buffer replays everything past
|
||
# the client's ``Last-Event-ID`` on the native
|
||
# EventSource reconnect. Delivering the backlog
|
||
# first would only stall recovery behind the very
|
||
# consumer that couldn't keep up. The farewell
|
||
# frame is id-less so ``lastEventId`` stays below
|
||
# the gap; the client counts these closes for its
|
||
# reconnect rate-limiter and the drop-vs-render-
|
||
# wedge field instrumentation.
|
||
log.info(
|
||
"ws.events.overflow_close ws=%s",
|
||
ws_id[:8],
|
||
)
|
||
yield {"data": json.dumps({"type": "stream_overflow", "ws_id": ws_id})}
|
||
return
|
||
# NOT poisoned: a ``closing`` ws is handled by the
|
||
# in-band ``ws_closed`` sentinel below, AFTER the FIFO
|
||
# drain delivers every queued event. Returning here on
|
||
# ``closing`` (as an earlier revision did) would drop a
|
||
# healthy-but-slightly-behind client's queued tail (the
|
||
# turn's final content batch + ``stream_end``) at
|
||
# teardown — a permanent truncation, since a close has
|
||
# no reconnect+replay to repaint it.
|
||
try:
|
||
event = await loop.run_in_executor(
|
||
live_executor,
|
||
functools.partial(client_queue.get, timeout=5),
|
||
)
|
||
except queue.Empty:
|
||
continue # ping keeps the connection alive
|
||
if event.get("type") == "ws_closed":
|
||
return
|
||
seq = event.get("_seq")
|
||
if seq is not None and seq <= snap_seq:
|
||
continue
|
||
yield _format_event(event)
|
||
finally:
|
||
_metrics.record_sse_disconnect()
|
||
unregister(client_queue)
|
||
|
||
return EventSourceResponse(event_generator(), ping=5)
|
||
|
||
return events
|
||
|
||
|
||
def make_create_handler(
|
||
cfg: SessionEndpointConfig,
|
||
*,
|
||
audit_emit: CreateAuditEmitter | None = None,
|
||
accepted_permissions: tuple[str, ...] = (),
|
||
) -> Handler:
|
||
"""Lifted body for ``POST {prefix}/new`` — workstream creation.
|
||
|
||
Both kinds share the create sequence (parse body → resolve uid
|
||
→ kind-specific validate → resolve skill → ``mgr.create`` (with
|
||
``defer_emit_created=True``) → save attachments → ``mgr.discard``
|
||
on validation failure / ``mgr.commit_create`` on success → audit
|
||
→ kind-specific post-install → respond). Per-kind divergence
|
||
captured by the cfg + ``audit_emit``:
|
||
|
||
- ``cfg.create_supports_attachments`` — when ``True``, the body
|
||
may arrive as ``multipart/form-data`` with a ``meta`` JSON
|
||
field + ``file`` parts; uploads are validated post-create and
|
||
the workstream is rolled back if any file fails (interactive's
|
||
pre-lift pattern, lifted to coord here for parity).
|
||
- ``cfg.create_supports_user_id_override`` — when ``True``, a
|
||
``user_id`` body field overrides the auth-derived uid if the
|
||
auth token is from a trusted service. Interactive ``True`` so
|
||
console-proxied creates carry the real end-user identity;
|
||
coord ``False``.
|
||
- ``cfg.create_validate_request`` — kind-specific pre-create
|
||
gates (interactive: ws_id format, kind, parent_ws_id ownership,
|
||
attachments+resume_ws combo; coord: 401-on-empty-uid).
|
||
- ``cfg.create_build_kwargs`` — kind-specific kwargs for
|
||
``mgr.create``. Required when the kind mounts a create handler.
|
||
- ``cfg.create_post_install`` — kind-specific tail end (e.g.
|
||
interactive's resume + skill_config + initial-message worker
|
||
thread; coord's initial_message via coord_adapter.send).
|
||
- ``audit_emit`` — ``workstream.created`` on interactive,
|
||
``coordinator.create`` on coord.
|
||
|
||
Ordering invariants (load-bearing — easy to break in a refactor):
|
||
|
||
1. ``mgr.create(defer_emit_created=True)`` runs FIRST so the
|
||
slot + storage row + session exist before any post-create
|
||
work touches them.
|
||
2. Attachment validation runs BEFORE ``commit_create`` so a
|
||
rejected upload produces zero lifecycle events. Failure path
|
||
is ``mgr.discard`` + ``delete_workstream``; success path
|
||
falls through.
|
||
3. ``mgr.commit_create(ws)`` runs BEFORE ``audit_emit`` and
|
||
``post_install`` so any state-change events ``post_install``
|
||
triggers (e.g. a worker dispatched on ``initial_message``)
|
||
reach the cluster collector for an already-known ws_id.
|
||
Reordering this commit after the worker dispatch puts
|
||
``emit_state`` on the wire ahead of ``emit_created``.
|
||
|
||
Behavior changes vs the pre-lift handlers (documented in
|
||
CHANGELOG, mostly coord-up-to-interactive parity gains):
|
||
|
||
- **Coord gains create-time attachments.** Pre-lift
|
||
``coordinator_create`` accepted JSON only and ignored uploads;
|
||
the lifted body parses multipart bodies on coord and saves
|
||
attachments through the kind-agnostic storage layer (§ Post-P3
|
||
reckoning item #1). When the same request supplies an
|
||
``initial_message``, the uploads are resolved from the buffer onto
|
||
the dispatched first turn via ``CoordinatorAdapter.send`` (which
|
||
gained ``attachments`` + ``send_id`` kwargs in the same
|
||
release).
|
||
- **No phantom create→close pair on coord rollback.** The lifted
|
||
body now passes ``defer_emit_created=True`` to ``mgr.create``
|
||
and explicitly fires ``mgr.commit_create(ws)`` only after
|
||
attachment validation passes. On failure ``mgr.discard(ws.id)``
|
||
releases the slot WITHOUT firing ``emit_closed`` (because the
|
||
create was never advertised). Pre-fix, coord's ``mgr.create``
|
||
fired ``emit_created`` synchronously and a rollback then
|
||
called ``mgr.close``, surfacing a quick create→close pair on
|
||
the cluster events stream that consumers had to reconcile via
|
||
the collector's diff path. Post-fix, a rejected upload
|
||
produces zero events. Interactive's ``emit_created`` is a
|
||
documented no-op stub so the deferral is observably a no-op
|
||
there; the ``ws_created`` broadcast on the global SSE queue
|
||
continues to fire from the kind's post_install callback.
|
||
- **Coord gains the disabled-skill rejection.** Pre-lift
|
||
``coordinator_create`` silently allowed disabled skills to
|
||
flow through to ``mgr.create``; the lifted body returns 400
|
||
("Skill not found or disabled") matching interactive's
|
||
behaviour. Disabled skills are inert by definition; the gate
|
||
makes that explicit.
|
||
- **Both kinds converge on 200 OK.** Pre-lift interactive
|
||
returned 200 (default); coord returned 201. SDK consumers
|
||
that were branching on ``response.status == 201`` on coord
|
||
should switch to ``response.ok``. 200 was picked over 201 for
|
||
response-shape parity with the rest of the v1 surface (every
|
||
other shared verb returns 200), at the cost of leaving REST-
|
||
strictly-correct semantics on the table — a one-time release
|
||
note rather than ongoing client churn.
|
||
- **Both kinds converge on the manager-at-capacity 429
|
||
semantic.** Pre-lift interactive translated mgr.create's
|
||
``RuntimeError`` to 400 ("invalid create request"); coord
|
||
already translated to 429. RuntimeError on ``SessionManager.create``
|
||
is documented as "manager at capacity" — 429 (rate-limit /
|
||
try-later) is the correct shape for both.
|
||
- **Both kinds converge on the factory-misconfig 503
|
||
semantic.** Pre-lift interactive let ``ValueError`` (raised by
|
||
the session factory on a misconfigured model alias) propagate
|
||
as 500; coord already translated to 503. The lifted body uses
|
||
503 with the factory's remediation text on both kinds —
|
||
operators get the actionable message instead of a generic
|
||
stack-traced 500.
|
||
- **Both kinds get a correlation_id'd 500 on unexpected
|
||
``mgr.create`` failure.** Pre-lift interactive let unexpected
|
||
exceptions propagate as 500 with a stack-traced response
|
||
(potential information leak); coord already returned a
|
||
correlation_id'd 500 with the message redacted. The lifted
|
||
body adopts coord's safer pattern on both kinds.
|
||
- **Audit-emit failures no longer 500.** Pre-lift interactive
|
||
audit failures surfaced as HTTP 500 (no try/except); coord
|
||
swallowed via try/except + log.debug. The lifted body wraps
|
||
``audit_emit`` in try/except + ``warning`` log, returning the
|
||
successful 200 to the caller. Mirrors the close / cancel /
|
||
open lift contracts.
|
||
- **Always-include response shape.** The lifted body always
|
||
returns ``{ws_id, name, resumed, message_count, attachment_ids}``,
|
||
with the parity fields defaulting to ``False`` / ``0`` / ``[]``
|
||
on kinds whose post-install doesn't populate them. SDK
|
||
consumers don't branch on kind.
|
||
|
||
Args:
|
||
cfg: per-kind policy bundle.
|
||
audit_emit: kind's audit emitter for the create event.
|
||
``None`` skips the audit entirely.
|
||
"""
|
||
# Lazy-imported at factory call time (mirrors the events lift) so
|
||
# ``session_routes.py``'s top-level import graph stays tight.
|
||
|
||
async def create(request: Request) -> Response:
|
||
import asyncio
|
||
import contextlib
|
||
import secrets
|
||
|
||
from turnstone.core.attachments import (
|
||
IMAGE_SIZE_CAP,
|
||
validate_and_save_uploaded_files,
|
||
)
|
||
from turnstone.core.auth import require_any_permission
|
||
from turnstone.core.web_helpers import (
|
||
read_json_or_400,
|
||
read_multipart_create_or_400,
|
||
)
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
elif accepted_permissions:
|
||
err = require_any_permission(request, accepted_permissions)
|
||
if err is not None:
|
||
return err
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
# See ``make_approve_handler`` for the cast rationale.
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
|
||
# --- Body parsing -------------------------------------------------
|
||
# Multipart only when the cfg lights up attachments AND the
|
||
# caller actually sent a multipart body. Plain JSON stays the
|
||
# default content type for both kinds.
|
||
content_type = (request.headers.get("content-type") or "").lower()
|
||
uploaded_files: list[tuple[str, str, bytes]] = []
|
||
body: dict[str, Any]
|
||
if cfg.create_supports_attachments and content_type.startswith("multipart/form-data"):
|
||
# Multipart cap: up to 10 files × the image cap, plus slack
|
||
# for JSON meta + multipart framing. Per-file size is
|
||
# enforced inside :func:`validate_and_save_uploaded_files`
|
||
# against the kind-specific cap.
|
||
parsed = await read_multipart_create_or_400(
|
||
request,
|
||
max_files=10,
|
||
max_per_file_bytes=IMAGE_SIZE_CAP,
|
||
max_total_bytes=10 * IMAGE_SIZE_CAP,
|
||
)
|
||
if isinstance(parsed, JSONResponse):
|
||
return parsed
|
||
body, uploaded_files = parsed
|
||
else:
|
||
json_body = await read_json_or_400(request)
|
||
if isinstance(json_body, JSONResponse):
|
||
return json_body
|
||
body = json_body
|
||
|
||
# --- User id resolution ------------------------------------------
|
||
# Auth middleware populates ``request.state.auth_result`` for
|
||
# every authed request; we just read the user_id off it.
|
||
auth = getattr(getattr(request, "state", None), "auth_result", None)
|
||
uid: str = getattr(auth, "user_id", "") or ""
|
||
if cfg.create_supports_user_id_override:
|
||
# Trusted services (currently just ``console``) may forward
|
||
# the real end-user's id in the body so console-proxied
|
||
# creates carry the right owner. Token sources on end-user
|
||
# tokens (including console-proxy tokens that carry the
|
||
# real user's identity at the auth layer) are NOT trusted;
|
||
# only service identities. The deny-by-default keeps a
|
||
# malicious caller from impersonating other users.
|
||
body_uid = body.get("user_id")
|
||
if (
|
||
isinstance(body_uid, str)
|
||
and body_uid
|
||
and auth is not None
|
||
and getattr(auth, "token_source", "") in {"console"}
|
||
):
|
||
uid = body_uid
|
||
|
||
# --- Per-kind pre-create validation ------------------------------
|
||
# Interactive validates ws_id format, kind, parent ownership,
|
||
# attachments+resume_ws combo. Coord 401s on empty uid.
|
||
if cfg.create_validate_request is not None:
|
||
err_validate = await cfg.create_validate_request(request, body, uid, uploaded_files)
|
||
if err_validate is not None:
|
||
return err_validate
|
||
|
||
# --- require_project gate ----------------------------------------
|
||
# Gated by the declarative cfg.create_gate_require_project capability
|
||
# (True on both the interactive and coordinator create mounts) rather
|
||
# than a hardcoded kind literal, matching the create_supports_* idiom.
|
||
# Sessions a coordinator SPAWNS remain exempt — that's the
|
||
# token_source == "coordinator" branch inside
|
||
# require_project_denies_create, not a mount property. On the
|
||
# interactive mount, by this point the validator has applied any
|
||
# parent-/resume-inherited project_id into body AND (for a fork)
|
||
# discarded any explicit pick to the source's project or "", so a
|
||
# private/dangling/projectless fork SOURCE funnels to the SAME uniform
|
||
# 400 as a projectless fresh create. (Coordinator has no fork/resume —
|
||
# its validator only checks attachability of an explicit pick.)
|
||
if cfg.create_gate_require_project:
|
||
from turnstone.core.auth import (
|
||
REQUIRE_PROJECT_CODE,
|
||
REQUIRE_PROJECT_ERROR,
|
||
require_project_denies_create,
|
||
)
|
||
|
||
_config_store = getattr(request.app.state, "config_store", None)
|
||
if require_project_denies_create(_config_store, auth, body.get("project_id")):
|
||
return JSONResponse(
|
||
{"error": REQUIRE_PROJECT_ERROR, "code": REQUIRE_PROJECT_CODE},
|
||
status_code=400,
|
||
)
|
||
|
||
# --- Skill resolution --------------------------------------------
|
||
# Both kinds resolve a body ``skill`` field through
|
||
# ``get_skill_by_name`` to the skill_data dict + the next
|
||
# applied_skill_version. Interactive previously skipped this
|
||
# entirely on resume_ws (the resumed session restores its own
|
||
# skill from config); the resume gate is captured by the
|
||
# interactive validator above (it returns 400 on the
|
||
# attachments+resume combo, but standalone resume_ws + skill
|
||
# is still allowed). To preserve that exact pre-lift skip on
|
||
# interactive, the validator may stash a sentinel — but
|
||
# simplest: re-read resume_ws_id here and skip skill lookup
|
||
# when both kinds see a non-empty resume_ws_id (coord doesn't
|
||
# support resume_ws today; the field is silently ignored).
|
||
# Strip whitespace on the skill name so a caller passing
|
||
# ``"skill": " "`` is treated identically to ``"skill": ""``
|
||
# (skip skill resolution). Pre-lift coord explicitly stripped
|
||
# via ``(body.get("skill") or "").strip() or None``; pre-lift
|
||
# interactive didn't strip but never received whitespace-only
|
||
# skill names from the web UI. Convergence on the safer
|
||
# behaviour avoids a misleading 400 for an inert payload.
|
||
body_skill_raw = body.get("skill") or ""
|
||
body_skill = body_skill_raw.strip() if isinstance(body_skill_raw, str) else ""
|
||
resume_ws_id_raw = body.get("resume_ws") or ""
|
||
skill_data: dict[str, Any] | None = None
|
||
applied_skill_version = 0
|
||
|
||
# --- mgr.create (with skill resolution) -------------------------
|
||
# Skill lookup + version count + ``mgr.create`` all live inside
|
||
# one try/except so any storage failure during skill resolution
|
||
# gets the same correlation_id'd 500 as a ``mgr.create``
|
||
# exception. Pre-lift interactive let storage exceptions
|
||
# propagate to a stack-traced 500; the lifted body keeps the
|
||
# 500 status but redacts the message (operator gets the
|
||
# correlation id; logs carry the full ``exc_info``). The
|
||
# ``RuntimeError`` (capacity) and ``ValueError`` (factory
|
||
# misconfig) branches stay specific to ``mgr.create``: the
|
||
# skill-lookup path doesn't raise either of those.
|
||
if cfg.create_build_kwargs is None:
|
||
# The cfg required a build_kwargs callback for any kind
|
||
# mounting a create handler. Surface the misconfig as 500
|
||
# with a clear log line so the operator sees it instead of
|
||
# a confusing AttributeError.
|
||
log.error("ws.create.misconfigured_no_build_kwargs")
|
||
return JSONResponse(
|
||
{"error": "create handler misconfigured"},
|
||
status_code=500,
|
||
)
|
||
try:
|
||
if body_skill and not (isinstance(resume_ws_id_raw, str) and resume_ws_id_raw):
|
||
from turnstone.core.storage._registry import get_storage as _get_storage
|
||
|
||
# Call ``storage.get_prompt_template_by_name`` directly
|
||
# rather than going through
|
||
# ``turnstone.core.memory.get_skill_by_name`` — that
|
||
# helper swallows all storage exceptions into ``None``,
|
||
# which would mask a real outage as the 400 "Skill not
|
||
# found or disabled" branch below. Calling storage
|
||
# directly lets exceptions propagate to the lifted
|
||
# body's correlation_id'd 500 path so operators chasing
|
||
# a "Skill not found" report can distinguish real
|
||
# misses from registry outages.
|
||
_st = _get_storage()
|
||
if _st is None:
|
||
return JSONResponse({"error": "storage unavailable"}, status_code=503)
|
||
skill_data = await asyncio.to_thread(_st.get_prompt_template_by_name, body_skill)
|
||
if not skill_data or not skill_data.get("enabled", False):
|
||
return JSONResponse(
|
||
{"error": f"Skill not found or disabled: {body_skill}"},
|
||
status_code=400,
|
||
)
|
||
tid = skill_data.get("template_id")
|
||
if tid:
|
||
# ``count_skill_versions`` is best-effort: if the
|
||
# version count call fails (transient storage
|
||
# blip), default to 1 rather than aborting the
|
||
# whole create. Persisted skill_version=1 is the
|
||
# right semantic for the first applied instance
|
||
# even if the count was unobtainable.
|
||
try:
|
||
applied_skill_version = (
|
||
await asyncio.to_thread(_st.count_skill_versions, str(tid)) + 1
|
||
)
|
||
except Exception:
|
||
log.debug(
|
||
"ws.create.skill_version_failed skill=%s",
|
||
body_skill,
|
||
exc_info=True,
|
||
)
|
||
applied_skill_version = 1
|
||
skill_id_resolved = (
|
||
str(skill_data["template_id"])
|
||
if skill_data and skill_data.get("template_id")
|
||
else ""
|
||
)
|
||
|
||
# --- Persona resolution (resolve ONCE, stamp forever) --------
|
||
# Same gate shape as the skill lookup above: an explicit name
|
||
# must exist, be enabled, and support this kind (400
|
||
# otherwise); an empty name resolves to the kind's default
|
||
# persona. A pre-seed database (no default persona) creates
|
||
# unstamped — legacy behavior, byte-identical to the
|
||
# engineer/orchestrator defaults. Resume skips resolution:
|
||
# the resumed session restores its own stamp from config.
|
||
body_persona_raw = body.get("persona") or ""
|
||
# [:64] matches the console proxy's cap: real slugs fit, and an
|
||
# oversized value must not reach the storage lookup or reflect
|
||
# into the 400 error text.
|
||
body_persona = (body_persona_raw.strip() if isinstance(body_persona_raw, str) else "")[
|
||
:64
|
||
]
|
||
persona_snapshot = None
|
||
if isinstance(resume_ws_id_raw, str) and resume_ws_id_raw:
|
||
# Fork-resume adopts the SOURCE workstream's stamp, resolved
|
||
# pre-construction so all four levers (including the
|
||
# construction-time MCP gate) apply to the fork. The four
|
||
# levers a fork runs under must be the ones its conversation
|
||
# was authored under — never a fresh default. A corrupt
|
||
# stamp is a loud 400, mirroring the rehydrate contract; an
|
||
# unstamped (legacy) source forks unstamped.
|
||
from turnstone.core.memory import resolve_workstream
|
||
from turnstone.core.personas import snapshot_from_config
|
||
from turnstone.core.storage._registry import get_storage as _get_storage
|
||
|
||
_st = _get_storage()
|
||
resume_target = await asyncio.to_thread(resolve_workstream, resume_ws_id_raw)
|
||
if _st is not None and resume_target:
|
||
try:
|
||
persona_snapshot = snapshot_from_config(
|
||
await asyncio.to_thread(_st.load_workstream_config, resume_target) or {}
|
||
)
|
||
except ValueError as exc:
|
||
return JSONResponse(
|
||
{"error": f"cannot fork {resume_ws_id_raw}: {exc}"},
|
||
status_code=400,
|
||
)
|
||
else:
|
||
from turnstone.core.personas import (
|
||
resolve_persona_for_kind,
|
||
snapshot_from_persona,
|
||
)
|
||
from turnstone.core.storage._registry import get_storage as _get_storage
|
||
|
||
persona_row: dict[str, Any] | None = None
|
||
if body_persona:
|
||
_st = _get_storage()
|
||
if _st is None:
|
||
return JSONResponse({"error": "storage unavailable"}, status_code=503)
|
||
persona_row, persona_err = await asyncio.to_thread(
|
||
resolve_persona_for_kind, _st, body_persona, mgr.kind.value
|
||
)
|
||
if persona_err:
|
||
return JSONResponse({"error": persona_err}, status_code=400)
|
||
else:
|
||
# No explicit persona: stamp the kind's default. A clean
|
||
# ``None`` (no default configured — pre-seed DB) creates
|
||
# unstamped legacy, but a FAILED lookup must not: the
|
||
# operator may have promoted a restricted persona to
|
||
# default, and degrading to the stock envelope on a
|
||
# storage blip would silently widen it.
|
||
_st = _get_storage()
|
||
if _st is not None:
|
||
try:
|
||
persona_row = await asyncio.to_thread(
|
||
_st.get_default_persona, mgr.kind.value
|
||
)
|
||
except Exception:
|
||
log.warning("ws.create.default_persona_lookup_failed", exc_info=True)
|
||
return JSONResponse(
|
||
{"error": "persona resolution unavailable"},
|
||
status_code=503,
|
||
)
|
||
if persona_row is not None:
|
||
persona_snapshot = snapshot_from_persona(persona_row)
|
||
|
||
kwargs = cfg.create_build_kwargs(
|
||
request, body, uid, skill_data, skill_id_resolved, applied_skill_version
|
||
)
|
||
if persona_snapshot is not None:
|
||
# ``persona`` is SessionManager.create's explicit param
|
||
# (Workstream attr + workstreams row); the snapshot rides
|
||
# **extra_session_kwargs into the session factory.
|
||
kwargs["persona"] = persona_snapshot.name
|
||
kwargs["persona_snapshot"] = persona_snapshot
|
||
# Deferred emit — committed below post-attachment-
|
||
# validation. See handler docstring's Ordering invariants.
|
||
ws = await asyncio.to_thread(mgr.create, defer_emit_created=True, **kwargs)
|
||
except RuntimeError as exc:
|
||
# ``SessionManager.create`` documents RuntimeError as
|
||
# "manager at capacity" — translate to 429 (rate-limit /
|
||
# try-later) on both kinds.
|
||
return JSONResponse({"error": str(exc)}, status_code=429)
|
||
except ValueError as exc:
|
||
# Session factory raises ValueError on misconfigured alias
|
||
# (model alias points at a model that no longer exists,
|
||
# etc.). Surface the factory's remediation text as 503 so
|
||
# operators get the actionable message instead of a
|
||
# stack-traced 500. Sanitiser caps + scrubs the echoed
|
||
# text since the alias is user-controlled on the create
|
||
# path (body ``model`` / ``judge_model``).
|
||
log.warning("ws.create.factory_misconfig exc=%r", exc)
|
||
return JSONResponse({"error": _safe_factory_misconfig_message(exc)}, status_code=503)
|
||
except Exception:
|
||
# Don't echo the exception text — it can leak internal
|
||
# paths / frame names. Log with a correlation id and
|
||
# return that to the client so support can match a report
|
||
# to the log line.
|
||
correlation_id = secrets.token_hex(4)
|
||
log.warning(
|
||
"ws.create.failed correlation_id=%s",
|
||
correlation_id,
|
||
exc_info=True,
|
||
)
|
||
kind_noun = cfg.audit_action_prefix or "workstream"
|
||
return JSONResponse(
|
||
{
|
||
"error": (
|
||
f"failed to create {kind_noun} (internal error). "
|
||
f"correlation_id={correlation_id}"
|
||
)
|
||
},
|
||
status_code=500,
|
||
)
|
||
|
||
# --- Attachment validation + save + rollback --------------------
|
||
# Validate post-create so ``ws_id`` is bound. Rollback uses
|
||
# ``mgr.discard`` (no ``emit_closed`` because the create was
|
||
# deferred) + ``delete_workstream`` for the storage row. See
|
||
# handler docstring's Ordering invariants for the rationale.
|
||
attachment_ids: list[str] = []
|
||
if uploaded_files:
|
||
saved_ids, save_err = await asyncio.to_thread(
|
||
validate_and_save_uploaded_files, uploaded_files, ws.id, uid
|
||
)
|
||
if save_err is not None:
|
||
from turnstone.core.memory import delete_workstream as _delete_ws
|
||
|
||
with contextlib.suppress(Exception):
|
||
await asyncio.to_thread(mgr.discard, ws.id)
|
||
with contextlib.suppress(Exception):
|
||
await asyncio.to_thread(_delete_ws, ws.id)
|
||
return save_err
|
||
attachment_ids = saved_ids
|
||
|
||
# --- Commit the deferred emit_created ----------------------------
|
||
# Synchronous: in-memory non-blocking work on every kind
|
||
# (interactive: no-op stub; coord: dict + ``queue.put_nowait``).
|
||
mgr.commit_create(ws)
|
||
|
||
# --- Audit emit --------------------------------------------------
|
||
if audit_emit is not None:
|
||
try:
|
||
audit_emit(request, ws, body, uid)
|
||
except Exception:
|
||
# Mirrors make_close_handler / make_cancel_handler /
|
||
# make_open_handler — audit-write failures shouldn't
|
||
# surface as HTTP 500. Log + continue.
|
||
log.warning(
|
||
"ws.create.audit_failed ws=%s",
|
||
ws.id[:8] if ws.id else "",
|
||
exc_info=True,
|
||
)
|
||
|
||
# --- Per-kind post-install ---------------------------------------
|
||
extra_response: dict[str, Any] = {}
|
||
if cfg.create_post_install is not None:
|
||
extra_response = await cfg.create_post_install(
|
||
request,
|
||
ws,
|
||
body,
|
||
uid,
|
||
skill_data,
|
||
applied_skill_version,
|
||
attachment_ids,
|
||
)
|
||
|
||
create_payload: dict[str, Any] = {
|
||
"ws_id": ws.id,
|
||
"name": ws.name,
|
||
"resumed": bool(extra_response.get("resumed", False)),
|
||
"message_count": int(extra_response.get("message_count", 0)),
|
||
"attachment_ids": attachment_ids,
|
||
}
|
||
if extra_response.get("initial_message_status"):
|
||
# Present only when the post-install hook could NOT deliver
|
||
# the initial message (raced live worker, interjection queue
|
||
# full) — the workstream exists, but a bare 200 would read as
|
||
# "first message accepted". Mirrors /send's in-body
|
||
# ``queue_full`` backpressure surface.
|
||
create_payload["initial_message_status"] = str(extra_response["initial_message_status"])
|
||
return JSONResponse(create_payload)
|
||
|
||
return create
|
||
|
||
|
||
def make_list_handler(cfg: SessionEndpointConfig) -> Handler:
|
||
"""Lifted body for ``GET {prefix}`` — list workstreams in memory.
|
||
|
||
Both kinds share the listing sequence (auth → manager lookup →
|
||
``mgr.list_all()`` → row serialisation → respond). Per-kind
|
||
divergence captured by:
|
||
|
||
- ``cfg.permission_gate`` — coord's ``admin.coordinator`` check;
|
||
interactive ``None`` (auth middleware covers it).
|
||
- ``cfg.manager_lookup`` — already used by every other lifted
|
||
verb.
|
||
- ``cfg.list_resolve_titles`` — interactive's bulk user-alias
|
||
lookup; coord ``None``. Single ``SELECT ... WHERE ws_id IN
|
||
(...)`` resolves every active row's title in one storage
|
||
round-trip (replaces the pre-lift per-row N+1).
|
||
|
||
Always-include row shape: ``{ws_id, name, state, kind,
|
||
parent_ws_id, user_id}``. SDK consumers don't branch on kind.
|
||
|
||
Behaviour changes vs the pre-lift handlers (documented in
|
||
CHANGELOG):
|
||
|
||
- **Top-level response key converges on ``"workstreams"``.**
|
||
Pre-lift coord returned ``{"coordinators": [...]}``; the lifted
|
||
body returns ``{"workstreams": [...]}`` for response-shape
|
||
parity with interactive. Coord SDK / frontend consumers
|
||
branching on ``data.coordinators`` swap to ``data.workstreams``.
|
||
- **Interactive row key renames ``"id"`` → ``"ws_id"``.** Pre-
|
||
lift interactive used the bare ``id`` field while every other
|
||
shared verb on this surface (cancel, open, events, create,
|
||
saved-list) uses ``ws_id``. Convergence eliminates the
|
||
internal inconsistency. Frontend consumers reading
|
||
``ws.id`` from the active-list response swap to ``ws.ws_id``.
|
||
- **Always-include row fields.** ``user_id`` was coord-only;
|
||
``kind`` + ``parent_ws_id`` were interactive-only. Both
|
||
kinds now populate all three. ``parent_ws_id`` defaults to
|
||
``None`` for coord (coordinators have no parent).
|
||
- **Storage / manager-lock work moved off the event loop.**
|
||
``mgr.list_all()`` acquires the manager mutex; the title
|
||
resolution may dip into storage for the alias lookup. Both
|
||
now run via ``asyncio.to_thread`` (matching coord's pre-
|
||
existing perf-2 pattern from the saved-coordinators review).
|
||
|
||
Args:
|
||
cfg: per-kind policy bundle.
|
||
"""
|
||
|
||
async def list_workstreams_handler(request: Request) -> Response:
|
||
import asyncio
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
# See ``make_approve_handler`` for the cast rationale.
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
|
||
# Manager-lock + bulk title resolution off the event loop.
|
||
# ``list_all`` snapshots under the manager lock; the bulk
|
||
# alias lookup hits storage for every row in a single
|
||
# ``SELECT ... WHERE ws_id IN (...)`` (replaces the pre-lift
|
||
# per-row N+1). Running inline would stall every other async
|
||
# handler for the duration of the listing.
|
||
resolve_titles = cfg.list_resolve_titles
|
||
|
||
def _build_rows() -> list[dict[str, Any]]:
|
||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||
|
||
visibility = WorkstreamProjectVisibility.for_request(request)
|
||
wss = mgr.list_all()
|
||
titles: dict[str, str | None] = {}
|
||
if resolve_titles is not None and wss:
|
||
titles = resolve_titles([ws.id for ws in wss])
|
||
rows: list[dict[str, Any]] = []
|
||
for ws in wss:
|
||
raw_pid = getattr(ws, "project_id", "")
|
||
project_id = raw_pid if isinstance(raw_pid, str) else ""
|
||
# Same guarded read as project_id — test doubles and older
|
||
# node payloads may lack the attribute.
|
||
raw_persona = getattr(ws, "persona", "")
|
||
persona = raw_persona if isinstance(raw_persona, str) else ""
|
||
# Private-project tenancy — drop rows the requester may
|
||
# not see (same predicate as the saved list).
|
||
if not visibility.ws_visible(project_id, ws_owner=ws.user_id or ""):
|
||
continue
|
||
title = titles.get(ws.id) or ws.name
|
||
rows.append(
|
||
{
|
||
"ws_id": ws.id,
|
||
"name": title,
|
||
"state": ws.state.value,
|
||
"kind": ws.kind,
|
||
"parent_ws_id": ws.parent_ws_id,
|
||
"user_id": ws.user_id,
|
||
"project_id": project_id or None,
|
||
"persona": persona or None,
|
||
}
|
||
)
|
||
return rows
|
||
|
||
rows = await asyncio.to_thread(_build_rows)
|
||
return JSONResponse({"workstreams": rows})
|
||
|
||
return list_workstreams_handler
|
||
|
||
|
||
async def _collect_saved_rows(
|
||
cfg: SessionEndpointConfig,
|
||
request: Request,
|
||
) -> list[dict[str, Any]]:
|
||
"""Query + exclude-loaded + row-build for one kind's saved list.
|
||
|
||
The shared inner body of :func:`make_saved_handler` (and one of the
|
||
N bodies :func:`make_unified_saved_handler` fans over): runs the
|
||
storage query filtered by ``cfg.list_kind`` / ``cfg.saved_state_filter``,
|
||
drops any ws_id reported by ``cfg.saved_loaded_lookup`` (coord-only
|
||
warm-pool exclusion), and serialises each surviving row to the
|
||
saved-card dict shape.
|
||
|
||
Caller-owned (NOT done here): ``cfg.permission_gate`` and the
|
||
``cfg.list_kind is None`` misconfig guard — both belong to the
|
||
handler wrapper so the unified handler can gate once and 500 per
|
||
cfg before fanning out. ``cfg.list_kind`` is therefore assumed
|
||
non-``None`` on entry.
|
||
"""
|
||
import asyncio
|
||
|
||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||
from turnstone.core.memory import list_workstreams_with_history
|
||
|
||
visibility = WorkstreamProjectVisibility.for_request(request)
|
||
|
||
def _fetch_visible_rows() -> list[Any]:
|
||
"""Page through storage until 50 visible rows (or exhaustion).
|
||
|
||
The visibility filter runs post-SQL, so a plain LIMIT-then-filter
|
||
would silently shrink the window whenever recently-updated rows
|
||
belong to private projects the caller can't see — their own rows
|
||
at position 51+ would never surface. Paging with OFFSET restores
|
||
the 'top-50 most-recent VISIBLE' contract. Runs entirely in the
|
||
worker thread: both the query and the per-row project lookups
|
||
are storage I/O. Bounded at 20 pages (1000 rows scanned) as a
|
||
runaway guard; hitting it is logged, not silent.
|
||
"""
|
||
visible: list[Any] = []
|
||
offset = 0
|
||
page = 50
|
||
max_pages = 20
|
||
for _ in range(max_pages):
|
||
batch = list_workstreams_with_history(
|
||
limit=page,
|
||
kind=cfg.list_kind,
|
||
user_id=None,
|
||
state=cfg.saved_state_filter,
|
||
offset=offset,
|
||
)
|
||
for row in batch:
|
||
# project_id / owner are the SELECT tail — see the column
|
||
# order comment below.
|
||
if visibility.ws_visible(row[15], ws_owner=row[16] or ""):
|
||
visible.append(row)
|
||
if len(visible) >= 50:
|
||
return visible
|
||
if len(batch) < page:
|
||
return visible
|
||
offset += page
|
||
log.info(
|
||
"ws.saved.visibility_scan_capped kind=%s scanned=%d visible=%d",
|
||
cfg.list_kind,
|
||
max_pages * page,
|
||
len(visible),
|
||
)
|
||
return visible
|
||
|
||
rows = await asyncio.to_thread(_fetch_visible_rows)
|
||
|
||
# Coord-only: exclude ws_ids currently in the warm pool.
|
||
loaded: set[str] = set()
|
||
if cfg.saved_loaded_lookup is not None:
|
||
try:
|
||
loaded = await cfg.saved_loaded_lookup(request)
|
||
except Exception:
|
||
# Defence-in-depth filter — never let a lookup error
|
||
# block the saved list. Log + continue with empty
|
||
# set (worst case: a duplicate row in the saved list
|
||
# for a few seconds during a close-emit race).
|
||
log.debug(
|
||
"ws.saved.loaded_lookup_failed",
|
||
exc_info=True,
|
||
)
|
||
|
||
# Column order from list_workstreams_with_history (keep in sync with
|
||
# the storage SELECT): ws_id, alias, title, name, created, updated,
|
||
# message_count, node_id, state, kind, model_alias, launch_skill,
|
||
# child_count, context_tokens, context_window, project_id, owner,
|
||
# persona.
|
||
# The occupancy ratio is derived here (Python float division) rather
|
||
# than in SQL so the NULL / zero-window cases stay obvious and
|
||
# identical across backends. context_window is NULL for model
|
||
# aliases absent from model_definitions (e.g. config.toml-only
|
||
# models), so context_ratio degrades to 0.0 there rather than
|
||
# reporting a bogus occupancy.
|
||
result: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
(
|
||
wid,
|
||
alias,
|
||
title,
|
||
name,
|
||
created,
|
||
updated,
|
||
count,
|
||
node_id,
|
||
state,
|
||
kind,
|
||
model_alias,
|
||
launch_skill,
|
||
child_count,
|
||
context_tokens,
|
||
context_window,
|
||
project_id,
|
||
owner,
|
||
persona,
|
||
) = row
|
||
if wid in loaded:
|
||
continue
|
||
ctx_tokens = context_tokens or 0
|
||
context_ratio = (
|
||
round(ctx_tokens / context_window, 3) if ctx_tokens and context_window else 0.0
|
||
)
|
||
result.append(
|
||
{
|
||
"ws_id": wid,
|
||
"alias": alias,
|
||
"title": title,
|
||
"name": name,
|
||
"created": created,
|
||
"updated": updated,
|
||
"message_count": count,
|
||
"node_id": node_id or "",
|
||
"state": state,
|
||
"kind": kind,
|
||
"model_alias": model_alias or None,
|
||
"launch_skill": launch_skill or None,
|
||
"child_count": child_count or 0,
|
||
"context_tokens": ctx_tokens,
|
||
"context_ratio": context_ratio,
|
||
"project_id": project_id or None,
|
||
"persona": persona or None,
|
||
}
|
||
)
|
||
return result
|
||
|
||
|
||
def make_saved_handler(cfg: SessionEndpointConfig) -> Handler:
|
||
"""Lifted body for ``GET {prefix}/saved`` — list persisted workstreams.
|
||
|
||
Both kinds share the storage-backed listing sequence (auth →
|
||
``list_workstreams_with_history`` filtered by kind → optional
|
||
in-memory exclusion filter → row serialisation → respond).
|
||
Per-kind divergence:
|
||
|
||
- ``cfg.permission_gate`` — coord's ``admin.coordinator`` check.
|
||
- ``cfg.list_kind`` — required ``WorkstreamKind`` passed straight
|
||
through to ``list_workstreams_with_history(kind=...)``. The
|
||
handler treats a missing value as a configuration error and
|
||
surfaces 500 with a clear log line — fails loud rather than
|
||
silently filtering for the wrong kind. Distinct from
|
||
``audit_action_prefix`` (audit-action namespacing) so adding a
|
||
third kind doesn't have to overload the audit prefix as a
|
||
kind classifier.
|
||
- ``cfg.saved_state_filter`` — coord wires ``"closed"`` so only
|
||
explicitly-closed coordinators surface; interactive wires
|
||
``None`` (any state except the tombstoned ``deleted`` rows the
|
||
storage layer already filters).
|
||
- ``cfg.saved_loaded_lookup`` — coord-only defence-in-depth
|
||
filter that excludes ws_ids currently in the in-memory pool
|
||
(a row can be ``state='closed'`` briefly while the close-emit
|
||
sequence races the in-memory pop). Interactive ``None``.
|
||
|
||
Always-include row shape: ``{ws_id, alias, title, name,
|
||
created, updated, message_count}``. Identical between kinds
|
||
pre-lift; the lift just moves the row construction into one
|
||
place.
|
||
|
||
Behaviour changes vs the pre-lift handlers:
|
||
|
||
- **Top-level response key converges on ``"workstreams"``.**
|
||
Pre-lift coord returned ``{"coordinators": [...]}``; the
|
||
lifted body returns ``{"workstreams": [...]}``. Mirrors the
|
||
active-list convergence.
|
||
- **Interactive's storage call moves to ``asyncio.to_thread``.**
|
||
Pre-lift interactive ran ``list_workstreams_with_history``
|
||
inline — under heavy load the SQL (which includes a
|
||
correlated COUNT subquery) stalled every other async
|
||
handler. Coord already used ``to_thread`` (perf-2 from the
|
||
saved-coordinators review); convergence lifts interactive up.
|
||
|
||
Args:
|
||
cfg: per-kind policy bundle.
|
||
"""
|
||
|
||
async def saved_workstreams_handler(request: Request) -> Response:
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
|
||
if cfg.list_kind is None:
|
||
# Misconfig: a kind mounted the saved handler without
|
||
# wiring ``cfg.list_kind``. Fail loud instead of silently
|
||
# filtering for the wrong kind — pre-fix the lifted body
|
||
# defaulted to INTERACTIVE on any non-"coordinator"
|
||
# ``audit_action_prefix``, which would have leaked
|
||
# interactive rows on any future kind that forgot the
|
||
# cfg field.
|
||
log.error("ws.saved.misconfigured_no_list_kind")
|
||
return JSONResponse(
|
||
{"error": "saved handler misconfigured"},
|
||
status_code=500,
|
||
)
|
||
|
||
result = await _collect_saved_rows(cfg, request)
|
||
return JSONResponse({"workstreams": result})
|
||
|
||
return saved_workstreams_handler
|
||
|
||
|
||
def _saved_updated_sort_key(row: dict[str, Any]) -> tuple[int, str]:
|
||
"""Descending-``updated`` sort key for the merged saved list.
|
||
|
||
Used with ``sorted(..., reverse=True)``. Returns ``(1, str(updated))``
|
||
for rows carrying an ``updated`` value and ``(0, "")`` for rows
|
||
missing it — so under ``reverse=True`` real rows sort newest-first
|
||
and ``updated``-less rows fall to the tail (the presence flag differs
|
||
so the string element of a present row is never compared against the
|
||
absent placeholder). ``updated`` is coerced to ``str`` purely as a
|
||
crash-proof comparator: each storage backend yields a single
|
||
homogeneous timestamp type (ISO string on sqlite, ``datetime`` on
|
||
postgres) whose lexical order matches chronological order, and
|
||
``list_workstreams_with_history`` already returns each kind
|
||
``ORDER BY updated DESC``, so this only re-interleaves two
|
||
already-sorted same-type runs — never an int-vs-string compare.
|
||
"""
|
||
updated = row.get("updated")
|
||
if updated is None:
|
||
return (0, "")
|
||
return (1, str(updated))
|
||
|
||
|
||
def make_unified_saved_handler(
|
||
cfgs: list[SessionEndpointConfig],
|
||
permission_gate: PermissionGate | None = None,
|
||
) -> Handler:
|
||
"""Saved-list handler that spans multiple kinds in one response.
|
||
|
||
The console's L-shell dashboard wants ONE saved list covering both
|
||
coordinator and interactive sessions. Storage is shared across kinds,
|
||
so this fans :func:`_collect_saved_rows` over each ``cfg`` (preserving
|
||
every kind's own ``list_kind`` / ``saved_state_filter`` /
|
||
``saved_loaded_lookup`` semantics — coord keeps its ``state='closed'``
|
||
+ warm-pool exclusion, interactive keeps its all-non-deleted listing),
|
||
concatenates the rows, and re-sorts the union by ``updated`` descending
|
||
(``updated``-less rows last).
|
||
|
||
Permission: a SINGLE ``permission_gate`` runs once up front (the
|
||
operator gate the console already applies to its coordinator saved
|
||
list). Per-``cfg`` ``permission_gate`` values are deliberately NOT
|
||
consulted here — the union is operator-gated as a whole, and the
|
||
operator already has cluster-wide visibility into every kind, so the
|
||
merge exposes nothing the per-kind lists didn't.
|
||
|
||
Each ``cfg`` must wire ``list_kind`` (a missing value is a mount-time
|
||
misconfiguration); the handler 500s loud rather than silently
|
||
filtering for the wrong kind, matching :func:`make_saved_handler`.
|
||
|
||
Args:
|
||
cfgs: per-kind policy bundles to merge, in any order (the response
|
||
is re-sorted by ``updated`` regardless of cfg order).
|
||
permission_gate: single gate applied to the whole list. ``None``
|
||
relies on upstream auth middleware only.
|
||
"""
|
||
|
||
async def unified_saved_handler(request: Request) -> Response:
|
||
if permission_gate is not None:
|
||
err = permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
|
||
# Fail loud BEFORE any query (same contract as the single-kind
|
||
# handler): a cfg mounted into the union without a kind would
|
||
# otherwise filter for the wrong (or all) kinds.
|
||
for cfg in cfgs:
|
||
if cfg.list_kind is None:
|
||
log.error("ws.saved.unified.misconfigured_no_list_kind")
|
||
return JSONResponse(
|
||
{"error": "saved handler misconfigured"},
|
||
status_code=500,
|
||
)
|
||
|
||
# The per-kind collections are independent (shared store, no data
|
||
# dependency), so overlap their DB round-trips instead of summing them.
|
||
import asyncio
|
||
|
||
parts = await asyncio.gather(*(_collect_saved_rows(cfg, request) for cfg in cfgs))
|
||
merged: list[dict[str, Any]] = []
|
||
for part in parts:
|
||
merged.extend(part)
|
||
|
||
merged.sort(key=_saved_updated_sort_key, reverse=True)
|
||
return JSONResponse({"workstreams": merged})
|
||
|
||
return unified_saved_handler
|
||
|
||
|
||
def _resume_cursor_and_trim(
|
||
messages: list[dict[str, Any]],
|
||
ui: Any,
|
||
awaiting_approval: bool,
|
||
) -> tuple[list[dict[str, Any]], int | None]:
|
||
"""Decide the fresh-connect resume cursor and trim the in-flight turn.
|
||
|
||
Returns ``(messages_to_project, cursor)``.
|
||
|
||
When the trailing turn is an *executing* in-flight orphan — an
|
||
assistant ``tool_calls`` message whose results aren't all saved yet,
|
||
with the ws NOT awaiting approval — AND the live ring buffer can
|
||
replay the delta past the last resolved-turn boundary, this DROPS the
|
||
orphan turn from ``/history`` and returns ``cursor`` = the resolved
|
||
boundary's ``_event_id``. The client opens its initial SSE with that
|
||
cursor and the existing ``replay_ok`` path fast-forwards the orphan
|
||
turn whole (content tokens, ``tool_info``, ``tool_result``, …), so the
|
||
committed snapshot and the live delta are disjoint — no double-render,
|
||
no lost siblings (the cursor sits *below* all the orphan's events, so
|
||
out-of-order result saves can't move it).
|
||
|
||
Otherwise returns ``(messages, None)`` unchanged, so the connect takes
|
||
the synthetic-snapshot floor:
|
||
- awaiting approval → the ``_pending_approval`` re-emit paints it;
|
||
- reloaded / evicted (empty or truncated buffer) → the orphan keeps
|
||
its #610 history-rendered block (never left unrenderable);
|
||
- quiescent / cursorless history → plain fresh connect.
|
||
|
||
Pure + defensive — reads only ``role`` / ``tool_calls`` /
|
||
``tool_call_id`` / ``_event_id``. The ``_event_id`` side-channel
|
||
survives reconstruct → decorate → extract_reasoning; this runs on the
|
||
pre-projection list, and ``project_history_messages`` then surfaces it
|
||
as the top-level ``event_id`` for the frontend.
|
||
"""
|
||
if awaiting_approval or not messages:
|
||
return messages, None
|
||
can_replay = getattr(ui, "can_replay_from", None)
|
||
if not callable(can_replay):
|
||
return messages, None
|
||
resulted: set[str] = {
|
||
str(m.get("tool_call_id"))
|
||
for m in messages
|
||
if m.get("role") == "tool" and m.get("tool_call_id")
|
||
}
|
||
# Locate the trailing assistant tool-call turn (break at the first
|
||
# one from the end — mirrors project_history_messages' #610 gate) and
|
||
# whether it still has an unresolved tool_call (an in-flight orphan).
|
||
orphan_idx: int | None = None
|
||
for i in range(len(messages) - 1, -1, -1):
|
||
tcs = messages[i].get("tool_calls")
|
||
if tcs:
|
||
has_orphan = any(
|
||
(tc.get("id") or "") and str(tc.get("id")) not in resulted for tc in tcs
|
||
)
|
||
orphan_idx = i if has_orphan else None
|
||
break
|
||
if not orphan_idx: # None (no orphan) or 0 (no resolved boundary before it)
|
||
return messages, None
|
||
resolved_ids = [
|
||
m["_event_id"] for m in messages[:orphan_idx] if isinstance(m.get("_event_id"), int)
|
||
]
|
||
if not resolved_ids:
|
||
return messages, None # no committed cursor → snapshot floor
|
||
cursor = max(resolved_ids)
|
||
if not can_replay(cursor):
|
||
return messages, None # buffer can't fast-forward → #610 floor
|
||
return messages[:orphan_idx], cursor
|
||
|
||
|
||
def make_history_handler(cfg: SessionEndpointConfig) -> Handler:
|
||
"""Lifted body for ``GET {prefix}/{ws_id}/history`` — message history.
|
||
|
||
Returns the tail of the workstream's reconstructed conversation as
|
||
OpenAI-like message dicts. Used by coord's page-load handshake (the
|
||
dashboard fetches history once, then SSE handles updates). The lift
|
||
also adds the endpoint to interactive as a feature gain — pre-lift
|
||
interactive only exposed history through the SSE replay on
|
||
``/events``, so SDK consumers had to subscribe to a stream just to
|
||
read message rows.
|
||
|
||
Per-kind divergence captured by:
|
||
|
||
- ``cfg.permission_gate`` — coord's ``admin.coordinator`` check;
|
||
interactive ``None``.
|
||
- ``cfg.manager_lookup`` — already used by every other lifted verb.
|
||
- ``cfg.list_kind`` — required for the storage-fallback kind check
|
||
so an interactive ws_id can't read history through the coord
|
||
process and vice versa. Pre-lift coord went through
|
||
:func:`_resolve_coordinator_or_404` for the same isolation; the
|
||
lifted body uses ``cfg.list_kind`` (already wired by both
|
||
production lifespans for the list/saved factories) instead of
|
||
adding a new cfg field. **Required when this handler is mounted**
|
||
— a missing value fails loud (500 + ``log.error``) rather than
|
||
silently leaking cross-kind history through the storage
|
||
fallback. Mirrors :func:`make_saved_handler`'s same gate.
|
||
- ``cfg.not_found_label`` — per-kind 404 wording.
|
||
|
||
Pre-lift coord behaviour preserved with one performance lift:
|
||
both the storage-row kind check and the ``load_messages`` call now
|
||
run through ``asyncio.to_thread`` (matched to the rest of the
|
||
lifted verbs' storage offload pattern; pre-lift coord ran them
|
||
inline on the event loop).
|
||
|
||
Args:
|
||
cfg: per-kind policy bundle.
|
||
"""
|
||
|
||
async def history(request: Request) -> Response:
|
||
import asyncio
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
|
||
# Fail-closed misconfig gate. Without ``cfg.list_kind`` the
|
||
# storage-fallback path below has no way to enforce cross-kind
|
||
# isolation — an interactive ws_id requested through a coord
|
||
# process would silently serve coord history from storage (and
|
||
# vice versa). Mirrors :func:`make_saved_handler`'s same gate
|
||
# for the same reason; a future kind / hand-rolled test cfg
|
||
# that drops the field fails loud instead of leaking rows.
|
||
if cfg.list_kind is None:
|
||
log.error("ws.history.misconfigured_no_list_kind")
|
||
return JSONResponse(
|
||
{"error": "history handler misconfigured"},
|
||
status_code=500,
|
||
)
|
||
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
if not ws_id:
|
||
return JSONResponse({"error": "ws_id is required"}, status_code=400)
|
||
|
||
# Cross-tenant gate. Pre-PR-447 the response carried only
|
||
# message rows that an owning user wrote and that owning
|
||
# user's tools produced — sensitive but bounded to the same
|
||
# ``user_id`` as the workstream. Even so, every other lifted
|
||
# session verb (send / approve / close / cancel / events /
|
||
# attachments) calls ``cfg.tenant_check`` and history was the
|
||
# outlier. Coord wires ``tenant_check=None`` (the
|
||
# cluster-wide ``admin.coordinator`` permission_gate covers
|
||
# it); interactive wires ``_interactive_tenant_check`` and
|
||
# this call now restores parity with the rest of the surface.
|
||
if cfg.tenant_check is not None:
|
||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||
if err_tenant is not None:
|
||
return err_tenant
|
||
|
||
# Existence + kind check. The workstream may live only in
|
||
# storage (closed coordinators are still readable via /history
|
||
# without rehydrating; persisted-but-not-loaded interactives
|
||
# are likewise readable). Mirrors the pre-lift coord
|
||
# ``_resolve_coordinator_or_404`` ladder: in-memory mgr.get →
|
||
# storage row + kind check → 404. Falling back to storage
|
||
# without the kind check would leak interactive rows through
|
||
# the coord endpoint (and vice versa) on a process that
|
||
# shares storage with the other kind. ``cfg.list_kind`` is
|
||
# guaranteed non-None by the misconfig gate above.
|
||
storage = getattr(request.app.state, "auth_storage", None)
|
||
live_session = mgr.get(ws_id)
|
||
if live_session is None:
|
||
if storage is None:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
try:
|
||
row = await asyncio.to_thread(storage.get_workstream, ws_id)
|
||
except Exception:
|
||
log.debug("ws.history.lookup_failed ws=%s", ws_id[:8], exc_info=True)
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
if row is None or row.get("kind") != cfg.list_kind:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
|
||
# Bound the row count. Pre-lift coord clamped to [1, 500].
|
||
try:
|
||
limit = int(request.query_params.get("limit", "100"))
|
||
except (TypeError, ValueError):
|
||
limit = 100
|
||
limit = max(1, min(limit, 500))
|
||
|
||
messages: list[dict[str, Any]] = []
|
||
# Fresh-connect resume cursor (the ``Last-Event-ID`` the client
|
||
# opens its initial SSE with). Non-None only when the trailing
|
||
# turn is an executing in-flight orphan that the ring buffer can
|
||
# fast-forward — see :func:`_resume_cursor_and_trim`. Stays None
|
||
# on every other path (and on any decoration failure below) so
|
||
# the client takes the synthetic-snapshot floor.
|
||
cursor: int | None = None
|
||
if storage is not None:
|
||
try:
|
||
# repair=False — display read; include_compaction=True so a
|
||
# persisted compaction marker projects as an in-place
|
||
# source="compaction" system row and the UI re-renders its
|
||
# compaction card after a reload. See the
|
||
# reconstruct_messages docstring for both flags.
|
||
messages = await asyncio.to_thread(
|
||
storage.load_messages,
|
||
ws_id,
|
||
limit=limit,
|
||
repair=False,
|
||
include_compaction=True,
|
||
)
|
||
except Exception:
|
||
log.debug("ws.history.load_failed ws=%s", ws_id[:8], exc_info=True)
|
||
# Audit-trail decoration — attach persisted intent_verdict and
|
||
# output_assessment data to each assistant.tool_calls entry so
|
||
# the dashboard's history replay paints the same verdict pills
|
||
# / output-warning bubbles the live SSE path shows. Both
|
||
# storage queries are off-loop via ``to_thread``. Best-effort:
|
||
# any failure leaves messages undecorated — replay degrades to
|
||
# the pre-decoration shape rather than 500-ing.
|
||
if messages:
|
||
try:
|
||
from turnstone.core.history_decoration import (
|
||
decorate_history_messages,
|
||
extract_reasoning_for_history,
|
||
load_verdict_indexes,
|
||
project_history_messages,
|
||
)
|
||
|
||
indexes = await asyncio.to_thread(load_verdict_indexes, ws_id)
|
||
# Pure transform but iterates every message and every
|
||
# tool_call dict — for a long workstream the pass takes
|
||
# tens of milliseconds and would otherwise block the
|
||
# event loop's hot path on the request handler.
|
||
# ``decorate_history_messages`` is thread-safe (no
|
||
# shared mutable state beyond the per-call message
|
||
# list) so the off-loop hop is free.
|
||
await asyncio.to_thread(decorate_history_messages, messages, indexes[0], indexes[1])
|
||
# Active-model ``surface_persisted_reasoning`` flag. Three-tier
|
||
# resolution so the operator's flag-flip takes effect
|
||
# uniformly — live session, storage-rehydratable cold
|
||
# workstream, or unknown workstream:
|
||
#
|
||
# 1. Live session in memory → read from its registry
|
||
# (already-warm path).
|
||
# 2. Cold workstream → ``workstream_config.model_alias``
|
||
# persisted at first send (see
|
||
# ``session_manager.py:628`` rehydrate path) →
|
||
# resolve through the kind-appropriate registry on
|
||
# ``app.state``.
|
||
# 3. Neither available → conservative default ``True``,
|
||
# matching the migration server_default and the
|
||
# rehydration default in spec.
|
||
surface_persisted_reasoning = True
|
||
resolved_alias = ""
|
||
resolved_registry: Any = None
|
||
if live_session is not None:
|
||
resolved_registry = getattr(live_session, "_registry", None)
|
||
resolved_alias = getattr(live_session, "_model_alias", "") or ""
|
||
if not resolved_alias and storage is not None:
|
||
# Off-loop the sync storage call (mirrors get_workstream
|
||
# / load_messages / load_verdict_indexes / decorate /
|
||
# extract_reasoning_for_history above). Preserves the
|
||
# try/except so a DB failure degrades to the
|
||
# conservative-default branch instead of bubbling out.
|
||
try:
|
||
ws_cfg = (
|
||
await asyncio.to_thread(storage.load_workstream_config, ws_id) or {}
|
||
)
|
||
except Exception:
|
||
ws_cfg = {}
|
||
resolved_alias = ws_cfg.get("model_alias") or ""
|
||
if resolved_registry is None:
|
||
# Interactive server stores the registry as
|
||
# ``app.state.registry``; console stores its coord
|
||
# registry as ``app.state.coord_registry``. The
|
||
# lifted handler is shared, so we try both.
|
||
resolved_registry = getattr(request.app.state, "registry", None) or getattr(
|
||
request.app.state, "coord_registry", None
|
||
)
|
||
if resolved_registry is not None and resolved_alias:
|
||
try:
|
||
surface_persisted_reasoning = bool(
|
||
resolved_registry.get_config(resolved_alias).surface_persisted_reasoning
|
||
)
|
||
except Exception:
|
||
surface_persisted_reasoning = True
|
||
await asyncio.to_thread(
|
||
extract_reasoning_for_history, messages, surface_persisted_reasoning
|
||
)
|
||
# ``pending`` on the trailing tool-call turn must track the
|
||
# LIVE awaiting-approval signal, not orphan-detection — an
|
||
# executing or interrupted orphan is not awaiting approval
|
||
# and must render its tool block on a fresh connect (else it
|
||
# vanishes until a reconnect replays the buffered events).
|
||
# Read ``_pending_approval`` off the loaded session; a
|
||
# storage-only / closed ws has no live session → never
|
||
# pending. Stays in lockstep with the approve_request
|
||
# re-emit in the SSE replay (``_interactive_events_replay``).
|
||
# Asserted as ``dict`` (its only real production shape) to
|
||
# match the detail handler below, so a MagicMock-based unit
|
||
# test's auto-vivified attribute doesn't trip the path.
|
||
awaiting_approval = isinstance(
|
||
getattr(getattr(live_session, "ui", None), "_pending_approval", None),
|
||
dict,
|
||
)
|
||
# Fresh-connect fast-forward: when the trailing turn is an
|
||
# executing in-flight orphan the ring buffer can replay,
|
||
# drop it from the committed snapshot and hand back a
|
||
# resume cursor so the client's initial SSE rebuilds it via
|
||
# the existing delta replay (disjoint from /history — no
|
||
# double-render). No-op on every other path (returns the
|
||
# list unchanged + cursor=None). Runs on the pre-project
|
||
# list while the ``_event_id`` side-channel is still present.
|
||
to_project, cursor = _resume_cursor_and_trim(
|
||
messages, getattr(live_session, "ui", None), awaiting_approval
|
||
)
|
||
# Final structural projection: flatten nested tool_calls,
|
||
# collapse multipart content, surface the
|
||
# ``_source`` / ``_attachments_meta`` side-channels
|
||
# top-level, and derive
|
||
# ``denied`` / ``is_error`` / ``pending``. Runs last (reads
|
||
# decorate's in-place verdict/advisory mutations + the
|
||
# stamped ``reasoning``) and returns a fresh list, so the
|
||
# wire payload is the canonical render shape both the
|
||
# interactive ``replayHistory`` and the coordinator history
|
||
# rebuild consume directly — no client-side normaliser.
|
||
messages = await asyncio.to_thread(
|
||
project_history_messages, to_project, awaiting_approval
|
||
)
|
||
# Task-agent recall: attach each task_agent tool_call's stashed
|
||
# sub-trajectory (projected step items) so the client's
|
||
# ``replayHistory`` can rebuild the collapsible card. Live
|
||
# in-memory session only — a cold/closed ws, or an entry evicted
|
||
# past the LRU cap, has none, so the card renders the flat parent
|
||
# record ("not retained"), never a fabricated 0-step card.
|
||
# [[HYPOTHESIS]] an unobserved sub-trajectory is unknown, not none.
|
||
get_traj = getattr(getattr(live_session, "ui", None), "get_agent_trajectory", None)
|
||
if get_traj is not None:
|
||
for msg in messages:
|
||
for tc in msg.get("tool_calls") or ():
|
||
# Only task_agent calls ever stash — skip the rest so
|
||
# we don't take the agent-state lock once per tool_call
|
||
# on a long history for ids that can never match.
|
||
if tc.get("name") != "task_agent":
|
||
continue
|
||
steps = get_traj(tc.get("id") or "")
|
||
# Attach only a well-formed, non-empty list — the
|
||
# ``get_agent_trajectory`` contract is ``list | None``,
|
||
# and the guard keeps a malformed result out of the
|
||
# JSON payload (a non-list can't be serialized).
|
||
if isinstance(steps, list) and steps:
|
||
tc["agent_steps"] = steps
|
||
except Exception:
|
||
# Operationally interesting: a persistent decoration
|
||
# failure (missing migration, driver mismatch, schema
|
||
# drift) silently strips verdict pills + output
|
||
# warnings from every reload of every workstream.
|
||
# Log at warning so it surfaces in normal log review
|
||
# rather than only when DEBUG is on. Reset the cursor so a
|
||
# mid-pipeline failure can't pair an un-trimmed orphan with
|
||
# a fast-forward cursor (which would double-render it).
|
||
cursor = None
|
||
log.warning(
|
||
"ws.history.decoration_failed ws=%s",
|
||
ws_id[:8],
|
||
exc_info=True,
|
||
)
|
||
return JSONResponse({"ws_id": ws_id, "messages": messages, "cursor": cursor})
|
||
|
||
return history
|
||
|
||
|
||
def make_export_handler(cfg: SessionEndpointConfig) -> Handler:
|
||
"""Lifted body for ``GET {prefix}/{ws_id}/export`` — conversation download.
|
||
|
||
Serves the workstream's full conversation as an OpenAI-style
|
||
envelope (``{"messages": [...]}``) for download. Reuses the same
|
||
:class:`SessionEndpointConfig` (and therefore the same gate ladder)
|
||
as :func:`make_history_handler`, so ownership + cross-kind isolation
|
||
come for free.
|
||
|
||
The HTTP surface is **conversation-only**: it never bundles
|
||
children and always returns ``application/json``. The
|
||
children/zip capability of :func:`export_workstream` is reserved
|
||
for the admin CLI, so the handler calls it with the default
|
||
``children=False``.
|
||
|
||
Per-kind divergence captured by the same fields history consults:
|
||
|
||
- ``cfg.permission_gate`` — coord's ``admin.coordinator`` check;
|
||
interactive ``None``.
|
||
- ``cfg.manager_lookup`` — the kind's manager.
|
||
- ``cfg.list_kind`` — required for the storage-fallback kind check
|
||
so an interactive ws_id can't be exported through the coord
|
||
process and vice versa. **Required when this handler is
|
||
mounted** — a missing value fails loud (500 + ``log.error``)
|
||
rather than silently leaking cross-kind history.
|
||
- ``cfg.tenant_check`` — per-``ws_id`` access gate.
|
||
- ``cfg.not_found_label`` — per-kind 404 wording.
|
||
|
||
Args:
|
||
cfg: per-kind policy bundle.
|
||
"""
|
||
|
||
async def export(request: Request) -> Response:
|
||
import asyncio
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
|
||
# Fail-closed misconfig gate. Without ``cfg.list_kind`` the
|
||
# storage-fallback path below has no way to enforce cross-kind
|
||
# isolation — an interactive ws_id requested through a coord
|
||
# process would silently export coord history from storage (and
|
||
# vice versa). Mirrors :func:`make_history_handler`'s same gate.
|
||
if cfg.list_kind is None:
|
||
log.error("ws.export.misconfigured_no_list_kind")
|
||
return JSONResponse(
|
||
{"error": "export handler misconfigured"},
|
||
status_code=500,
|
||
)
|
||
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
if not ws_id:
|
||
return JSONResponse({"error": "ws_id is required"}, status_code=400)
|
||
|
||
# Cross-tenant gate — same posture as history (interactive wires
|
||
# ``_interactive_tenant_check``; coord wires ``None`` and relies
|
||
# on the ``admin.coordinator`` permission_gate above). Always
|
||
# offloaded via ``to_thread`` since the interactive resolver
|
||
# falls through to a synchronous storage read on a cache miss.
|
||
if cfg.tenant_check is not None:
|
||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||
if err_tenant is not None:
|
||
return err_tenant
|
||
|
||
# Existence + kind check. The workstream may live only in
|
||
# storage (closed coordinators / persisted-but-not-loaded
|
||
# interactives are still exportable without rehydrating).
|
||
# Mirrors history's ladder: in-memory mgr.get → storage row +
|
||
# kind check → 404. Falling back to storage without the kind
|
||
# check would leak interactive rows through the coord endpoint
|
||
# (and vice versa) on a process that shares storage with the
|
||
# other kind. ``cfg.list_kind`` is guaranteed non-None above.
|
||
storage = getattr(request.app.state, "auth_storage", None)
|
||
live_session = mgr.get(ws_id)
|
||
if live_session is None:
|
||
if storage is None:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
try:
|
||
row = await asyncio.to_thread(storage.get_workstream, ws_id)
|
||
except Exception:
|
||
log.debug("ws.export.lookup_failed ws=%s", ws_id[:8], exc_info=True)
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
if row is None or row.get("kind") != cfg.list_kind:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
|
||
# Past the gate but no storage handle — the live-session branch
|
||
# above skips the storage requirement, but the serializer needs
|
||
# a real handle. Degrade to the same 404 the fallback uses for a
|
||
# missing storage rather than serving an empty / 500 export.
|
||
if storage is None:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
|
||
from starlette.responses import Response as _Response
|
||
|
||
from turnstone.core.export import WorkstreamNotFoundError, export_workstream
|
||
from turnstone.core.web_helpers import latin1_safe_filename
|
||
|
||
# Conversation-only: never bundle children, always JSON. A live
|
||
# session whose storage row was deleted skips the fallback
|
||
# existence gate above, so guard the serializer's own not-found
|
||
# raise and degrade to the same 404 rather than surfacing a 500.
|
||
try:
|
||
result = await asyncio.to_thread(export_workstream, storage, ws_id)
|
||
except WorkstreamNotFoundError:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
# ws_ids are hex so the filename is already safe, but run the shared
|
||
# sanitizer anyway so a future non-hex id can't break the
|
||
# Content-Disposition (latin-1 fold + control-char strip).
|
||
safe_name = latin1_safe_filename(result.filename)
|
||
return _Response(
|
||
result.data,
|
||
media_type=result.content_type,
|
||
headers={
|
||
"Content-Disposition": f'attachment; filename="{safe_name}"',
|
||
"X-Content-Type-Options": "nosniff",
|
||
"Cache-Control": "private, no-store",
|
||
},
|
||
)
|
||
|
||
return export
|
||
|
||
|
||
def make_detail_handler(cfg: SessionEndpointConfig) -> Handler:
|
||
"""Lifted body for ``GET {prefix}/{ws_id}`` — workstream display fields.
|
||
|
||
Returns ``{ws_id, name, state, user_id, kind}`` for the workstream.
|
||
Lazy-rehydrates on miss via ``mgr.open(ws_id)`` so a closed/evicted
|
||
workstream comes back into memory before the response. Mirrors the
|
||
error-handling pattern from :func:`make_open_handler`: ``ValueError``
|
||
from the session factory surfaces as 503 with the factory's
|
||
remediation text; any other rehydrate failure surfaces as a
|
||
correlation-id'd 500 with the per-kind noun in the user-facing
|
||
message.
|
||
|
||
Cross-kind isolation is enforced inside ``mgr.open()`` itself —
|
||
it returns ``None`` for missing rows, kind mismatches, and
|
||
tombstoned rows; all surface as 404 with ``cfg.not_found_label``.
|
||
No inline storage check needed (unlike :func:`make_history_handler`)
|
||
because rehydrate is the existence proof.
|
||
|
||
Per-kind divergence:
|
||
|
||
- ``cfg.permission_gate`` — coord's ``admin.coordinator`` check;
|
||
interactive ``None``.
|
||
- ``cfg.manager_lookup`` — already used by every other lifted verb.
|
||
- ``cfg.not_found_label`` — per-kind 404 wording.
|
||
- ``cfg.audit_action_prefix`` — per-kind noun in the 500 error.
|
||
|
||
Pre-lift coord behaviour preserved verbatim. The lift adds the
|
||
endpoint to interactive as a feature gain — pre-lift interactive
|
||
had no HTTP detail endpoint (SDK consumers had to subscribe to
|
||
SSE just to read display fields).
|
||
|
||
Args:
|
||
cfg: per-kind policy bundle.
|
||
"""
|
||
|
||
async def detail(request: Request) -> Response:
|
||
import asyncio
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
if not ws_id:
|
||
return JSONResponse({"error": "ws_id is required"}, status_code=400)
|
||
|
||
# Cross-tenant gate. PR 447 added the inline approval payload
|
||
# (now ``pending_approval_details``) to the response (tool
|
||
# previews, function arguments, LLM judge reasoning) — a
|
||
# richer payload than the pre-PR
|
||
# ``{ws_id, name, state, user_id, kind}`` tuple. Coord wires
|
||
# ``tenant_check=None`` (the cluster-wide ``admin.coordinator``
|
||
# permission_gate covers it); interactive wires
|
||
# ``_interactive_tenant_check`` so any authenticated user that
|
||
# GETs another user's ``ws_id`` 404s here instead of reading
|
||
# the in-flight tool-call payload. Brings detail in line with
|
||
# every other lifted session verb.
|
||
if cfg.tenant_check is not None:
|
||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||
if err_tenant is not None:
|
||
return err_tenant
|
||
|
||
ws = mgr.get(ws_id)
|
||
if ws is None:
|
||
try:
|
||
ws = mgr.open(ws_id)
|
||
except ValueError as exc:
|
||
# Session factory misconfig (e.g. a model alias that no
|
||
# longer resolves). Surface remediation text as 503
|
||
# mirroring :func:`make_open_handler`.
|
||
log.warning("ws.detail.factory_misconfig ws_id=%s exc=%r", ws_id[:8], exc)
|
||
return JSONResponse(
|
||
{"error": _safe_factory_misconfig_message(exc)}, status_code=503
|
||
)
|
||
except Exception:
|
||
# Bare ``Exception`` is intentional — see
|
||
# :func:`make_open_handler` for the rationale
|
||
# (``adapter.build_session`` / ``ChatSession.resume``
|
||
# have no documented exception spec).
|
||
import secrets
|
||
|
||
correlation_id = secrets.token_hex(4)
|
||
log.warning(
|
||
"ws.detail.rehydrate_failed correlation_id=%s ws_id=%s",
|
||
correlation_id,
|
||
ws_id[:8] if ws_id else "",
|
||
exc_info=True,
|
||
)
|
||
kind_noun = cfg.audit_action_prefix or "workstream"
|
||
return JSONResponse(
|
||
{
|
||
"error": (
|
||
f"failed to rehydrate {kind_noun} (internal error). "
|
||
f"correlation_id={correlation_id}"
|
||
)
|
||
},
|
||
status_code=500,
|
||
)
|
||
if ws is None:
|
||
# ``mgr.open`` returns None for missing rows, kind
|
||
# mismatch, and tombstoned rows — all surface as 404.
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
|
||
# A detail GET that lazily rehydrates IS an open — run the
|
||
# same kind-specific post-load the open handler runs.
|
||
# Skipping it leaves the now-live session with no watch
|
||
# dispatch registration (its next watch fire would take the
|
||
# restore path and spawn a duplicate auto-approved session
|
||
# racing writes into this live conversation) and never tells
|
||
# dashboards the workstream came live (``ws_created``).
|
||
if cfg.open_post_load is not None:
|
||
try:
|
||
# Off-loop: interactive's post_load does blocking
|
||
# storage I/O (display-name lookup).
|
||
await asyncio.to_thread(cfg.open_post_load, request, ws)
|
||
except Exception:
|
||
# Post-load is observational — never let a hook bug
|
||
# block the detail response. Log + continue.
|
||
log.debug(
|
||
"ws.detail.post_load_failed ws=%s",
|
||
ws.id[:8],
|
||
exc_info=True,
|
||
)
|
||
|
||
# Pending-approval snapshot — lets a freshly-loaded chat tab
|
||
# paint the inline approval gate from this single response
|
||
# instead of waiting for the SSE approve_request replay (which
|
||
# introduces a brief --running flash on reload). Both keys
|
||
# (``pending_approval`` + ``pending_approval_details``) are
|
||
# always present in the response: a UI that doesn't expose
|
||
# ``serialize_pending_approval_details`` (CLI / channel
|
||
# adapters) reports ``False`` / ``[]`` for them. The
|
||
# ``_pending_approval`` lookup is asserted as ``dict`` (its
|
||
# only real production shape — the oldest-cycle view kept by
|
||
# ``SessionUIBase``) so a MagicMock-based unit test or other
|
||
# non-dict sentinel doesn't trip the path.
|
||
pending_approval = False
|
||
pending_approval_details: list[dict[str, Any]] = []
|
||
ui = ws.ui
|
||
pending_raw = getattr(ui, "_pending_approval", None) if ui is not None else None
|
||
if isinstance(pending_raw, dict):
|
||
pending_approval = True
|
||
# Full per-cycle list — parallel task agents can have
|
||
# several prompts live; the reload path paints them all.
|
||
serializer = getattr(ui, "serialize_pending_approval_details", None)
|
||
if callable(serializer):
|
||
try:
|
||
maybe_list = serializer()
|
||
if isinstance(maybe_list, list):
|
||
pending_approval_details = maybe_list
|
||
except Exception:
|
||
# Defensive: a malformed verdict object inside the
|
||
# serializer shouldn't fail the entire detail
|
||
# response. The boolean still informs the UI that
|
||
# an approval is pending; SSE replay carries the
|
||
# full payload.
|
||
log.warning(
|
||
"ws.detail.pending_serialize_failed ws_id=%s",
|
||
ws_id[:8] if ws_id else "",
|
||
exc_info=True,
|
||
)
|
||
|
||
return JSONResponse(
|
||
{
|
||
"ws_id": ws.id,
|
||
"name": ws.name,
|
||
"state": ws.state.value,
|
||
"user_id": ws.user_id,
|
||
"kind": ws.kind,
|
||
"pending_approval": pending_approval,
|
||
"pending_approval_details": pending_approval_details,
|
||
}
|
||
)
|
||
|
||
return detail
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Deferred sends (command windows / order barrier)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# The dataclass, the order-barrier predicate, and the saturation bound
|
||
# all live with the Workstream fields they annotate
|
||
# (turnstone.core.workstream, imported at module top): _PendingSend
|
||
# carries the "drain not alive ⇒ nothing claimed" invariant,
|
||
# Workstream.send_barrier_active() is the ONE definition of the two-term
|
||
# barrier every dispatch surface consults, and PENDING_SENDS_MAX is the
|
||
# shared backpressure bound ChatSession._QUEUE_MAX aliases — one
|
||
# constant, structurally incapable of diverging between the interjection
|
||
# queue and the deferred list.
|
||
|
||
|
||
def _make_drain_thread(ws: Workstream) -> threading.Thread:
|
||
"""Construct (never start) the pending-send drain thread for *ws*.
|
||
|
||
A module-level seam so tests can inject spawn failure without
|
||
touching the global ``threading`` module; ``_defer_send`` owns the
|
||
slot write, the ``start()`` call, and the rollback discipline.
|
||
"""
|
||
import threading # matches the file's handler-scope import style
|
||
|
||
return threading.Thread(
|
||
target=_drain_pending_sends,
|
||
args=(ws,),
|
||
name=f"pending-drain-{ws.id[:8]}",
|
||
daemon=True,
|
||
)
|
||
|
||
|
||
def _emit_send_ui(ws: Workstream, ui: Any, hook_name: str, *args: Any) -> None:
|
||
"""Best-effort UI hook dispatch for send-worker closures.
|
||
|
||
Each call is wrapped in try/except so a failure in one hook (e.g.
|
||
listener-queue full → on_error raises) doesn't suppress the others.
|
||
Mirrors the pre-P1.5 coord_adapter.send per-hook defense.
|
||
"""
|
||
if ui is None:
|
||
return
|
||
method = getattr(ui, hook_name, None)
|
||
if method is None:
|
||
return
|
||
try:
|
||
method(*args)
|
||
except Exception:
|
||
log.debug(
|
||
"ws.send.ui_hook_failed ws=%s hook=%s",
|
||
ws.id[:8] if ws.id else "",
|
||
hook_name,
|
||
exc_info=True,
|
||
)
|
||
|
||
|
||
def _make_dispatch_attempt(
|
||
ws: Workstream,
|
||
cfg: SessionEndpointConfig,
|
||
ui: Any,
|
||
*,
|
||
message: str,
|
||
resolved_atts: list[Any],
|
||
ordered_taken: list[str],
|
||
send_id: str,
|
||
acting_uid: str,
|
||
defer_fidelity: bool = False,
|
||
) -> Callable[[ChatSession], tuple[bool, dict[str, Any]]]:
|
||
"""Build one atomic queue-or-spawn attempt bound to ONE session capture.
|
||
|
||
The single dispatch implementation shared by the /send route's
|
||
immediate path and :func:`_drain_pending_sends` — session re-capture
|
||
across /resume//new identity swaps, the cross-user and attachment
|
||
queue guards, ``send_id`` threading (the queue path reuses it as
|
||
``queue_msg_id`` so the client's DELETE targets one id either way),
|
||
and the spawn-path metrics all live here, once. Callers re-capture
|
||
``ws.session`` before every attempt and pass it in: closures bound
|
||
to a pre-swap capture would send the user's message into the wrong
|
||
workstream's transcript.
|
||
|
||
``queue_outcome`` (second element of the return) is written only
|
||
when the dispatcher takes the live-worker reuse path; empty after a
|
||
fresh-spawn dispatch.
|
||
|
||
``defer_fidelity=True`` marks a deferred entry's attempt: it was
|
||
answered "queued" under the full-fidelity defer contract, so the
|
||
interjection fallback — which truncates at ``queue_message``'s cap
|
||
(``workstream.INTERJECTION_CAP_CHARS``) and cannot carry
|
||
attachments — is refused for
|
||
oversized or attachment-bearing entries (the drain waits for the
|
||
slot and retries into the fresh-spawn arm instead). The refusal
|
||
happens inside the enqueue callback, under the same ``ws._lock``
|
||
acquisition as the queue-vs-spawn decision, so a turn claiming the
|
||
slot between the drain's poll and this dispatch can never route the
|
||
entry into truncation.
|
||
"""
|
||
import threading
|
||
|
||
from turnstone.core import session_worker
|
||
from turnstone.core.session import (
|
||
AttachmentsNotQueueableError,
|
||
CrossUserInterjectionError,
|
||
GenerationCancelled,
|
||
)
|
||
|
||
def attempt(session: ChatSession) -> tuple[bool, dict[str, Any]]:
|
||
queue_outcome: dict[str, Any] = {}
|
||
|
||
def _enqueue() -> None:
|
||
# Runs under ``ws._lock`` (session_worker.send calls it inside
|
||
# the same acquisition that reads _worker_running), so this
|
||
# worker_kind read cannot race the spawn write. A command
|
||
# window must NEVER reach queue_message — its cap and
|
||
# cross-user guard are turn semantics — so report it and let
|
||
# the route defer (or the drain re-park).
|
||
if ws.worker_kind == "command":
|
||
queue_outcome["rejected"] = "command_window"
|
||
return
|
||
# Measures the RAW message while queue_message caps the
|
||
# post-!!!-strip CLEANED text — deliberate: parse_priority
|
||
# only strips, so raw >= cleaned and the raw measure can
|
||
# only ever OVER-refuse (a borderline fold-in costs one
|
||
# full-fidelity fresh spawn, never a truncation); measuring
|
||
# cleaned here would run parse_priority twice per dispatch
|
||
# for zero safety gain.
|
||
if defer_fidelity and (resolved_atts or len(message) > INTERJECTION_CAP_CHARS):
|
||
queue_outcome["rejected"] = "defer_full_fidelity"
|
||
return
|
||
try:
|
||
cleaned, priority, msg_id = session.queue_message(
|
||
message,
|
||
attachment_ids=list(ordered_taken),
|
||
queue_msg_id=send_id or None,
|
||
interjector_user_id=acting_uid,
|
||
)
|
||
except AttachmentsNotQueueableError:
|
||
queue_outcome["rejected"] = "attachments_busy"
|
||
return
|
||
except CrossUserInterjectionError:
|
||
# A different authenticated participant tried to interject
|
||
# into someone else's in-flight turn; folding it in would
|
||
# borrow the initiator's credentials and misattribute the
|
||
# message. Reject so they resend as a fresh turn once the
|
||
# worker idles (the drain instead waits and re-attempts).
|
||
queue_outcome["rejected"] = "cross_user_interjection"
|
||
return
|
||
queue_outcome["cleaned"] = cleaned
|
||
queue_outcome["priority"] = priority
|
||
queue_outcome["msg_id"] = msg_id
|
||
|
||
def _run() -> None:
|
||
me = threading.current_thread()
|
||
try:
|
||
kwargs: dict[str, Any] = {}
|
||
if resolved_atts:
|
||
kwargs["attachments"] = resolved_atts
|
||
if send_id:
|
||
kwargs["send_id"] = send_id
|
||
# Fresh turn: rebind per-user MCP credentials to the
|
||
# authenticated sender. Bound here (not via a send()
|
||
# kwarg) so per-kind session stubs with explicit send
|
||
# signatures keep working; getattr-guarded for the same
|
||
# reason. The queue path above never rebinds.
|
||
bind = getattr(session, "bind_acting_user", None)
|
||
if acting_uid and callable(bind):
|
||
bind(acting_uid)
|
||
session.send(message, **kwargs)
|
||
except GenerationCancelled:
|
||
# Safety net — send() normally handles this internally.
|
||
# If this thread was force-abandoned, ws.worker_thread
|
||
# was set to None — don't emit spurious events.
|
||
if ws.worker_thread is me:
|
||
_emit_send_ui(ws, ui, "on_stream_end")
|
||
_emit_send_ui(ws, ui, "on_state_change", "idle")
|
||
except Exception:
|
||
# Undrained staged uploads aren't locked (the buffer is a
|
||
# peek, not a reservation) — they expire on the buffer TTL
|
||
# — so the only cleanup owed here is the UI streaming
|
||
# hook: ``session.send()`` already fired ``on_error``
|
||
# (with sanitized text), persisted ``last_error``, and
|
||
# emitted ``state='error'`` via
|
||
# :meth:`ChatSession._record_fatal_error` before
|
||
# re-raising.
|
||
if ws.worker_thread is me:
|
||
_emit_send_ui(ws, ui, "on_stream_end")
|
||
|
||
ok = session_worker.send(
|
||
ws,
|
||
enqueue=_enqueue,
|
||
run=_run,
|
||
thread_name=f"send-worker-{ws.id[:8]}",
|
||
)
|
||
if ok and not queue_outcome and cfg.spawn_metrics is not None:
|
||
# Fresh spawn — the kind's per-turn metrics fire exactly once,
|
||
# from the shared attempt so the drain's dispatches count too.
|
||
try:
|
||
cfg.spawn_metrics(ui)
|
||
except Exception:
|
||
log.debug(
|
||
"ws.send.spawn_metrics_failed ws=%s",
|
||
ws.id[:8] if ws.id else "",
|
||
exc_info=True,
|
||
)
|
||
if ok and defer_fidelity and "rejected" not in queue_outcome and cfg.emit_message_queued:
|
||
# A deferred entry actually dispatched — the settle signal the
|
||
# panes' queued chips wait on (the busy→idle sweep skips
|
||
# deferred chips: "idle ⇒ drained" is untrue for them). Lives
|
||
# HERE, not in the drain, which is endpoint-agnostic by design:
|
||
# this arm has ``cfg``/``ui`` in scope and fires for BOTH
|
||
# dispatch shapes. ``folded`` marks the interjection fold-in
|
||
# (non-empty outcome): the message moved to the live turn's
|
||
# queue where DELETE still genuinely removes it, so the client
|
||
# clears only its deferred flag and lets the chip resume the
|
||
# normal interjection lifecycle — a flat promote there would
|
||
# strip the ✕ while retraction is still honored. Pane-tier
|
||
# like ``message_queued`` (not SDK-typed); best-effort via
|
||
# _emit_send_ui — an emission failure must not look like a
|
||
# dispatch failure (the drain would re-insert and DOUBLE-send).
|
||
event: dict[str, Any] = {"type": "message_dispatched", "msg_id": send_id}
|
||
if queue_outcome:
|
||
event["folded"] = True
|
||
_emit_send_ui(ws, ui, "_enqueue", event)
|
||
return ok, queue_outcome
|
||
|
||
return attempt
|
||
|
||
|
||
def _drain_pending_sends(ws: Workstream) -> None:
|
||
"""Dispatch a workstream's deferred sends once its command window closes.
|
||
|
||
Per-workstream single-flight (``ws._pending_drain``), started by the
|
||
/send route when it defers an entry and run on a small daemon thread
|
||
(the dispatch machinery is synchronous and thread-shaped like every
|
||
other worker here, and a thread's lifetime is independent of any
|
||
event loop's — the request loop owes this drain nothing once the
|
||
route has answered). It owns the waiting the parked POST used to do
|
||
— but server-side, so a client timeout or abort can no longer become
|
||
message loss. Entries dispatch in arrival order via their prebuilt
|
||
attempt closures, re-capturing ``ws.session`` per attempt (a /resume
|
||
or /new that swapped the session mid-window routes the message into
|
||
the post-swap session, exactly as the park did).
|
||
|
||
Terminal outcomes per entry: dispatched (fresh spawn — or, for a
|
||
queue-shaped entry, the interjection fallback into a live turn,
|
||
msg_id preserved so the client's DELETE still targets it),
|
||
retracted (dismissed before dispatch), or dropped because the
|
||
workstream closed. There is deliberately no give-up bound: an entry
|
||
acknowledged "queued" is never silently dropped while the workstream
|
||
lives — rejections wait for the slot to free and retry into the
|
||
fresh-spawn arm.
|
||
|
||
Claim discipline: an entry is popped under ``ws._lock`` immediately
|
||
before its dispatch attempt and re-inserted at head on ANY
|
||
non-dispatch outcome — rejection or a crash inside the attempt — so
|
||
the DELETE fall-through (which marks only in-list entries) can never
|
||
"remove" a message whose dispatch already left the station, and the
|
||
:class:`_PendingSend` invariant (drain not alive ⇒ nothing claimed)
|
||
holds on every exit path. A claimed entry answers ``not_found``
|
||
("already sent"), which its eventual dispatch makes true.
|
||
|
||
Clean-exit wake backstop: the drain's retirement is the moment the
|
||
/send order barrier clears, and a list that empties by RETRACTION
|
||
never runs a deferred turn — so no worker exit would ever re-run the
|
||
wake gate that yielded to us (see the pending-sends yield in
|
||
:func:`~turnstone.core.idle_nudge_watcher.wake_workstream_if_pending`).
|
||
Re-running the gate here, outside ``ws._lock`` (session_worker's
|
||
exit-backstop discipline), closes that strand; when entries DID
|
||
dispatch, it's a cheap no-op re-check after the last turn's own exit
|
||
backstop.
|
||
"""
|
||
# Function-local imports (file style): ``threading`` is NEEDED here —
|
||
# the module-top import is TYPE_CHECKING-only, and the except arm's
|
||
# identity guard below would otherwise NameError at runtime inside
|
||
# the last-resort handler (masking the original exception and leaving
|
||
# the slot permanently held — the exact wedge the handler prevents);
|
||
# mypy can't catch that because the type-only import satisfies it.
|
||
import threading
|
||
import time
|
||
|
||
from turnstone.core.idle_nudge_watcher import wake_workstream_if_pending
|
||
|
||
clean_exit = False
|
||
try:
|
||
while True:
|
||
with ws._lock:
|
||
pending = ws._pending_sends
|
||
while pending and pending[0].retracted:
|
||
pending.pop(0)
|
||
if ws._closed:
|
||
if pending:
|
||
log.warning(
|
||
"ws.send.pending_dropped_on_close ws=%s count=%d",
|
||
ws.id[:8],
|
||
len(pending),
|
||
)
|
||
pending.clear()
|
||
ws._pending_drain = None
|
||
return
|
||
if not pending:
|
||
ws._pending_drain = None
|
||
clean_exit = True
|
||
break # clean exit — wake backstop AFTER the try block
|
||
entry = pending[0]
|
||
if ws._worker_running and ws.worker_kind == "command":
|
||
# The park, relocated server-side: the poll cadence
|
||
# matches the old request-handler loop's.
|
||
time.sleep(0.25)
|
||
continue
|
||
session_now = ws.session
|
||
if session_now is None:
|
||
# Mid-swap / partial-construction gap; the loop-top close
|
||
# check terminates this if it's a close in progress.
|
||
time.sleep(0.25)
|
||
continue
|
||
claimed = False
|
||
try:
|
||
with ws._lock:
|
||
if not ws._pending_sends or ws._pending_sends[0] is not entry:
|
||
continue # list reshaped under us — re-evaluate
|
||
if entry.retracted:
|
||
ws._pending_sends.pop(0)
|
||
continue
|
||
ws._pending_sends.pop(0) # claim
|
||
claimed = True
|
||
ok, outcome = entry.attempt(session_now)
|
||
except Exception:
|
||
# An entry acknowledged "queued" must never be eaten by a
|
||
# crash (Thread.start under thread exhaustion, MemoryError
|
||
# in the dispatch path): restore the claim, back off, and
|
||
# retry — the docstring's no-give-up contract. ``claimed``
|
||
# gates the re-insert so a claim-section failure can't
|
||
# duplicate the head entry.
|
||
log.exception(
|
||
"ws.send.pending_dispatch_crashed ws=%s msg_id=%s — entry retained",
|
||
ws.id[:8] if ws.id else "",
|
||
entry.msg_id,
|
||
)
|
||
if claimed:
|
||
with ws._lock:
|
||
ws._pending_sends.insert(0, entry)
|
||
time.sleep(1.0)
|
||
continue
|
||
if not ok or outcome.get("rejected") in (
|
||
"command_window",
|
||
"defer_full_fidelity",
|
||
"attachments_busy",
|
||
"cross_user_interjection",
|
||
):
|
||
# Window re-claimed between the poll and the dispatch, a
|
||
# live turn holds the slot against a full-fidelity entry,
|
||
# the interjection queue is saturated
|
||
# (session_worker.send → False on queue.Full), or the ws
|
||
# closed (resolved at the loop top). Unclaim, pace, wait.
|
||
with ws._lock:
|
||
ws._pending_sends.insert(0, entry)
|
||
time.sleep(0.25)
|
||
if outcome.get("rejected") != "command_window":
|
||
# Every non-window rejection is stable for the CURRENT
|
||
# worker (cross-user / attachments / full-fidelity are
|
||
# per-turn structural; queue.Full clears only at the
|
||
# turn's drain seams and the entry is already acked, so
|
||
# turn-bounded delay is contract-legal) — wait on the
|
||
# cheap flags instead of re-running the full dispatch
|
||
# machinery against ``ws._lock`` at 4 Hz for the length
|
||
# of a turn. Lockless reads: DELETE only MARKS
|
||
# ``retracted`` (the loop-top purge under the lock is
|
||
# authoritative), a ``worker_kind`` flip to "command"
|
||
# exits into the window arm above, and force-cancel's
|
||
# flag-clear releases this exactly as it released the
|
||
# old park. One dispatch attempt per slot-state change.
|
||
while (
|
||
ws._worker_running
|
||
and ws.worker_kind != "command"
|
||
and not entry.retracted
|
||
and not ws._closed
|
||
):
|
||
time.sleep(0.25)
|
||
continue
|
||
# Dispatched: fresh spawn (empty outcome) or interjection
|
||
# fallback (msg_id preserved) — this entry is done. The
|
||
# settle event (message_dispatched) fired inside the attempt.
|
||
except Exception:
|
||
# Never die holding the single-flight slot — a wedged drain would
|
||
# strand every future deferred send for this workstream. With the
|
||
# per-iteration handler above, reaching here means the loop
|
||
# machinery itself failed; entries stay on the list and the
|
||
# route's barrier arm re-ensures a drain on the next /send
|
||
# (deliberately NO successor spawn here: Thread.start fails under
|
||
# the same exhaustion that gets you here, and the route staying
|
||
# the single spawn site is what makes single-flight structural).
|
||
log.exception("ws.send.pending_drain_failed ws=%s", ws.id[:8] if ws.id else "")
|
||
with ws._lock:
|
||
# Identity-guarded, like every sibling exit seam: on paths
|
||
# that already RELEASED the slot before raising, an
|
||
# unconditional clear here would null a SUCCESSOR drain's
|
||
# live registration (two drains servicing one list — FIFO
|
||
# inversion, and the barrier reads inactive while the
|
||
# survivor holds a claimed entry). The guard makes any
|
||
# future post-release statement inside the try safe by
|
||
# construction.
|
||
if ws._pending_drain is threading.current_thread():
|
||
ws._pending_drain = None
|
||
return
|
||
# Clean-exit wake backstop — AFTER the try/except, deliberately: this
|
||
# runs once the drain has already retired its slot, so a raise out of
|
||
# the wake (session_worker.send re-raises Thread.start failures) must
|
||
# not reach the last-resort handler above and mutate state this
|
||
# thread no longer owns. Own guard, mirroring _retry_pending_wake's
|
||
# discipline around the same gate. Not run on the closed arm (the
|
||
# workstream is torn down) nor after the except (entries remain, the
|
||
# barrier still holds — the gate would just yield).
|
||
if clean_exit:
|
||
try:
|
||
wake_workstream_if_pending(ws, trigger="drain-exit")
|
||
except Exception:
|
||
log.warning(
|
||
"ws.send.drain_exit_wake_failed ws=%s", ws.id[:8] if ws.id else "", exc_info=True
|
||
)
|
||
|
||
|
||
def make_send_handler(cfg: SessionEndpointConfig) -> Handler:
|
||
"""Lifted body for ``POST {prefix}/{ws_id}/send`` — message dispatch.
|
||
|
||
Resolves any attachment ids the request carries from the per-node upload
|
||
buffer (a peek — the bytes stay buffered for a retry if the queue rejects
|
||
the turn), captures a ``send_id`` tracking token, then dispatches via
|
||
:func:`turnstone.core.session_worker.send` (atomic spawn-or-enqueue under
|
||
``ws._lock``). The committing ``send`` drains the buffer and writes the
|
||
bytes content-addressed; no reservation is taken or released.
|
||
|
||
Capability flags on ``cfg`` toggle the kind-specific behaviour:
|
||
|
||
- ``supports_attachments``: when ``False``, the entire
|
||
attachment-resolution block (buffer peek + scope-check)
|
||
short-circuits and any ``attachment_ids`` in the body are
|
||
silently ignored — no resolution, no error. Both kinds wire
|
||
``True`` post-P1.5; the flag exists so a kind that hasn't
|
||
lit up its UI surface yet can defer.
|
||
- ``spawn_metrics``: when set, fires once on the spawn path with
|
||
``(ui)``. Interactive wires its WebUI per-conversation counters
|
||
here; coord wires its per-UI-counter analog.
|
||
- ``emit_message_queued``: when ``True``, the queue-reuse path
|
||
pushes a ``message_queued`` event onto the listener queue.
|
||
|
||
Response shape (both kinds, P1.5 onwards). Every successful
|
||
response carries ``attached_ids`` and ``dropped_attachment_ids``
|
||
(empty lists when no attachments are involved), so SDK
|
||
consumers don't have to branch on whether the request had
|
||
attachments:
|
||
|
||
- 200 ``{"status": "ok", "attached_ids", "dropped_attachment_ids"}``
|
||
— fresh worker spawned. ``attached_ids`` is the subset of
|
||
requested attachments that landed (may be a strict subset on
|
||
reservation race losses).
|
||
- 200 ``{"status": "queued", "priority", "msg_id", "attached_ids",
|
||
"dropped_attachment_ids"}`` — reused live worker (queued for
|
||
injection at the next tool-result seam), OR — with ``"deferred":
|
||
true`` — parked on ``ws._pending_sends`` (a slash-command window
|
||
holds the slot, or earlier deferred sends hold the order barrier)
|
||
and dispatched full-fidelity by :func:`_drain_pending_sends` when
|
||
the slot frees — see :class:`_PendingSend` for the durability
|
||
contract. ``DELETE {prefix}/{ws_id}/send`` with the ``msg_id``
|
||
retracts either kind before dispatch; a deferred dispatch also
|
||
emits the pane-tier ``message_dispatched`` settle event (see
|
||
:func:`_make_dispatch_attempt`).
|
||
- 200 ``{"status": "queue_full", "attached_ids",
|
||
"dropped_attachment_ids"}`` — the send was refused with
|
||
retry-shortly semantics: the live worker's interjection queue is
|
||
at capacity, the deferred-send list hit its saturation bound
|
||
(``PENDING_SENDS_MAX`` — the shared backpressure bound), or the
|
||
drain thread could not be started under resource exhaustion (the
|
||
entry is rolled back, never phantom-parked). Reservations
|
||
released; caller should retry. The ``attached_ids`` list is
|
||
always empty here (the dispatch didn't take ownership of any
|
||
reservations).
|
||
- 4xx / 500 — auth / not-found / no-session per the usual
|
||
:class:`SessionEndpointConfig` semantics.
|
||
"""
|
||
import asyncio
|
||
import uuid
|
||
|
||
from turnstone.core.tool_advisory import parse_priority
|
||
from turnstone.core.web_helpers import auth_user_id, read_json_or_400
|
||
|
||
async def send(request: Request) -> Response:
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
|
||
body = await read_json_or_400(request)
|
||
if isinstance(body, JSONResponse):
|
||
return body
|
||
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
message = (body.get("message") or "").strip()
|
||
if not message:
|
||
return JSONResponse({"error": "message is required"}, status_code=400)
|
||
|
||
if cfg.tenant_check is not None:
|
||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||
if err_tenant is not None:
|
||
return err_tenant
|
||
|
||
ws = mgr.get(ws_id)
|
||
if ws is None:
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
ui = ws.ui
|
||
if ui is None:
|
||
return JSONResponse({"error": "session UI not available"}, status_code=409)
|
||
|
||
# The authenticated sender: threaded into the fresh-turn dispatch
|
||
# below so per-user MCP credentials follow whoever is actually
|
||
# driving a shared workstream. Deliberately NOT applied on the
|
||
# live-worker queue path — an interjection folds into the current
|
||
# turn under the initiator's identity (no mid-turn credential
|
||
# switch); the next fresh turn rebinds.
|
||
acting_uid = auth_user_id(request)
|
||
|
||
# ----- Attachment resolution (from the per-node upload buffer) -----
|
||
send_id = ""
|
||
requested_ids: list[str] = []
|
||
ordered_taken: list[str] = []
|
||
taken_set: set[str] = set()
|
||
resolved_atts: list[Any] = []
|
||
attach_user_id = ""
|
||
|
||
if cfg.supports_attachments:
|
||
from turnstone.core.attachment_buffer import get_attachment_buffer
|
||
from turnstone.core.attachments import resolve_staged_attachments
|
||
|
||
if cfg.attachment_owner_resolver is None:
|
||
# Mis-wired config — the resolver is mandatory when
|
||
# attachments are enabled. Fail loudly rather than
|
||
# silently filing under the wrong owner.
|
||
return JSONResponse({"error": "attachment_owner_resolver missing"}, status_code=500)
|
||
attach_user_id, owner_err = cfg.attachment_owner_resolver(request, ws_id, mgr)
|
||
if owner_err is not None:
|
||
return owner_err
|
||
|
||
send_id = uuid.uuid4().hex
|
||
raw_ids = body.get("attachment_ids")
|
||
if raw_ids is None:
|
||
# Auto-consume: every pending (staged) upload for this caller,
|
||
# in stage order.
|
||
buffer = get_attachment_buffer()
|
||
requested_ids = [
|
||
s.attachment_id for s in buffer.list_for(ws_id=ws_id, user_id=attach_user_id)
|
||
]
|
||
elif isinstance(raw_ids, list) and raw_ids:
|
||
requested_ids = [str(x) for x in raw_ids if x]
|
||
|
||
# Peek (not drain): the bytes stay buffered so an attachment-bearing
|
||
# turn the queue rejects can still be retried; the committing
|
||
# ``send`` drains them at write time. ``resolved`` carries the
|
||
# bytes the session persists content-addressed.
|
||
resolved_atts, ordered_taken, _dropped_resolve = resolve_staged_attachments(
|
||
requested_ids, ws_id, attach_user_id
|
||
)
|
||
taken_set = set(ordered_taken)
|
||
|
||
# If a cancel was just issued, briefly poll for the worker to
|
||
# exit before dispatching — avoids spawning into a stale
|
||
# worker. ``_worker_running`` flips False under ws._lock when
|
||
# the thread reaches its finally block (same gate the
|
||
# dispatcher uses). Async sleep keeps the event loop free.
|
||
if ws._worker_running and ws.session and ws.session._cancel_event.is_set():
|
||
for _ in range(30): # up to 3s in 100ms steps
|
||
await asyncio.sleep(0.1)
|
||
if not ws._worker_running:
|
||
break
|
||
|
||
# Defer-and-drain. While a slash-command worker holds the slot (a
|
||
# manual /compact can hold it for MINUTES), a send must not take
|
||
# the interjection-queue path — its INTERJECTION_CAP_CHARS cap and
|
||
# cross-user guard are mid-TURN semantics, and a queued message
|
||
# would cross a
|
||
# /resume//new identity swap into the wrong workstream. Instead
|
||
# of parking THIS request until the window closes (which encoded
|
||
# "client disconnected" as "message retracted" — deterministic
|
||
# message loss for every bounded caller: the coordinator client
|
||
# and console proxy time out at 30s, and the web composers' long
|
||
# abort bound raced the compaction card), the send is answered
|
||
# "queued" immediately and registered on ``ws._pending_sends``;
|
||
# the per-workstream drain thread dispatches it full-fidelity when
|
||
# the window closes. Dismissal is the same DELETE /send
|
||
# {msg_id} the interjection queue uses — server-confirmed, no
|
||
# POST-abort side channel.
|
||
#
|
||
# Two triggers share ``_defer_send`` below: the command-window
|
||
# rejection (the attempt's enqueue closure reports it), and the
|
||
# ORDER BARRIER — once entries are pending (or a claimed entry's
|
||
# dispatch is in flight: the drain-alive term, backed by the
|
||
# _PendingSend invariant), the pending list is the order
|
||
# authority, and a fresh send lines up behind it instead of
|
||
# overtaking messages already acknowledged "queued". The barrier
|
||
# check and the append happen under ONE ``ws._lock`` acquisition —
|
||
# ws._lock is not reentrant and session_worker.send takes it, so
|
||
# the lock is always released before any dispatch attempt; the
|
||
# command-window trigger re-acquires for its append, which is safe
|
||
# because that rejection was reported under the lock the attempt
|
||
# itself held.
|
||
def _queue_full_response() -> Response:
|
||
# Shared refusal shape (see the not-ok arm below for the
|
||
# rationale): retry-shortly semantics, no ownership taken.
|
||
return JSONResponse(
|
||
{
|
||
"status": "queue_full",
|
||
"attached_ids": [],
|
||
"dropped_attachment_ids": list(requested_ids),
|
||
}
|
||
)
|
||
|
||
def _defer_send(*, require_barrier: bool) -> Response | None:
|
||
with ws._lock:
|
||
# Probe FIRST, before constructing anything: in the
|
||
# overwhelmingly common no-barrier case this costs two
|
||
# field reads instead of a discarded closure tree +
|
||
# parse_priority per ordinary send.
|
||
if require_barrier and not ws.send_barrier_active():
|
||
return None # no barrier — caller dispatches directly
|
||
if ws._closed:
|
||
# Mirror the dispatch-refusal 404 below: a "queued"
|
||
# answer for a workstream whose next resolution 404s
|
||
# would promise a dispatch that can never happen.
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
if len(ws._pending_sends) >= PENDING_SENDS_MAX:
|
||
# Saturation backpressure — the SHARED bound
|
||
# (workstream.PENDING_SENDS_MAX, which the
|
||
# interjection queue's _QUEUE_MAX aliases): without
|
||
# a bound, each acked entry pins its message text
|
||
# plus materialized attachment bytes for a whole
|
||
# command window and then costs one unattended turn
|
||
# — an automated caller could OOM the node with 200s.
|
||
# len() deliberately counts retract-marked husks
|
||
# awaiting the drain's loop-top purge (DELETE only
|
||
# marks): a live-only count would let park/retract
|
||
# churn re-open the unbounded-growth hole. Transient
|
||
# over-refusal self-heals at the next purge.
|
||
return _queue_full_response()
|
||
# ``send_id`` is minted only when attachments are enabled
|
||
# — the deferred entry needs a truthy id regardless: it
|
||
# is the client's dismiss/bind handle and the drain's
|
||
# queue_msg_id/send_id thread. Constructed INSIDE the
|
||
# lock: closure creation is microsecond-cheap (the same
|
||
# argument session_worker.send makes for Thread()), and
|
||
# it keeps probe→append atomic.
|
||
pending_msg_id = send_id or uuid.uuid4().hex
|
||
cleaned_display, pending_priority = parse_priority(message)
|
||
entry = _PendingSend(
|
||
msg_id=pending_msg_id,
|
||
attempt=_make_dispatch_attempt(
|
||
ws,
|
||
cfg,
|
||
ui,
|
||
message=message,
|
||
resolved_atts=resolved_atts,
|
||
ordered_taken=ordered_taken,
|
||
send_id=pending_msg_id,
|
||
acting_uid=acting_uid,
|
||
defer_fidelity=True,
|
||
),
|
||
)
|
||
ws._pending_sends.append(entry)
|
||
drain = ws._pending_drain
|
||
if drain is None or not drain.is_alive():
|
||
# Single drain-spawn site — also the recovery path
|
||
# for a drain that died in its last-resort handler.
|
||
# ``t.start()`` stays INSIDE this lock acquisition,
|
||
# deliberately unlike session_worker.send's
|
||
# outside-lock start (d3028234): that site's readers
|
||
# gate on the ``_worker_running`` flag, which is
|
||
# valid before start — this slot's only liveness
|
||
# signal is ``Thread.is_alive()``, which reads False
|
||
# for a constructed-but-unstarted thread, so an
|
||
# outside-lock start would let a concurrent defer's
|
||
# eligibility check see the pending drain as dead
|
||
# and spawn a SECOND dispatcher (FIFO inversion; the
|
||
# drain's exit slot-clears are single-flight-only).
|
||
# Holding the lock through start makes that state
|
||
# unobservable and keeps single-flight structural;
|
||
# the cost is thread-spawn latency (~100µs) on a
|
||
# cold path.
|
||
t = _make_drain_thread(ws)
|
||
ws._pending_drain = t
|
||
try:
|
||
t.start()
|
||
except Exception:
|
||
# Thread creation failed (exhaustion,
|
||
# MemoryError). Roll back BOTH writes — the
|
||
# lock was held throughout, so the entry is
|
||
# provably the tail and the slot is provably
|
||
# ``t`` — and refuse with queue_full: the SDK's
|
||
# existing retry-shortly vocabulary. Never a
|
||
# 500 after registration (a phantom entry the
|
||
# client can't retract that dispatches later as
|
||
# a duplicate), and never a queued ack (it would
|
||
# promise a dispatch whose only revival trigger
|
||
# is a FUTURE send). Entries acked by earlier
|
||
# successful defers stay parked under the
|
||
# next-send-re-ensures policy — no respawn
|
||
# attempt here under the same exhaustion that
|
||
# just failed.
|
||
ws._pending_sends.pop()
|
||
ws._pending_drain = None
|
||
log.exception(
|
||
"ws.send.pending_drain_spawn_failed ws=%s — send refused",
|
||
ws.id[:8],
|
||
)
|
||
return _queue_full_response()
|
||
# Best-effort ack event — a raising UI hook must not convert
|
||
# an ACCEPTED deferred send into a 500 (the client would
|
||
# retry and deliver twice); same never-mask-acceptance rule
|
||
# as message_dispatched.
|
||
if cfg.emit_message_queued:
|
||
_emit_send_ui(
|
||
ws,
|
||
ui,
|
||
"_enqueue",
|
||
{
|
||
"type": "message_queued",
|
||
"message": cleaned_display,
|
||
"priority": pending_priority,
|
||
"msg_id": pending_msg_id,
|
||
},
|
||
)
|
||
return JSONResponse(
|
||
{
|
||
"status": "queued",
|
||
# Parked on ws._pending_sends, NOT in a live turn's
|
||
# interjection queue: the panes keep the chip's ✕ past
|
||
# the busy→idle edge until message_dispatched settles
|
||
# it. See SendResponse for the SDK-facing contract.
|
||
"deferred": True,
|
||
"priority": pending_priority,
|
||
"msg_id": pending_msg_id,
|
||
"attached_ids": list(ordered_taken),
|
||
"dropped_attachment_ids": [
|
||
aid for aid in requested_ids if aid not in taken_set
|
||
],
|
||
}
|
||
)
|
||
|
||
barrier_resp = _defer_send(require_barrier=True)
|
||
if barrier_resp is not None:
|
||
return barrier_resp
|
||
|
||
attempt = _make_dispatch_attempt(
|
||
ws,
|
||
cfg,
|
||
ui,
|
||
message=message,
|
||
resolved_atts=resolved_atts,
|
||
ordered_taken=ordered_taken,
|
||
send_id=send_id,
|
||
acting_uid=acting_uid,
|
||
)
|
||
session_now = ws.session
|
||
if session_now is None:
|
||
return JSONResponse({"error": "No session"}, status_code=500)
|
||
ok, queue_outcome = attempt(session_now)
|
||
if queue_outcome.get("rejected") == "command_window":
|
||
window_resp = _defer_send(require_barrier=False)
|
||
if window_resp is None: # unreachable: only the barrier probe returns None
|
||
return JSONResponse({"error": "defer failed"}, status_code=500)
|
||
return window_resp
|
||
if not ok:
|
||
if ws._closed:
|
||
# ``send`` refused because the workstream closed between our
|
||
# resolution and the dispatch — a ``queue_full`` here would
|
||
# tell the client to retry a workstream whose very next
|
||
# resolution 404s. Mirror the resolution miss instead.
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
# queue.Full or session-disappeared race — surface as
|
||
# queue_full so clients retry rather than 500. ``attached_ids``
|
||
# is always empty on this path (the dispatch never took
|
||
# ownership); the empty arrays preserve the response-shape
|
||
# guarantee so SDK consumers don't branch on status.
|
||
return _queue_full_response()
|
||
|
||
if queue_outcome.get("rejected") == "attachments_busy":
|
||
# Attachments can't ride a queued user turn (see
|
||
# AttachmentsNotQueueableError for the role-ordering reason).
|
||
# The staged uploads stay in the buffer (peek, not drain) so the
|
||
# client can hold the file and retry once the worker idles.
|
||
return JSONResponse(
|
||
{
|
||
"status": "attachments_busy",
|
||
"attached_ids": [],
|
||
"dropped_attachment_ids": list(requested_ids),
|
||
}
|
||
)
|
||
|
||
if queue_outcome.get("rejected") == "cross_user_interjection":
|
||
# A different participant tried to interject into someone else's
|
||
# in-flight turn (see CrossUserInterjectionError). 409 Conflict so
|
||
# the client can surface "wait for the current turn" and resend as
|
||
# a fresh turn under their own identity.
|
||
return JSONResponse(
|
||
{
|
||
"status": "cross_user_interjection",
|
||
"error": (
|
||
"Another participant's turn is in progress. Wait for it "
|
||
"to finish, then send your message."
|
||
),
|
||
"attached_ids": [],
|
||
"dropped_attachment_ids": list(requested_ids),
|
||
},
|
||
status_code=409,
|
||
)
|
||
|
||
dropped = [aid for aid in requested_ids if aid not in taken_set]
|
||
if queue_outcome:
|
||
# Reused a live worker; ``queue_message`` succeeded. Best-
|
||
# effort like the defer arm's ack: the message is already
|
||
# accepted, so a raising UI hook must not 500 this into a
|
||
# client retry (duplicate delivery).
|
||
if cfg.emit_message_queued:
|
||
_emit_send_ui(
|
||
ws,
|
||
ui,
|
||
"_enqueue",
|
||
{
|
||
"type": "message_queued",
|
||
"message": queue_outcome["cleaned"],
|
||
"priority": queue_outcome["priority"],
|
||
"msg_id": queue_outcome["msg_id"],
|
||
},
|
||
)
|
||
return JSONResponse(
|
||
{
|
||
"status": "queued",
|
||
"priority": queue_outcome["priority"],
|
||
"msg_id": queue_outcome["msg_id"],
|
||
"attached_ids": list(ordered_taken),
|
||
"dropped_attachment_ids": dropped,
|
||
}
|
||
)
|
||
|
||
# Spawned a fresh worker — the kind's per-turn metrics fired inside
|
||
# the shared attempt (see _make_dispatch_attempt), where the drain
|
||
# task's dispatches fire them too.
|
||
return JSONResponse(
|
||
{
|
||
"status": "ok",
|
||
"attached_ids": list(ordered_taken),
|
||
"dropped_attachment_ids": dropped,
|
||
}
|
||
)
|
||
|
||
return send
|
||
|
||
|
||
def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers:
|
||
"""Lifted bodies for the four per-workstream attachment endpoints.
|
||
|
||
Both kinds share the storage layer
|
||
(:mod:`turnstone.core.memory` calls are kind-agnostic) and the
|
||
same per-(``ws_id``, ``user_id``) scope semantics. Differences
|
||
factor into ``cfg.permission_gate`` (auth) and
|
||
``cfg.attachment_owner_resolver`` (scope + 404 mask).
|
||
|
||
``cfg.supports_attachments`` is checked at registration time —
|
||
callers should only invoke this factory when it's ``True``. The
|
||
factory still returns four working handlers if you call it
|
||
otherwise; they'll just no-op-with-500 when
|
||
``attachment_owner_resolver`` is unset.
|
||
"""
|
||
|
||
async def _gate(request: Request) -> JSONResponse | None:
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
return None
|
||
|
||
async def _resolve_owner(request: Request, ws_id: str) -> tuple[str, JSONResponse | None]:
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return "", err503
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
if cfg.attachment_owner_resolver is None:
|
||
return "", JSONResponse({"error": "attachment_owner_resolver missing"}, status_code=500)
|
||
return cfg.attachment_owner_resolver(request, ws_id, mgr)
|
||
|
||
async def upload(request: Request) -> Response:
|
||
from turnstone.core.attachment_buffer import get_attachment_buffer
|
||
from turnstone.core.attachments import PDF_SIZE_CAP
|
||
from turnstone.core.web_helpers import read_multipart_file_or_400
|
||
|
||
# The file-classification policy (sniff order, per-kind caps, allowlists)
|
||
# lives in one place — core.attachments.classify_upload — handed in via
|
||
# the upload-helper hook so the console surface can wire it without
|
||
# depending on the node-side server module.
|
||
if cfg.attachment_helpers is None:
|
||
return JSONResponse({"error": "attachment_helpers missing"}, status_code=500)
|
||
classify = cfg.attachment_helpers.classify_upload
|
||
|
||
err_gate = await _gate(request)
|
||
if err_gate is not None:
|
||
return err_gate
|
||
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
if not ws_id:
|
||
return JSONResponse({"error": "ws_id is required"}, status_code=400)
|
||
|
||
user_id, err = await _resolve_owner(request, ws_id)
|
||
if err:
|
||
return err
|
||
|
||
got = await read_multipart_file_or_400(request, field="file", max_bytes=PDF_SIZE_CAP)
|
||
if isinstance(got, JSONResponse):
|
||
return got
|
||
filename, claimed_mime, data = got
|
||
if not data:
|
||
return JSONResponse({"error": "Empty file"}, status_code=400)
|
||
|
||
kind, mime, rejection = classify(filename, claimed_mime, data)
|
||
if rejection is not None:
|
||
return JSONResponse(
|
||
{"error": rejection.message, "code": rejection.code},
|
||
status_code=rejection.status,
|
||
)
|
||
assert kind is not None and mime is not None # success ⟹ both set
|
||
|
||
# Stage in the per-node upload buffer (content-addressed: the id is the
|
||
# content hash, so re-uploading identical bytes is idempotent). The
|
||
# bytes are written to storage only at send-commit. No DB write, no
|
||
# per-user cap — the buffer's size/TTL ceilings bound a flood.
|
||
staged = get_attachment_buffer().stage(
|
||
ws_id=ws_id,
|
||
user_id=user_id,
|
||
filename=filename,
|
||
mime_type=mime,
|
||
kind=kind,
|
||
content=data,
|
||
)
|
||
return JSONResponse(
|
||
{
|
||
"attachment_id": staged.attachment_id,
|
||
"filename": staged.filename,
|
||
"mime_type": staged.mime_type,
|
||
"size_bytes": staged.size_bytes,
|
||
"kind": staged.kind,
|
||
}
|
||
)
|
||
|
||
async def list_pending(request: Request) -> Response:
|
||
from turnstone.core.attachment_buffer import get_attachment_buffer
|
||
|
||
err_gate = await _gate(request)
|
||
if err_gate is not None:
|
||
return err_gate
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
if not ws_id:
|
||
return JSONResponse({"error": "ws_id is required"}, status_code=400)
|
||
user_id, err = await _resolve_owner(request, ws_id)
|
||
if err:
|
||
return err
|
||
# Pending uploads live in the buffer; project to the same wire shape
|
||
# the DB-backed listing used (no content bytes).
|
||
rows = [
|
||
{
|
||
"attachment_id": s.attachment_id,
|
||
"filename": s.filename,
|
||
"mime_type": s.mime_type,
|
||
"size_bytes": s.size_bytes,
|
||
"kind": s.kind,
|
||
}
|
||
for s in get_attachment_buffer().list_for(ws_id=ws_id, user_id=user_id)
|
||
]
|
||
return JSONResponse({"attachments": rows})
|
||
|
||
async def _resolve_served_blob(
|
||
request: Request,
|
||
) -> tuple[bytes, str, str, str] | Response:
|
||
"""Gate + resolve an attachment blob for serving (content or thumbnail).
|
||
|
||
Returns ``(body, kind, mime, filename)`` or an error ``Response``.
|
||
Pending (staged) blobs serve from the buffer scoped to the uploader;
|
||
committed blobs serve from the store gated by ownership — the requester
|
||
(already gated to own ``ws_id``) must have a turn whose ref-list names the
|
||
id. Cross-user / cross-ws / unreferenced → 404 so existence doesn't leak.
|
||
"""
|
||
import asyncio
|
||
|
||
from turnstone.core.attachment_buffer import get_attachment_buffer
|
||
from turnstone.core.memory import attachment_referenced_in_ws, get_attachment
|
||
|
||
err_gate = await _gate(request)
|
||
if err_gate is not None:
|
||
return err_gate
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
attachment_id = request.path_params.get("attachment_id", "")
|
||
if not ws_id or not attachment_id:
|
||
return JSONResponse({"error": "ws_id and attachment_id are required"}, status_code=400)
|
||
user_id, err = await _resolve_owner(request, ws_id)
|
||
if err:
|
||
return err
|
||
staged = get_attachment_buffer().get(attachment_id, ws_id=ws_id, user_id=user_id)
|
||
if staged is not None:
|
||
return (
|
||
staged.content,
|
||
staged.kind,
|
||
staged.mime_type or "application/octet-stream",
|
||
staged.filename or "attachment",
|
||
)
|
||
# Committed-blob gates are sync DB I/O — the ref check is an unbounded
|
||
# ws-scoped LIKE scan (O(turns-in-ws)), so keep it off the event loop.
|
||
row = await asyncio.to_thread(get_attachment, attachment_id)
|
||
if not row or not await asyncio.to_thread(
|
||
attachment_referenced_in_ws, attachment_id, ws_id
|
||
):
|
||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||
return (
|
||
row.get("content") or b"",
|
||
row.get("kind") or "",
|
||
row.get("mime_type") or "application/octet-stream",
|
||
str(row.get("filename") or "attachment"),
|
||
)
|
||
|
||
async def get_content(request: Request) -> Response:
|
||
from starlette.responses import Response as _Response
|
||
|
||
from turnstone.core.web_helpers import latin1_safe_filename
|
||
|
||
resolved = await _resolve_served_blob(request)
|
||
if not isinstance(resolved, tuple):
|
||
return resolved
|
||
body, kind, stored_mime, filename = resolved
|
||
# Force text/plain for text kinds — avoids same-origin HTML/SVG
|
||
# rendering if a user uploaded an HTML-ish text file. Images keep their
|
||
# sniffed MIME (allowlist is strict: png/jpeg/gif/webp).
|
||
response_mime = "text/plain; charset=utf-8" if kind == "text" else stored_mime
|
||
# Uploaded filenames routinely carry CJK / em dashes (non-latin-1) and
|
||
# can carry control bytes — either would 500 the serving route, so run
|
||
# the shared header sanitizer rather than emit them verbatim.
|
||
safe_name = latin1_safe_filename(filename)
|
||
headers = {
|
||
"X-Content-Type-Options": "nosniff",
|
||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||
"Content-Disposition": f'inline; filename="{safe_name}"',
|
||
"Cache-Control": "private, no-store",
|
||
}
|
||
return _Response(body, media_type=response_mime, headers=headers)
|
||
|
||
async def get_preview(request: Request) -> Response:
|
||
from starlette.responses import Response as _Response
|
||
|
||
from turnstone.core.preview import PREVIEW_SERVE_MIMES, preview_response_headers
|
||
|
||
resolved = await _resolve_served_blob(request)
|
||
if not isinstance(resolved, tuple):
|
||
return resolved
|
||
body, _kind, stored_mime, filename = resolved
|
||
# Serve the STORED type so the browser renders it (html document, pdf
|
||
# viewer, image) — the opposite posture from ``get_content``'s
|
||
# force-text/plain, made safe by the per-mime CSP sandbox headers
|
||
# (``preview_response_headers``) plus the pane's iframe sandbox.
|
||
# Non-renderable types 415 rather than fall back to octet-stream: this
|
||
# route exists to render, ``/content`` exists to download.
|
||
bare_mime = stored_mime.split(";", 1)[0].strip().lower()
|
||
if bare_mime not in PREVIEW_SERVE_MIMES:
|
||
return JSONResponse({"error": "attachment is not previewable"}, status_code=415)
|
||
# ``?assets=1`` opts a previewed page back into loading its remote
|
||
# images / styles; default-off keeps the sandboxed document off the
|
||
# network (see ``preview_response_headers``).
|
||
allow_remote_assets = bool(request.query_params.get("assets"))
|
||
headers = preview_response_headers(
|
||
bare_mime, filename, allow_remote_assets=allow_remote_assets
|
||
)
|
||
# ``?probe=1`` preflight: the pane asks "will the real load paint?"
|
||
# before pointing an iframe / img at this URL. Answer with the exact
|
||
# hardening headers the real response would carry but no body — the
|
||
# console reverse proxy forwards a HEAD as a full GET, so a HEAD
|
||
# preflight would drag the whole blob across the node→console hop just
|
||
# to discard it. The ownership gate and the renderable-type check
|
||
# above have already run, so a 204 here means the GET will succeed.
|
||
if request.query_params.get("probe"):
|
||
return _Response(status_code=204, headers=headers)
|
||
return _Response(body, media_type=stored_mime, headers=headers)
|
||
|
||
async def get_thumbnail(request: Request) -> Response:
|
||
import asyncio
|
||
|
||
from starlette.responses import Response as _Response
|
||
|
||
from turnstone.core.thumbnails import make_thumbnail
|
||
|
||
resolved = await _resolve_served_blob(request)
|
||
if not isinstance(resolved, tuple):
|
||
return resolved
|
||
body, kind, _mime, _filename = resolved
|
||
if kind not in ("image", "pdf"):
|
||
return JSONResponse({"error": "no thumbnail for this attachment kind"}, status_code=415)
|
||
png = await asyncio.to_thread(make_thumbnail, body, kind)
|
||
if png is None:
|
||
return JSONResponse({"error": "thumbnail unavailable"}, status_code=415)
|
||
return _Response(
|
||
png,
|
||
media_type="image/png",
|
||
headers={
|
||
"X-Content-Type-Options": "nosniff",
|
||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||
"Cache-Control": "private, max-age=300",
|
||
},
|
||
)
|
||
|
||
async def delete_(request: Request) -> Response:
|
||
from turnstone.core.attachment_buffer import get_attachment_buffer
|
||
|
||
err_gate = await _gate(request)
|
||
if err_gate is not None:
|
||
return err_gate
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
attachment_id = request.path_params.get("attachment_id", "")
|
||
if not ws_id or not attachment_id:
|
||
return JSONResponse({"error": "ws_id and attachment_id are required"}, status_code=400)
|
||
user_id, err = await _resolve_owner(request, ws_id)
|
||
if err:
|
||
return err
|
||
# Only pending (staged) uploads are deletable — a committed blob is
|
||
# owned by the turn that references it and is GC'd by refcount.
|
||
deleted = get_attachment_buffer().discard(attachment_id, ws_id=ws_id, user_id=user_id)
|
||
if not deleted:
|
||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||
return JSONResponse({"status": "deleted"})
|
||
|
||
return AttachmentHandlers(
|
||
upload=upload,
|
||
list=list_pending,
|
||
get_content=get_content,
|
||
thumbnail=get_thumbnail,
|
||
preview=get_preview,
|
||
delete=delete_,
|
||
)
|
||
|
||
|
||
def make_dequeue_handler(cfg: SessionEndpointConfig) -> Handler:
|
||
"""Lifted body for ``DELETE {prefix}/{ws_id}/send`` — cancel a queued message.
|
||
|
||
Removes a previously-queued message identified by ``msg_id`` —
|
||
first from the session's interjection queue, then (fall-through)
|
||
from the workstream's deferred-send list (``ws._pending_sends``,
|
||
sends answered "queued" during a command window). Returns
|
||
``status: removed`` when either held the entry and
|
||
``status: not_found`` otherwise. Interjection-queued messages
|
||
don't carry attachments (see :class:`AttachmentsNotQueueableError`)
|
||
and a deferred entry's resolved attachments die with it (the staged
|
||
bytes were peeked, not reserved — they expire on the buffer TTL),
|
||
so there's no reservation side-effect to undo here; the client
|
||
surfaces the discarded-attachments consequence to the user.
|
||
"""
|
||
from turnstone.core.web_helpers import read_json_or_400
|
||
|
||
async def dequeue(request: Request) -> Response:
|
||
import asyncio
|
||
|
||
if cfg.permission_gate is not None:
|
||
err = cfg.permission_gate(request)
|
||
if err is not None:
|
||
return err
|
||
mgr_opt, err503 = cfg.manager_lookup(request)
|
||
if err503 is not None:
|
||
return err503
|
||
mgr = cast("SessionManager", mgr_opt)
|
||
|
||
body = await read_json_or_400(request)
|
||
if isinstance(body, JSONResponse):
|
||
return body
|
||
msg_id = body.get("msg_id")
|
||
if not msg_id:
|
||
return JSONResponse({"error": "msg_id required"}, status_code=400)
|
||
|
||
ws_id = request.path_params.get("ws_id", "")
|
||
if cfg.tenant_check is not None:
|
||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||
if err_tenant is not None:
|
||
return err_tenant
|
||
|
||
ws = mgr.get(ws_id)
|
||
if ws is None or ws.ui is None:
|
||
# ``ws.ui is None`` mirrors the pre-P1.5 ``_get_ws`` check —
|
||
# a workstream observed during a partial-construction or
|
||
# close window can have no UI; dequeue would otherwise
|
||
# answer for a session whose listener queues are gone.
|
||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||
if ws.session is None:
|
||
return JSONResponse({"error": "No session"}, status_code=400)
|
||
removed = ws.session.dequeue_message(msg_id)
|
||
if not removed:
|
||
# Fall through to the deferred-send list: a send answered
|
||
# "queued" during a command window lives on the workstream
|
||
# (see _PendingSend), not in the session's interjection
|
||
# queue. Marked under the same lock the drain claims under,
|
||
# so a retracted entry can never dispatch; an entry the
|
||
# drain already claimed is gone from the list and correctly
|
||
# answers not_found ("already sent" — its dispatch is in
|
||
# flight).
|
||
with ws._lock:
|
||
for entry in ws._pending_sends:
|
||
if entry.msg_id == msg_id and not entry.retracted:
|
||
entry.retracted = True
|
||
removed = True
|
||
break
|
||
return JSONResponse({"status": "removed" if removed else "not_found"})
|
||
|
||
return dequeue
|