Files
turnstone/turnstone/server.py
Patrick Buckley f583fb06db fix(projects): address PR review feedback — drop redundant asyncio import, precise failure-mode docs, format
The redundant function-local asyncio import in project_resources_endpoint
shadowed the module-level one. resolve_workstream_owner's docstring now
maps the failure modes precisely: a failed ROW lookup is fail-soft 404
(get_workstream_row degrades to None, pre-existing behaviour), while the
fail-closed 403 applies once a row is resolved and the project gate's
storage lookup fails — in-memory workstreams 403 on a gate blip,
not-loaded ones 404 at the row fetch first. Plus ruff-format on the
visibility test file (edited via script, so the local format hook never
saw it).
2026-07-01 18:01:50 -07:00

5199 lines
213 KiB
Python

"""Web server frontend for turnstone.
Provides a browser-based chat UI that mirrors the terminal CLI experience.
Uses Starlette (ASGI) with uvicorn for the server, communicating with the
browser via Server-Sent Events (SSE) for streaming and HTTP POST for user
actions.
Supports multiple concurrent workstreams (tabs), each with independent
ChatSession and event streams.
"""
from __future__ import annotations
import argparse
import asyncio
import collections
import contextlib
import functools
import hashlib
import json
import os
import queue
import random
import re
import sys
import textwrap
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterable
from sse_starlette import EventSourceResponse
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from turnstone import __version__
from turnstone.api.docs import make_docs_handler, make_openapi_handler
from turnstone.api.server_spec import build_server_spec
from turnstone.core.adapters.interactive_adapter import InteractiveAdapter
from turnstone.core.auth import (
AUTH_COOKIE_SERVER,
DENY_EMPTY_SUB,
JWT_AUD_SERVER,
AuthMiddleware,
_DenyFilter,
jwt_version_slot,
)
from turnstone.core.log import get_logger
from turnstone.core.metrics import metrics as _metrics
from turnstone.core.ratelimit import resolve_client_ip
from turnstone.core.session import ChatSession, GenerationCancelled, SessionUI # noqa: F401
from turnstone.core.session_manager import SessionManager
from turnstone.core.session_replay import session_replay_preamble
from turnstone.core.session_routes import (
AttachmentUploadHelpers,
SessionEndpointConfig,
SharedSessionVerbHandlers,
make_approve_handler,
make_attachment_handlers,
make_cancel_handler,
make_close_handler,
make_create_handler,
make_dequeue_handler,
make_detail_handler,
make_events_handler,
make_export_handler,
make_history_handler,
make_list_handler,
make_open_handler,
make_refresh_title_handler,
make_retry_handler,
make_rewind_handler,
make_saved_handler,
make_send_handler,
make_set_title_handler,
register_session_routes,
)
from turnstone.core.session_ui_base import (
AutoApproveReason,
SessionUIBase,
fire_judge_verdict_metric,
)
from turnstone.core.tools import TOOLS # noqa: F401 — available for introspection
from turnstone.core.trajectory import turn_to_dict
from turnstone.core.web_helpers import version_html as _version_html
from turnstone.core.workstream import (
Workstream,
WorkstreamKind,
WorkstreamState,
)
from turnstone.prompts import ClientType
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, MutableMapping
from starlette.types import ASGIApp, Receive, Scope, Send
# ---------------------------------------------------------------------------
# Static assets — loaded once at startup from turnstone/ui/static/
# ---------------------------------------------------------------------------
log = get_logger(__name__)
_STATIC_DIR = Path(__file__).parent / "ui" / "static"
_SHARED_DIR = Path(__file__).parent / "shared_static"
_HTML = _version_html((_STATIC_DIR / "index.html").read_text(encoding="utf-8"))
_HTML_ETAG = '"' + hashlib.md5(_HTML.encode()).hexdigest()[:16] + '"' # noqa: S324
_VALID_WS_ID = re.compile(r"^[0-9a-f]{32}$")
# ---------------------------------------------------------------------------
# WebUI — implements SessionUI for browser-based interaction
# ---------------------------------------------------------------------------
class WebUI(SessionUIBase):
"""Browser-based UI using SSE for streaming and HTTP POST for actions.
Implements the SessionUI protocol from turnstone.core.session.
Each workstream gets its own WebUI instance.
"""
# Shared global event queue for state-change broadcasts across all
# workstreams. Set by main() before any WebUI instances are created.
_global_queue: queue.Queue[dict[str, Any]] | None = None # bounded in main()
_workstream_mgr: SessionManager | None = None
def __init__(
self,
ws_id: str = "",
user_id: str = "",
*,
kind: WorkstreamKind = WorkstreamKind.INTERACTIVE,
parent_ws_id: str | None = None,
) -> None:
super().__init__(ws_id=ws_id, user_id=user_id)
# Cached for broadcast event payloads — both are immutable for
# the lifetime of the workstream, so locking the manager on
# every state/activity tick to re-read them burns lock budget.
self._kind = kind
# Normalize empty string to None at the UI boundary so the
# invariant "parent_ws_id is either a non-empty string or None"
# holds in every ws_state/ws_activity event payload — mirrors
# the storage-layer normalization at register_workstream.
self._parent_ws_id = parent_ws_id if parent_ws_id else None
# ``_enqueue`` / ``_register_listener`` / ``_unregister_listener``
# inherited from :class:`SessionUIBase`. ``_ws_turn_content`` /
# ``_ws_turn_content_size`` accumulator fields lifted to
# :class:`SessionUIBase` so coord can populate them too.
def _ws_kind_and_parent(self) -> tuple[WorkstreamKind, str | None]:
"""Return cached (kind, parent_ws_id) for broadcast event payloads.
Stored on the UI at construction time — both fields are
immutable for the lifetime of the workstream, so re-reading
them from the manager under lock on every broadcast was a
process-wide serialization tax on every activity tick.
"""
return self._kind, self._parent_ws_id
def _broadcast_state(self, state: str) -> None:
"""Send a state-change event to the global SSE channel.
Reads the rich-payload snapshot via the lifted
:meth:`SessionUIBase.snapshot_and_consume_state_payload`
helper, then puts the assembled ``ws_state`` event on the
global queue. The snapshot helper handles the IDLE/ERROR
``_ws_turn_content`` consume + clear under ``_ws_lock``.
"""
if WebUI._global_queue is not None:
payload = self.snapshot_and_consume_state_payload(state)
kind, parent_ws_id = self._ws_kind_and_parent()
event: dict[str, Any] = {
"type": "ws_state",
"ws_id": self.ws_id,
"state": state,
"tokens": payload["tokens"],
"context_ratio": payload["context_ratio"],
"activity": payload["activity"],
"activity_state": payload["activity_state"],
"kind": kind,
"parent_ws_id": parent_ws_id,
}
if state == "idle":
event["content"] = payload["content"]
# ``pending_approval_detail`` is NO LONGER piggybacked on
# state-change events (Stage 3 cleanup). Symmetric event
# flow now: initial approval items arrive via bulk fetch
# triggered by the ``activity_state="approval"`` transition,
# individual verdicts via the explicit
# ``intent_verdict`` event class, and resolution via
# ``approval_resolved``. Reducer no longer has to dedupe
# the piggyback path against the explicit one.
try:
WebUI._global_queue.put_nowait(event)
except queue.Full:
log.debug("Global SSE queue full, dropping %s event", event.get("type"))
def _broadcast_activity(self) -> None:
"""Send an activity-change event to the global SSE channel."""
if WebUI._global_queue is not None:
with self._ws_lock:
activity = self._ws_current_activity
activity_state = self._ws_activity_state
kind, parent_ws_id = self._ws_kind_and_parent()
with contextlib.suppress(queue.Full):
WebUI._global_queue.put_nowait(
{
"type": "ws_activity",
"ws_id": self.ws_id,
"activity": activity,
"activity_state": activity_state,
"kind": kind,
"parent_ws_id": parent_ws_id,
}
)
def _broadcast_intent_verdict(self, verdict: dict[str, Any]) -> None:
"""Send an LLM intent-judge verdict to the global SSE channel.
Stage 3 Step 5 — the cluster collector's ``_apply_delta``
forwards this verbatim to the cluster bus, where coord
adapters dispatch it as ``child_ws_intent_verdict`` for the
owning parent's tree UI. Unlike the existing
``pending_approval_detail`` piggyback on ``ws_state``, this
fires WHENEVER a verdict lands — including the common case
where the judge daemon writes during ``attention`` with no
state transition to ride along on.
"""
if WebUI._global_queue is not None:
with contextlib.suppress(queue.Full):
WebUI._global_queue.put_nowait(
{
"type": "intent_verdict",
"ws_id": self.ws_id,
"verdict": verdict,
}
)
def _broadcast_approval_resolved(
self,
approved: bool,
feedback: str | None = None,
*,
always: bool = False,
) -> None:
"""Send an ``approval_resolved`` decision to the global SSE channel.
Clears the parent's pending-approval pill in lockstep with
the actual decision rather than waiting for the next
state-change piggyback.
"""
if WebUI._global_queue is not None:
with contextlib.suppress(queue.Full):
WebUI._global_queue.put_nowait(
{
"type": "approval_resolved",
"ws_id": self.ws_id,
"approved": approved,
"feedback": feedback or "",
"always": bool(always),
}
)
def _broadcast_approve_request(self, detail: dict[str, Any]) -> None:
"""Send an ``approve_request`` payload to the global SSE channel.
Push path for the initial approval items so a coord parent's
tree UI can render the inline approve/deny block immediately
without waiting for a bulk-fetch round-trip. The bulk fetch
races with ``_pending_approval`` being set inside
``approve_tools`` (the state transition to ATTENTION fires
upstream first); the push path eliminates that race entirely.
"""
if WebUI._global_queue is not None:
with contextlib.suppress(queue.Full):
WebUI._global_queue.put_nowait(
{
"type": "approve_request",
"ws_id": self.ws_id,
"detail": detail,
}
)
# --- SessionUI protocol ---
#
# ``on_thinking_start`` / ``on_thinking_stop`` / ``on_reasoning_token``
# / ``on_content_token`` / ``on_stream_end`` / ``on_tool_output_chunk``
# / ``on_info`` / ``on_error`` are inherited from
# :class:`SessionUIBase`. ``on_status`` and ``on_tool_result`` are
# overridden below to layer Prometheus ``_metrics.record_*`` calls
# (node-only) on top of the shared per-ws metric writes.
# ``approve_tools`` is inherited from :class:`SessionUIBase`. The
# node-level prometheus metric for heuristic verdicts is layered via
# the ``_record_judge_metric`` hook below so the lifted body stays
# transport-agnostic.
def _record_judge_metric(self, verdict: dict[str, Any]) -> None:
"""Layer the per-node Prometheus metric on top of the shared body.
``SessionUIBase.approve_tools`` calls this for each persisted
heuristic verdict; ``ConsoleCoordinatorUI`` overrides the same
hook to feed the console's ``ConsoleMetrics`` — same metric
name, so a cluster-wide PromQL query rolls coord and
interactive verdicts up uniformly. The LLM-tier counterpart
lives in ``on_intent_verdict`` below — same metric, different
tier label, same ``record_judge_verdict`` call.
"""
fire_judge_verdict_metric(_metrics, verdict, "heuristic")
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None:
"""Layer node-only Prometheus metrics on top of the shared body."""
_metrics.record_tool_call(name)
super().on_tool_result(call_id, name, output, is_error=is_error)
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None:
"""Layer node-only Prometheus metrics on top of the shared body.
``_metrics.record_*`` calls feed the node's prometheus
endpoint; the per-ws counter writes, the ``status`` event
enqueue, and the ``usage_event`` storage row are inherited
from :meth:`SessionUIBase.on_status`. ``usage`` field access
is defensive for parity with the lifted body.
"""
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
total_tok = prompt_tokens + completion_tokens
cache_creation = usage.get("cache_creation_tokens", 0)
cache_read = usage.get("cache_read_tokens", 0)
_metrics.record_tokens(prompt_tokens, completion_tokens)
_metrics.record_cache_tokens(cache_creation, cache_read)
_metrics.record_context_ratio(total_tok / context_window if context_window > 0 else 0.0)
super().on_status(usage, context_window, effort)
def on_aux_usage(self, usage: dict[str, Any]) -> None:
"""Feed node Prometheus token counters for auxiliary LLM calls.
Mirrors :meth:`on_status`' token-metric writes (minus
context-ratio — an auxiliary prompt is not the main context
window) so ``turnstone_tokens_total`` reflects sub-agent,
compaction, and utility spend, not just main-loop turns. The
``usage_event`` storage row is inherited from
:meth:`SessionUIBase.on_aux_usage`.
"""
_metrics.record_tokens(usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0))
_metrics.record_cache_tokens(
usage.get("cache_creation_tokens", 0), usage.get("cache_read_tokens", 0)
)
super().on_aux_usage(usage)
def on_error(self, message: str) -> None:
"""Layer node-only Prometheus error counter on top of the shared body."""
_metrics.record_error()
super().on_error(message)
def on_state_change(self, state: str) -> None:
# Update the Workstream object so dashboard/polling sees the new state
if WebUI._workstream_mgr is not None:
try:
ws_state = WorkstreamState(state)
except ValueError:
log.debug("Ignoring unknown state %r for ws %s", state, self.ws_id)
else:
WebUI._workstream_mgr.set_state(self.ws_id, ws_state)
self._broadcast_state(state)
# Also send to per-workstream listeners so the browser UI can track
# busy/idle transitions (stream_end fires per-segment, not per-turn).
self._enqueue({"type": "state_change", "state": state})
def on_rename(self, name: str) -> None:
"""Update the workstream's display name and broadcast to all clients."""
if WebUI._global_queue is not None:
with contextlib.suppress(queue.Full):
WebUI._global_queue.put_nowait(
{"type": "ws_rename", "ws_id": self.ws_id, "name": name}
)
def on_intent_verdict(self, verdict: dict[str, Any]) -> None:
"""Extend :meth:`SessionUIBase.on_intent_verdict` with a
node-level prometheus metric update.
"""
super().on_intent_verdict(verdict)
fire_judge_verdict_metric(_metrics, verdict, "llm")
# ``on_output_warning`` inherited from :class:`SessionUIBase`.
# ``resolve_approval`` inherited from :class:`SessionUIBase`.
# Intent-verdict decision propagation lives in the base now — both
# interactive and coord share the same bookkeeping.
# ---------------------------------------------------------------------------
# Pure ASGI middleware (NOT BaseHTTPMiddleware — that breaks SSE streaming)
# ---------------------------------------------------------------------------
class RateLimitMiddleware:
"""Per-IP token-bucket rate limiting."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request = Request(scope)
if request.method == "OPTIONS":
await self.app(scope, receive, send)
return
limiter = getattr(request.app.state, "rate_limiter", None)
if limiter is None:
await self.app(scope, receive, send)
return
if not request.client:
# No peer address — cannot enforce per-IP limit; pass through
await self.app(scope, receive, send)
return
client_ip = request.client.host
xff = request.headers.get("X-Forwarded-For", "")
client_ip = resolve_client_ip(client_ip, xff, limiter.trusted_proxies)
path = request.url.path
allowed, retry_after = limiter.check(client_ip, path)
if not allowed:
_metrics.record_ratelimit_reject()
response = JSONResponse(
{"error": "Rate limit exceeded", "retry_after": round(retry_after, 1)},
status_code=429,
headers={"Retry-After": str(int(retry_after) + 1)},
)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
class MetricsMiddleware:
"""Record request method, path, status, and latency."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
t0 = time.monotonic()
status_code = 500
original_send = send
async def capture_send(message: MutableMapping[str, Any]) -> None:
nonlocal status_code
if message["type"] == "http.response.start":
status_code = message["status"]
await original_send(message)
request = Request(scope)
try:
await self.app(scope, receive, capture_send)
finally:
_metrics.record_request(
request.method, request.url.path, status_code, time.monotonic() - t0
)
class LogContextMiddleware:
"""Set structlog context variables (request_id, ws_id) per request."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
import structlog
from turnstone.core.log import ctx_request_id, ctx_ws_id
rid = uuid.uuid4().hex[:8]
tok_rid = ctx_request_id.set(rid)
# Extract ws_id from query params if present
request = Request(scope)
ws_id = request.query_params.get("ws_id", "")
tok_ws = ctx_ws_id.set(ws_id) if ws_id else None
try:
await self.app(scope, receive, send)
finally:
ctx_request_id.reset(tok_rid)
if tok_ws is not None:
ctx_ws_id.reset(tok_ws)
structlog.contextvars.clear_contextvars()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Helper — workstream lookup (replaces self._get_ws on the old handler)
# ---------------------------------------------------------------------------
def _get_ws(mgr: SessionManager, ws_id: str | None) -> tuple[Workstream, WebUI] | tuple[None, None]:
"""Look up workstream by id. Returns (Workstream, WebUI) or (None, None)."""
if not ws_id:
return None, None
ws = mgr.get(ws_id)
if ws and ws.ui:
ui: WebUI = ws.ui # type: ignore[assignment]
return ws, ui
return None, None
def _audit_context(request: Request) -> tuple[str, str]:
"""Extract (user_id, ip_address) from request for audit logging."""
auth = getattr(getattr(request, "state", None), "auth_result", None)
uid: str = auth.user_id if auth else ""
ip = ""
if request.client:
ip = request.client.host
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
from turnstone.core.auth import is_secure_request
if is_secure_request(dict(request.headers), request.url.scheme):
ip = forwarded.split(",")[0].strip()
return uid, ip
# ---------------------------------------------------------------------------
# Per-kind policies passed to the lifted session_routes handlers
# ---------------------------------------------------------------------------
def _interactive_manager_lookup(
request: Request,
) -> tuple[SessionManager | None, JSONResponse | None]:
"""Return the interactive ``SessionManager`` from app.state.
Interactive always has the manager loaded (it's constructed
synchronously at server startup), so the 503 branch is unused
on this side. Matches the :attr:`SessionEndpointConfig.manager_lookup`
callable shape so the lifted handler bodies can call it uniformly.
"""
return request.app.state.workstreams, None
def _interactive_tenant_check(
request: Request, ws_id: str, mgr: SessionManager
) -> JSONResponse | None:
"""Cross-tenant gate for the lifted session handlers.
Forwards to :func:`_require_ws_access`, which returns 404 on
owner mismatch (the interactive trusted-team model).
"""
_owner, err = _require_ws_access(request, ws_id, mgr=mgr)
return err
def _audit_close_workstream(
request: Request,
ws_id: str,
ws_before: Workstream,
reason: str,
) -> None:
"""Record the ``workstream.closed`` audit event for interactive close.
Passed to :func:`make_close_handler` as the ``audit_emit``
callable. ``storage`` is guaranteed non-``None`` by the lifted
handler's upstream gate; the ``getattr`` fallback is defensive
consistency with the rest of the storage access pattern.
"""
from turnstone.core.audit import record_audit
storage = getattr(request.app.state, "auth_storage", None)
if storage is None:
return
_, ip = _audit_context(request)
detail: dict[str, Any] = {
"kind": str(ws_before.kind),
"parent_ws_id": ws_before.parent_ws_id,
}
if reason:
detail["reason"] = reason
record_audit(
storage,
_auth_user_id(request),
"workstream.closed",
"workstream",
ws_id,
detail,
ip,
)
def _audit_rewind_workstream(
request: Request,
ws_id: str,
ws_before: Workstream, # noqa: ARG001 — detail keys off ws_id
turns: int,
) -> None:
"""Record the ``conversation.rewind`` audit event for interactive rewind.
Passed to :func:`make_rewind_handler` as ``audit_emit``. The action
is hardcoded ``conversation.rewind`` on both kinds (no
``coordinator.rewind`` split).
"""
from turnstone.core.audit import record_audit
storage = getattr(request.app.state, "auth_storage", None)
if storage is None:
return
_, ip = _audit_context(request)
record_audit(
storage,
_auth_user_id(request),
"conversation.rewind",
"workstream",
ws_id,
{"turns": turns, "ws_id": ws_id},
ip,
)
def _audit_retry_workstream(
request: Request,
ws_id: str,
ws_before: Workstream, # noqa: ARG001 — detail keys off ws_id
) -> None:
"""Record the ``conversation.retry`` audit event for interactive retry."""
from turnstone.core.audit import record_audit
storage = getattr(request.app.state, "auth_storage", None)
if storage is None:
return
_, ip = _audit_context(request)
record_audit(
storage,
_auth_user_id(request),
"conversation.retry",
"workstream",
ws_id,
{"ws_id": ws_id},
ip,
)
def _interactive_dispatch_retry(ws: Workstream, user_msg: str) -> None:
"""Re-send ``user_msg`` on an interactive workstream after ``/retry``.
Passed to :func:`make_retry_handler` as ``dispatch_retry``; called
once :meth:`ChatSession.retry` has truncated the last turn. Drives
the shared :func:`turnstone.core.session_worker.send` dispatcher with
an interactive ``run`` closure (surfaces ``GenerationCancelled`` /
errors through the WebUI hooks) and a hard-reject ``enqueue`` closure
(a retry must not silently queue behind an in-flight turn — preserves
the pre-lift inline behaviour). The shared dispatcher owns the
``_worker_running`` lifecycle, so the ``run`` closure needs no
``finally`` flag-clear of its own.
"""
from turnstone.core import session_worker
session = ws.session
ui = ws.ui
if session is None or ui is None:
return
def _run() -> None:
me = threading.current_thread()
try:
session.send(user_msg)
except GenerationCancelled:
if ws.worker_thread is me:
ui.on_stream_end()
ui.on_state_change("idle")
except Exception as exc:
if ws.worker_thread is me:
ui.on_error(f"Error: {exc}")
ui.on_stream_end()
ui.on_state_change("error")
def _enqueue() -> None:
ui.on_error("Cannot retry: workstream is busy")
session_worker.send(ws, enqueue=_enqueue, run=_run, thread_name=f"retry-{ws.id[:8]}")
def _interactive_events_replay(
ws: Workstream, ui: Any, request: Request
) -> Iterable[dict[str, Any]]:
"""Initial SSE replay payload for interactive ``events`` connections.
Yields a ``connected`` event (model + skip_permissions), a
``status`` event with the workstream's last token usage + context %
(when a turn has completed), and the pending approval prompt + cached
intent verdicts (if a prompt is pending). The lifted
``make_events_handler`` body delegates that yield sequence to this
callback so the kind-specific shape stays in this module.
Conversation history is NOT replayed over SSE: the frontend fetches
it via ``GET /history`` on page load and re-fetches on the
``clear_ui`` signal (coord's REST-first model), keeping a multi-MB
message list off every (re)connect.
Pure read — never mutates ``ws`` / ``ui`` / ``session``.
"""
session = ws.session
if session is None:
# Defensive — the lifted body's UI presence check guarantees
# the workstream made it past placeholder state, but the
# session can still be detached on the close-then-reopen path.
return
# Connected + status preamble — same shape coord replays use; the
# shared helper keeps the two surfaces from drifting on a future
# field add.
yield from session_replay_preamble(session, ui)
# Pending approval re-injection (so a reconnecting tab sees the
# prompt) + cached LLM verdicts received since the prompt fired.
pending_approval = getattr(ui, "_pending_approval", None)
if pending_approval is not None:
yield pending_approval
with ui._ws_lock:
cached_verdicts = list(ui._llm_verdicts.values())
for v in cached_verdicts:
yield {"type": "intent_verdict", **v}
def _interactive_open_post_load(request: Request, ws: Workstream) -> None:
"""Post-load hook for the lifted interactive ``open`` body.
Runs after ``mgr.open(ws_id)`` returns the workstream (which
internally already attempted ``ws.session.resume(ws_id)`` and
fired ``InteractiveAdapter.emit_rehydrated`` — the latter being
a no-op stub on interactive per the documented asymmetry). This
callback handles the interactive-only out-of-band emissions:
1. Sync the workstream's name to the persisted display alias
(a user-renamed workstream stores its alias separately from
the manager's in-memory name).
2. Emit ``clear_ui`` onto the per-workstream UI listener queue so a
connected browser tab re-fetches conversation state over REST
``GET /history`` (the REST-first model — history is no longer
replayed inline over SSE). Only fires when ``ws.session.messages``
is non-empty (resume succeeded and there's history to show).
3. Enqueue ``ws_created`` onto the global SSE queue so dashboards
and other multi-workstream consumers see the rehydrate. The
handler-side emission is the load-bearing path on interactive;
``InteractiveAdapter.emit_rehydrated`` is a no-op stub
precisely because this enqueue lives here.
"""
from turnstone.core.memory import get_workstream_display_name
ws.name = get_workstream_display_name(ws.id) or ws.name
ui = ws.ui
session = ws.session
if isinstance(ui, WebUI) and session is not None and session.messages:
ui._enqueue({"type": "clear_ui"})
gq: queue.Queue[dict[str, Any]] | None = getattr(request.app.state, "global_queue", None)
if gq is not None:
with contextlib.suppress(queue.Full):
gq.put_nowait(
{
"type": "ws_created",
"ws_id": ws.id,
"name": ws.name,
"model": session.model if session else "",
"model_alias": session.model_alias if session else "",
"kind": ws.kind,
"parent_ws_id": ws.parent_ws_id,
"user_id": ws.user_id,
"project_id": ws.project_id,
}
)
def _audit_workstream_opened(request: Request, ws: Workstream) -> None:
"""Record the ``workstream.opened`` audit event.
Passed to :func:`make_open_handler` as the ``audit_emit``
callable. Mirrors :func:`_audit_close_workstream`'s shape.
Distinguishing rehydrate from fresh-create in the audit trail
(same ``ws_created`` SSE shape on the wire; the audit action
name is the disambiguator) is the original justification for
this row.
"""
from turnstone.core.audit import record_audit
storage = getattr(request.app.state, "auth_storage", None)
if storage is None:
return
_, ip = _audit_context(request)
record_audit(
storage,
_auth_user_id(request),
"workstream.opened",
"workstream",
ws.id,
{"kind": str(ws.kind), "parent_ws_id": ws.parent_ws_id},
ip,
)
# ---------------------------------------------------------------------------
# Route handlers — all async
# ---------------------------------------------------------------------------
async def index(request: Request) -> Response:
"""GET / — serve the embedded HTML client."""
if request.headers.get("If-None-Match") == _HTML_ETAG:
return Response(status_code=304, headers={"ETag": _HTML_ETAG, "Cache-Control": "no-cache"})
resp = HTMLResponse(_HTML)
resp.headers["Cache-Control"] = "no-cache"
resp.headers["ETag"] = _HTML_ETAG
return resp
def _build_node_snapshot(app_state: Any) -> dict[str, Any]:
"""Build a complete node state snapshot for SSE consumers.
Includes workstream list, health, and aggregate — everything the console
collector needs to populate a ``NodeSnapshot`` without polling.
"""
from turnstone.core.memory import get_workstream_display_name
mgr: SessionManager = app_state.workstreams
wss = mgr.list_all()
total_tokens = 0
total_tool_calls = 0
active_count = 0
ws_list = []
for ws in wss:
ui = ws.ui
if hasattr(ui, "_ws_lock"):
with ui._ws_lock: # type: ignore[union-attr]
tok = ui._ws_prompt_tokens + ui._ws_completion_tokens # type: ignore[union-attr]
tc = sum(ui._ws_tool_calls.values()) # type: ignore[union-attr]
ctx = ui._ws_context_ratio # type: ignore[union-attr]
activity = ui._ws_current_activity # type: ignore[union-attr]
activity_state = ui._ws_activity_state # type: ignore[union-attr]
else:
tok = tc = 0
ctx = 0.0
activity = activity_state = ""
total_tokens += tok
total_tool_calls += tc
if ws.state.value != "idle":
active_count += 1
title = ""
if ws.session:
title = get_workstream_display_name(ws.session.ws_id) or ""
# ``pending_approval_detail`` mirrors the dashboard handler's
# projection so the console collector's reconnect-via-snapshot
# path (``_reconcile_node``) can carry the rich approval payload
# across reconnects — without it, a child sitting in approval-
# pending across a console restart or network blip would render
# with no buttons until the next state change. Same data, same
# ``read`` scope as ``/v1/api/dashboard``.
approval_detail: dict[str, Any] | None = None
if ui is not None and hasattr(ui, "serialize_pending_approval_detail"):
approval_detail = ui.serialize_pending_approval_detail()
ws_list.append(
{
"id": ws.id,
"name": title or ws.name,
"state": ws.state.value,
"title": title,
"tokens": tok,
"context_ratio": round(ctx, 3),
"activity": activity,
"activity_state": activity_state,
"tool_calls": tc,
"model": ws.session.model if ws.session else "",
"model_alias": ws.session.model_alias if ws.session else "",
"kind": ws.kind,
"parent_ws_id": ws.parent_ws_id,
"user_id": ws.user_id,
"project_id": ws.project_id,
"pending_approval_detail": approval_detail,
}
)
return {
"type": "node_snapshot",
"node_id": getattr(app_state, "node_id", ""),
"workstreams": ws_list,
"health": _build_health_dict(app_state),
"aggregate": {
"total_tokens": total_tokens,
"total_tool_calls": total_tool_calls,
"active_count": active_count,
"total_count": len(ws_list),
},
}
async def global_events_sse(request: Request) -> Response:
"""GET /v1/api/events/global — global SSE event stream.
Supports optional ``?expected_node_id=X`` query parameter for node identity
verification. If present and the server's node_id does not match, returns
409 Conflict immediately.
On connect, emits a ``node_snapshot`` event with the full node state
(workstreams, health, aggregate) followed by real-time delta events.
The snapshot and listener registration are atomic — no events are lost.
"""
# -- Service-scope gate ---------------------------------------------------
# The global stream carries cluster-wide workstream inventory across
# every tenant (user_id, kind, parent_ws_id, token counts) — intended
# for the console's ClusterCollector, not end-user browsers. Require
# a service-scoped token so an authenticated end-user can't subscribe
# and observe cluster state for other tenants.
if "service" not in _auth_scopes(request):
return JSONResponse({"error": "service scope required"}, status_code=403)
# -- Node identity check --------------------------------------------------
expected = request.query_params.get("expected_node_id")
actual_node_id = getattr(request.app.state, "node_id", "")
if expected and expected != actual_node_id:
return JSONResponse(
{
"error": "node_id mismatch" if actual_node_id else "node_id unavailable",
"expected": expected,
"actual": actual_node_id,
},
status_code=409,
)
# -- Last-Event-ID resume parsing -----------------------------------------
# Native EventSource sets the header on auto-reconnect; the
# manual-reconnect path (which can't set custom headers on
# ``new EventSource(url)``) uses the query-param fallback.
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
# -- Atomic snapshot / replay-slice + listener registration ---------------
client_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=1000)
listeners = request.app.state.global_listeners
listeners_lock = request.app.state.global_listeners_lock
event_buffer: collections.deque[tuple[int, dict[str, Any]]] = (
request.app.state.global_event_buffer
)
# Three replay shapes, matching :func:`make_events_handler`:
# - ``last_event_id is None`` → ``"fresh"``: emit node_snapshot
# then live.
# - ``last_event_id`` + buffer covers gap → ``"replay_ok"``:
# emit buffered events past the id, SKIP node_snapshot, then
# live.
# - ``last_event_id`` + buffer too short → ``"truncated"``: emit
# a ``replay_truncated`` envelope then fall through to
# ``"fresh"`` (node_snapshot is the recovery floor).
replay_status: str
replay_events: list[dict[str, Any]] = []
lost_count = 0
earliest_available_id = 0
snapshot: dict[str, Any] | None = None
with listeners_lock:
if last_event_id is None:
replay_status = "fresh"
snapshot = _build_node_snapshot(request.app.state)
else:
buffered = list(event_buffer)
if not buffered:
replay_status = "replay_ok"
else:
earliest_available_id = buffered[0][0]
if last_event_id < earliest_available_id - 1:
replay_status = "truncated"
lost_count = (earliest_available_id - 1) - last_event_id
snapshot = _build_node_snapshot(request.app.state)
else:
replay_status = "replay_ok"
replay_events = [ev for eid, ev in buffered if eid > last_event_id]
listeners.append(client_queue)
async def event_generator() -> AsyncGenerator[dict[str, Any], None]:
_metrics.record_sse_connect()
def _format_event(event: dict[str, Any]) -> dict[str, str]:
"""Strip ``_event_id`` from the wire dict, attach SSE ``id:``."""
ev_copy = dict(event)
eid = ev_copy.pop("_event_id", 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 jitter (see per-ws handler for
# rationale) — staggers reconnect of many panes / many
# global subscribers after a shared blip.
yield {"retry": random.randint(2500, 4500)}
if replay_status == "truncated":
yield {
"data": json.dumps(
{
"type": "replay_truncated",
"lost_count": lost_count,
"earliest_available_id": earliest_available_id,
}
)
}
if replay_status == "replay_ok":
for ev in replay_events:
yield _format_event(ev)
else:
# Fresh or truncated: emit the node_snapshot as the
# recovery floor. Snapshot is synthetic (built from
# current ws state) and carries no ``_event_id`` —
# the client's ``lastEventId`` stays at whatever the
# last buffered event was (or empty on fresh).
if snapshot is not None:
yield {"data": json.dumps(snapshot)}
loop = asyncio.get_running_loop()
executor = request.app.state.sse_executor
while True:
try:
event = await loop.run_in_executor(
executor, functools.partial(client_queue.get, timeout=5)
)
yield _format_event(event)
except queue.Empty:
pass # poll timeout, retry
finally:
_metrics.record_sse_disconnect()
with listeners_lock:
if client_queue in listeners:
listeners.remove(client_queue)
return EventSourceResponse(event_generator(), ping=5)
async def dashboard(request: Request) -> JSONResponse:
"""GET /v1/api/dashboard — enriched workstream data + aggregate stats."""
from turnstone.core.auth import WorkstreamProjectVisibility
from turnstone.core.memory import get_workstream_display_name
mgr: SessionManager = request.app.state.workstreams
# No per-user OWNER filter (trusted-team deployment shape) — but
# private-project rows are dropped for non-members, same predicate
# as every other listing surface. Executor: the predicate resolves
# project rows from storage, so the filter must not run on the
# event loop.
visibility = WorkstreamProjectVisibility.for_request(request)
def _visible_wss() -> list[Workstream]:
return [
ws
for ws in mgr.list_all()
if visibility.ws_visible(getattr(ws, "project_id", "") or "", ws_owner=ws.user_id or "")
]
wss = await asyncio.to_thread(_visible_wss)
total_tokens = 0
total_tool_calls = 0
active_count = 0
ws_list = []
for ws in wss:
ui: WebUI = ws.ui # type: ignore[assignment]
with ui._ws_lock:
tok = ui._ws_prompt_tokens + ui._ws_completion_tokens
tc = sum(ui._ws_tool_calls.values())
ctx = ui._ws_context_ratio
activity = ui._ws_current_activity
activity_state = ui._ws_activity_state
total_tokens += tok
total_tool_calls += tc
if ws.state.value != "idle":
active_count += 1
title = ""
if ws.session:
title = get_workstream_display_name(ws.session.ws_id) or ""
ws_list.append(
{
"ws_id": ws.id,
"name": title or ws.name,
"state": ws.state.value,
"title": title,
"tokens": tok,
"context_ratio": round(ctx, 3),
"activity": activity,
"activity_state": activity_state,
"tool_calls": tc,
"node": "local",
"model": ws.session.model if ws.session else "",
"model_alias": ws.session.model_alias if ws.session else "",
"kind": ws.kind,
"parent_ws_id": ws.parent_ws_id,
"user_id": ws.user_id,
"project_id": ws.project_id,
"pending_approval_detail": ui.serialize_pending_approval_detail(),
# Per-ws ring buffer of recent auto-approves (last 10).
# Lets the coord-tree render a "recently auto-approved
# by skill X" pill without a per-child round-trip — the
# tools-bypassed-the-prompt set is otherwise invisible
# to anyone watching the dashboard tree.
"recent_auto_approvals": ui.serialize_recent_auto_approvals(),
}
)
uptime_sec = round(time.monotonic() - _metrics.start_time)
return JSONResponse(
{
"workstreams": ws_list,
"aggregate": {
"total_tokens": total_tokens,
"total_tool_calls": total_tool_calls,
"active_count": active_count,
"total_count": len(ws_list),
"uptime_seconds": uptime_sec,
"node": "local",
},
}
)
async def list_skills_summary(request: Request) -> JSONResponse:
"""GET /v1/api/skills — list available skills (summary)."""
from turnstone.core.storage._registry import get_storage
from turnstone.core.web_helpers import skill_summary_rows
try:
storage = get_storage()
except Exception:
return JSONResponse({"error": "Storage not available"}, status_code=503)
return JSONResponse({"skills": skill_summary_rows(storage)})
async def list_available_models(request: Request) -> JSONResponse:
"""GET /v1/api/models — list available model aliases."""
registry = getattr(request.app.state, "registry", None)
if registry is None:
return JSONResponse({"models": []})
models = []
for alias in registry.list_aliases():
cfg = registry.get_config(alias)
models.append(
{
"alias": cfg.alias,
"model": cfg.model,
"provider": cfg.provider,
"capabilities": cfg.capabilities,
}
)
# Include effective defaults for clients (web UI, channel gateway).
cs = getattr(request.app.state, "config_store", None)
default_alias = ""
channel_default_alias = ""
judge_default_alias = ""
if cs is not None:
default_alias = cs.get("model.default_alias") or ""
channel_default_alias = cs.get("channels.default_model_alias") or ""
judge_default_alias = (cs.get("judge.model") or "").strip()
# Mirror session_factory's ``_effective_default_alias`` (and
# ``_effective_routing``): a ``model.default_alias`` that's unset — or
# names an alias absent from THIS server's registry — falls back to
# ``registry.default``, which is the model a new workstream actually
# launches on. Reporting "" here made the dashboard show a bare
# "Default model" placeholder even though creation has a concrete default
# (e.g. when the shared ConfigStore points at a console-only alias).
enabled_aliases = set(registry.list_aliases())
if default_alias not in enabled_aliases:
default_alias = registry.default
# Defensive: only blank if even registry.default is unknown/disabled.
if default_alias and default_alias not in enabled_aliases:
default_alias = ""
if channel_default_alias and channel_default_alias not in enabled_aliases:
channel_default_alias = ""
# ``judge.model`` is alias-only. When unset — or pointing at a
# disabled/removed alias — the judge inherits the per-workstream agent
# model at runtime (see session_factory: ``judge_config.model or
# model``). Leave the field blank in that case so the UI renders
# "Default (agent model)" rather than a misleading fixed alias; only
# an explicitly-configured, enabled alias surfaces a concrete default.
if judge_default_alias and judge_default_alias not in enabled_aliases:
judge_default_alias = ""
# STT/TTS are media roles: report a default only when the configured alias
# exists AND is capability-eligible (the same gate the endpoints apply), so
# the UI shows the mic / playback affordances only when they actually work.
from turnstone.core.audio import resolve_role_alias
stt_default_alias = resolve_role_alias(config_store=cs, registry=registry, role="stt") or ""
tts_default_alias = resolve_role_alias(config_store=cs, registry=registry, role="tts") or ""
return JSONResponse(
{
"models": models,
"default_alias": default_alias,
"channel_default_alias": channel_default_alias,
"judge_default_alias": judge_default_alias,
"stt_default_alias": stt_default_alias,
"tts_default_alias": tts_default_alias,
}
)
_STT_UPLOAD_CAP = 25 * 1024 * 1024 # 25 MiB — generous for short dictation clips
_TTS_TEXT_CAP = 8000 # characters per synthesis request
async def speech_to_text(request: Request) -> JSONResponse:
"""POST /v1/api/workstreams/{ws_id}/speech-to-text — transcribe one audio clip.
Multipart body with a single ``audio`` field. Returns the transcript for
the browser to place into the composer; this endpoint never sends on the
user's behalf (no auto-send, no request rewriting).
"""
from turnstone.core.audio import (
AudioBackendError,
AudioUnavailableError,
resolve_role_alias,
transcribe,
)
from turnstone.core.web_helpers import read_multipart_file_or_400
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 = _require_ws_access(request, ws_id)
if err:
return err
registry = getattr(request.app.state, "registry", None)
config_store = getattr(request.app.state, "config_store", None)
alias = resolve_role_alias(config_store=config_store, registry=registry, role="stt")
if not alias:
return JSONResponse(
{
"error": (
"Speech-to-text is not configured. Assign an STT model role in Models → Roles."
)
},
status_code=503,
)
got = await read_multipart_file_or_400(request, field="audio", max_bytes=_STT_UPLOAD_CAP)
if isinstance(got, JSONResponse):
return got
filename, _claimed_mime, data = got
if not data:
return JSONResponse({"error": "Empty audio upload"}, status_code=400)
stt_prompt = ""
if config_store is not None:
stt_prompt = (config_store.get("audio.stt_prompt") or "").strip()
try:
# Blocking SDK round-trip — offload so the shared event loop (and SSE
# streaming) stays responsive.
result = await asyncio.to_thread(
transcribe,
registry=registry,
alias=alias,
data=data,
filename=filename or "speech.webm",
prompt=stt_prompt,
)
except AudioUnavailableError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
except AudioBackendError as exc:
# str(exc) wraps the backend SDK error, which can carry upstream
# response detail — log it, but return a static body to the caller.
log.warning("speech_to_text.backend_failed", error=str(exc), exc_info=True)
return JSONResponse({"error": "Speech transcription backend failed"}, status_code=502)
if not result.transcript:
# Successful call that detected no speech (silence / non-speech audio)
# is not a backend failure — surface it as 422 so the UI can say so
# rather than treating a healthy backend as a bad gateway.
return JSONResponse({"error": "No speech detected"}, status_code=422)
return JSONResponse(
{
"status": "ok",
"transcript": result.transcript,
"model_alias": result.model_alias,
}
)
async def speech_to_text_stream(request: Request) -> Response:
"""POST /v1/api/workstreams/{ws_id}/speech-to-text/stream — stream the
transcript as plain-text deltas for lower perceived latency than the JSON
``speech-to-text`` endpoint. Resolve/transcode failures surface as
503 / 502 before any bytes are sent; once streaming begins the body is
best-effort (a mid-stream backend error just ends the partial stream)."""
from turnstone.core.audio import (
AudioBackendError,
AudioUnavailableError,
resolve_role_alias,
transcribe_stream,
)
from turnstone.core.web_helpers import read_multipart_file_or_400
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 = _require_ws_access(request, ws_id)
if err:
return err
registry = getattr(request.app.state, "registry", None)
config_store = getattr(request.app.state, "config_store", None)
alias = resolve_role_alias(config_store=config_store, registry=registry, role="stt")
if not alias:
return JSONResponse(
{
"error": (
"Speech-to-text is not configured. Assign an STT model role in Models → Roles."
)
},
status_code=503,
)
got = await read_multipart_file_or_400(request, field="audio", max_bytes=_STT_UPLOAD_CAP)
if isinstance(got, JSONResponse):
return got
_filename, _claimed_mime, data = got
if not data:
return JSONResponse({"error": "Empty audio upload"}, status_code=400)
stt_prompt = ""
if config_store is not None:
stt_prompt = (config_store.get("audio.stt_prompt") or "").strip()
# Resolve + transcode + open the stream eagerly (off the event loop) so the
# common failures map to a clean status before any bytes are sent.
try:
deltas = await asyncio.to_thread(
transcribe_stream, registry=registry, alias=alias, data=data, prompt=stt_prompt
)
except AudioUnavailableError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
except AudioBackendError:
log.warning("speech_to_text_stream.backend_failed", exc_info=True)
return JSONResponse({"error": "Speech transcription backend failed"}, status_code=502)
# Drive the blocking stream from one worker thread that owns (and closes)
# the upstream connection, handing deltas to the loop via a queue. A client
# disconnect sets ``stop`` so the thread releases the connection promptly
# instead of being pinned mid-``next()`` (which can't be cancelled).
async def _body() -> AsyncGenerator[bytes, None]:
loop = asyncio.get_running_loop()
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
stop = threading.Event()
def _pump() -> None:
try:
for delta in deltas:
if stop.is_set():
break
loop.call_soon_threadsafe(queue.put_nowait, delta.encode("utf-8"))
except Exception:
# Mid-stream backend failure: end the partial stream (logged).
log.warning("speech_to_text_stream.mid_stream_failed", exc_info=True)
finally:
close = getattr(deltas, "close", None)
if callable(close):
close()
loop.call_soon_threadsafe(queue.put_nowait, None)
loop.run_in_executor(None, _pump)
try:
while True:
chunk = await queue.get()
if chunk is None:
break
yield chunk
finally:
stop.set()
return StreamingResponse(_body(), media_type="text/plain; charset=utf-8")
async def text_to_speech(request: Request) -> Response:
"""POST /v1/api/tts — synthesize assistant text into playable audio."""
from turnstone.core.audio import (
AudioBackendError,
AudioUnavailableError,
resolve_role_alias,
synthesize,
)
from turnstone.core.web_helpers import read_json_or_400
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
text = str(body.get("text") or "").strip()
if not text:
return JSONResponse({"error": "text is required"}, status_code=400)
if len(text) > _TTS_TEXT_CAP:
return JSONResponse(
{"error": f"text too long (cap {_TTS_TEXT_CAP} chars)"}, status_code=400
)
registry = getattr(request.app.state, "registry", None)
config_store = getattr(request.app.state, "config_store", None)
alias = resolve_role_alias(config_store=config_store, registry=registry, role="tts")
if not alias:
return JSONResponse(
{
"error": (
"Text-to-speech is not configured. Assign a TTS model role in Models → Roles."
)
},
status_code=503,
)
voice = str(body.get("voice") or "").strip()
if not voice and config_store is not None:
voice = (config_store.get("audio.tts_voice") or "").strip()
try:
# Blocking SDK round-trip — offload off the event loop.
speech = await asyncio.to_thread(
synthesize, registry=registry, alias=alias, text=text, voice=voice
)
except AudioUnavailableError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
except AudioBackendError as exc:
# Static body to the caller; backend SDK detail stays in the log.
log.warning("text_to_speech.backend_failed", error=str(exc), exc_info=True)
return JSONResponse({"error": "Speech synthesis backend failed"}, status_code=502)
return Response(
speech.audio_bytes,
media_type=speech.media_type,
headers={"X-Model-Alias": speech.model_alias},
)
def _count_ws_states(wss: list[Workstream]) -> dict[str, int]:
"""Count workstream states for health/metrics endpoints."""
counts = dict.fromkeys(("idle", "thinking", "running", "attention", "error"), 0)
for ws in wss:
counts[ws.state.value] = counts.get(ws.state.value, 0) + 1
return counts
def _build_health_dict(app_state: Any) -> dict[str, Any]:
"""Assemble health status dict from app state.
Shared by the ``/health`` endpoint and the global SSE snapshot.
"""
mgr: SessionManager = app_state.workstreams
wss = mgr.list_all()
states = _count_ws_states(wss)
health_reg = getattr(app_state, "health_registry", None)
registry = getattr(app_state, "registry", None)
tracker = None
if health_reg and registry:
# Prefer ConfigStore runtime override, fall back to registry default
config_store = getattr(app_state, "config_store", None)
effective_alias = None
if config_store:
effective_alias = config_store.get("model.default_alias") or None
if effective_alias:
tracker = health_reg.get_tracker_for_alias(registry, effective_alias)
if tracker is None:
tracker = health_reg.get_tracker_for_alias(registry, registry.default)
backend_ok = tracker.is_healthy if tracker else True
data: dict[str, Any] = {
"status": "ok" if backend_ok else "degraded",
"version": __version__,
"node_id": getattr(app_state, "node_id", ""),
"uptime_seconds": round(time.monotonic() - _metrics.start_time, 2),
"model": _metrics.model,
"max_ws": mgr.max_active,
"workstreams": {"total": len(wss), **states},
"backend": {
"status": "up" if backend_ok else "down",
},
}
mc = getattr(app_state, "mcp_client", None)
if mc:
data["mcp"] = {
"servers": mc.server_count,
"resources": mc.resource_count,
"prompts": mc.prompt_count,
}
# Only present when tls.enabled: "active" (serving HTTPS) or "fallback"
# (TLS init failed, serving plain HTTP). Makes a silently-downgraded
# node observable.
tls_state = getattr(app_state, "tls_state", None)
if tls_state:
data["tls"] = tls_state
return data
async def health(request: Request) -> JSONResponse:
"""GET /health — server health status."""
return JSONResponse(_build_health_dict(request.app.state))
async def metrics_endpoint(request: Request) -> Response:
"""GET /metrics — Prometheus text exposition format."""
mgr: SessionManager = request.app.state.workstreams
wss = mgr.list_all()
states = _count_ws_states(wss)
ws_data = []
for ws in wss:
ui: WebUI = ws.ui # type: ignore[assignment]
with ui._ws_lock:
ws_data.append(
{
"ws_id": ws.id,
"name": ws.name,
"prompt_tokens": ui._ws_prompt_tokens,
"completion_tokens": ui._ws_completion_tokens,
"messages": ui._ws_messages,
"tool_calls": dict(ui._ws_tool_calls),
"context_ratio": ui._ws_context_ratio,
}
)
mcp_info = None
mc = getattr(request.app.state, "mcp_client", None)
if mc:
mcp_info = {
"servers": mc.server_count,
"resources": mc.resource_count,
"prompts": mc.prompt_count,
"errors": mc.error_count,
}
content = _metrics.generate_text(
workstream_states=states,
total_workstreams=len(wss),
workstream_metrics=ws_data,
mcp_info=mcp_info,
)
return Response(content, media_type="text/plain; version=0.0.4; charset=utf-8")
def _capture_cancel_forensics(session: Any, ui: Any, *, was_running: bool) -> dict[str, Any]:
"""Snapshot in-flight session state for the cancel response.
Pure read — never mutates ``session`` or ``ui``. Fields are
best-effort: any attribute miss (test double, alternate UI) falls
through to "not observable". Kept short so a coordinator surfacing
the dropped dict doesn't bloat the tool-result payload.
"""
out: dict[str, Any] = {"was_running": was_running}
pending = getattr(ui, "_pending_approval", None)
if isinstance(pending, dict):
tool_names: list[str] = []
first_call_id = ""
for item in pending.get("items", []) or []:
if not isinstance(item, dict):
continue
if not item.get("needs_approval"):
continue
name = item.get("approval_label") or item.get("func_name") or ""
if name:
tool_names.append(str(name))
if not first_call_id:
first_call_id = str(item.get("call_id") or "")
if tool_names:
out["pending_approval"] = {
"tool_names": tool_names,
"call_id": first_call_id,
}
queued = getattr(session, "_queued_messages", None)
if queued:
try:
count = len(queued)
except TypeError:
count = 0
preview = ""
try:
first = next(iter(queued.values()))
if isinstance(first, tuple) and first:
# Run through the credential-redactor before truncating so
# pasted secrets / connection strings / JWTs in the queued
# message don't land verbatim in the cancel_workstream
# tool result (which gets persisted to the coordinator's
# conversation history AND fanned out via SSE). Matches
# the close_workstream.reason persistence path (phase 5).
from turnstone.core.output_guard import redact_credentials
preview = redact_credentials(str(first[0]))[:120]
except StopIteration:
pass
except Exception:
preview = ""
if count:
out["queued_messages"] = {"count": count, "first_preview": preview}
return out
async def command(request: Request) -> JSONResponse:
"""POST /v1/api/command — execute a slash command."""
from turnstone.core.web_helpers import read_json_or_400
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
cmd = body.get("command", "").strip()
ws_id = body.get("ws_id")
if not cmd:
return JSONResponse({"error": "Empty command"}, status_code=400)
mgr = request.app.state.workstreams
_owner, err = _require_ws_access(request, str(ws_id or ""), mgr=mgr)
if err:
return err
ws, ui = _get_ws(mgr, ws_id)
if not ws or not ui:
return JSONResponse({"error": "Unknown workstream"}, status_code=404)
assert ws.session is not None
try:
# Permission gate for conversation-modifying commands
cmd_word = cmd.strip().split(None, 1)[0].lower()
# ``/rewind`` and ``/retry`` were lifted to path-keyed endpoints
# (POST /v1/api/workstreams/{ws_id}/rewind|retry, issue #549).
# Reject them here so a stale web client gets a clear pointer
# instead of a half-applied mutation: ``handle_command`` below
# would still rewind/retry, but the web-specific clear_ui emit +
# retry re-dispatch no longer live in this handler. The terminal
# CLI keeps dispatching these through ``handle_command`` in-process.
if cmd_word in ("/rewind", "/retry"):
verb = cmd_word[1:]
return JSONResponse(
{
"error": (
f"{cmd_word} is no longer served by /command; "
f"use POST /v1/api/workstreams/{{ws_id}}/{verb}"
)
},
status_code=400,
)
should_exit = ws.session.handle_command(cmd)
if should_exit:
ui.on_info("Session ended. You can close this tab.")
# Handle UI updates for workstream-changing commands
if cmd_word in ("/clear", "/new"):
ui._enqueue({"type": "clear_ui"})
elif cmd_word == "/resume":
# clear_ui signals the frontend to re-fetch history via REST.
ui._enqueue({"type": "clear_ui"})
# Sync in-memory workstream name after any command that can change it.
# This ensures /api/workstreams and future page loads see the right name.
if cmd_word in ("/name", "/resume"):
from turnstone.core.memory import get_workstream_display_name
updated_name = get_workstream_display_name(ws.session.ws_id) if ws.session else None
if updated_name:
ws.name = updated_name
except Exception as e:
ui.on_error(f"Command error: {e}")
return JSONResponse({"status": "ok"})
# ---------------------------------------------------------------------------
# Notification helpers — completion delivery for scheduled workstreams
# ---------------------------------------------------------------------------
_MAX_NOTIFY_TARGETS = 10
def _validate_notify_targets(raw: Any) -> tuple[str, str]:
"""Validate and normalize notify_targets input.
Returns (json_string, error_message). Error is empty on success.
"""
if not raw:
return "[]", ""
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return "[]", "notify_targets must be valid JSON"
elif isinstance(raw, list):
parsed = raw
else:
return "[]", "notify_targets must be a JSON array or string"
if not isinstance(parsed, list):
return "[]", "notify_targets must be a JSON array"
if len(parsed) > _MAX_NOTIFY_TARGETS:
return "[]", f"notify_targets limited to {_MAX_NOTIFY_TARGETS} entries"
normalized: list[dict[str, str]] = []
for i, t in enumerate(parsed):
if not isinstance(t, dict):
return "[]", f"notify_targets[{i}] must be an object"
if "channel_type" not in t:
return "[]", f"notify_targets[{i}] missing channel_type"
has_channel_id = "channel_id" in t and t.get("channel_id") is not None
has_user_id = "user_id" in t and t.get("user_id") is not None
if has_channel_id and has_user_id:
return "[]", f"notify_targets[{i}] must specify only one of channel_id or user_id"
if not has_channel_id and not has_user_id:
return "[]", f"notify_targets[{i}] requires channel_id or user_id"
normalized_target: dict[str, str] = {}
for key in ("channel_type", "channel_id", "user_id"):
val = t.get(key)
if val is None:
continue
if not isinstance(val, str):
return "[]", f"notify_targets[{i}].{key} must be a non-empty string <= 256 chars"
stripped = val.strip()
if not stripped:
return "[]", f"notify_targets[{i}].{key} must be a non-empty string <= 256 chars"
if len(stripped) > 256:
return "[]", f"notify_targets[{i}].{key} must be a non-empty string <= 256 chars"
normalized_target[key] = stripped
normalized.append(normalized_target)
return json.dumps(normalized), ""
def _extract_last_assistant_content(session: Any) -> str:
"""Return the text content of the last assistant message."""
for turn in reversed(session.messages):
msg = turn_to_dict(turn)
if msg.get("role") == "assistant":
content = msg.get("content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text")
if isinstance(text, str) and text:
parts.append(text)
return "\n".join(parts)
return ""
def _fire_notify_targets(ws: Any, content: str) -> None:
"""Send completion notifications to all configured targets."""
if not ws.notify_targets:
return
if not content:
content = "(Task completed — no output captured)"
try:
targets = json.loads(ws.notify_targets)
except (json.JSONDecodeError, TypeError):
return
if not targets or not isinstance(targets, list):
return
from turnstone.core.session import _notify_auth_headers
from turnstone.core.storage import get_storage
storage = get_storage()
auth_headers = _notify_auth_headers()
task_name = ws.name or ws.id[:8]
for target in targets:
if not isinstance(target, dict):
continue
channel_type = target.get("channel_type", "")
resolved: dict[str, str] = {}
if "channel_id" in target:
resolved = {"channel_type": channel_type, "channel_id": target["channel_id"]}
elif "user_id" in target:
resolved = {"channel_type": channel_type, "channel_id": target["user_id"]}
else:
continue
payload = {
"target": resolved,
"message": content,
"title": f"Schedule: {task_name}",
"ws_id": ws.id,
}
_deliver_notification(storage, payload, auth_headers)
def _deliver_notification(
storage: Any,
payload: dict[str, Any],
auth_headers: dict[str, str],
) -> None:
"""POST to channel gateway /v1/api/notify with retry."""
import httpx
for attempt in range(3):
services = storage.list_services("channel", max_age_seconds=120)
if not services:
if attempt < 2:
time.sleep(1.0 if attempt == 0 else 3.0)
continue
log.warning("notify_completion.no_services")
return
for svc in services:
url = svc["url"].rstrip("/") + "/v1/api/notify"
if not url.startswith(("http://", "https://")):
continue
try:
resp = httpx.post(url, json=payload, timeout=10, headers=auth_headers)
if resp.status_code < 300:
# Verify at least one target was delivered (mirrors _exec_notify)
try:
data = resp.json()
results = data.get("results") if isinstance(data, dict) else None
if isinstance(results, list) and any(
isinstance(r, dict) and r.get("status") == "sent" for r in results
):
log.info("notify_completion.delivered", ws_id=payload.get("ws_id"))
return
except Exception:
log.debug("notify_completion.response_parse_error", url=url, exc_info=True)
log.warning("notify_completion.no_successful_delivery", url=url)
continue
log.warning(
"notify_completion.failed",
status=resp.status_code,
url=url,
)
except Exception:
log.exception("notify_completion.error", url=url)
continue
if attempt < 2:
time.sleep(1.0 if attempt == 0 else 3.0)
async def _interactive_create_validate_request(
request: Request,
body: dict[str, Any],
uid: str,
uploaded_files: list[tuple[str, str, bytes]],
) -> JSONResponse | None:
"""Per-kind pre-create gates for interactive workstreams.
Wired onto :attr:`SessionEndpointConfig.create_validate_request`
and called by :func:`make_create_handler` after body parsing
but before skill resolution / ``mgr.create``. Returns the
rejection response or ``None`` to continue.
Gates:
- ws_id format must match :data:`_VALID_WS_ID` (32 hex chars)
when supplied.
- attachments + resume_ws combo is disallowed (resume forks an
existing ws; attachments belong on the *fresh* turn — caller
should resume first, then upload via the standard endpoint).
- body kind must be ``INTERACTIVE``: coordinator workstreams
land on the console handler with ``admin.coordinator`` scope,
not this one. Unknown / future kind values 400 rather than
silently coerce.
- parent_ws_id (when supplied) must reference a coordinator
owned by ``uid``. Without this gate an attacker could point a
new interactive workstream at someone else's coordinator and
receive that coordinator's child_ws_* SSE events
(name/state/tokens leak).
- notify_targets (when supplied) must validate.
:func:`_validate_notify_targets` is pure-read and doesn't need
``ws`` to be built — gating here preserves pre-lift's 400
semantic for caller-supplied input. Without this pre-create
gate a malformed ``notify_targets`` would land in
``post_install`` (after ``mgr.create``, audit emit, and the
``ws_created`` broadcast) and the only available signal is to
raise — which the factory turns into 500. 400 at the gate is
correct shape for client-input validation.
"""
requested_ws_id = body.get("ws_id", "") or ""
if not isinstance(requested_ws_id, str):
requested_ws_id = ""
if requested_ws_id and not _VALID_WS_ID.match(requested_ws_id):
return JSONResponse({"error": "invalid ws_id format"}, status_code=400)
resume_ws_id = body.get("resume_ws", "") or ""
if uploaded_files and resume_ws_id:
return JSONResponse(
{"error": "attachments cannot be combined with resume_ws"},
status_code=400,
)
try:
body_kind = WorkstreamKind.from_raw(body.get("kind"))
except ValueError:
return JSONResponse(
{"error": f"unknown workstream kind {body.get('kind')!r}"},
status_code=400,
)
if body_kind != WorkstreamKind.INTERACTIVE:
return JSONResponse(
{
"error": (
"coordinator workstreams must be created on the console via "
"POST /v1/api/workstreams/new (with admin.coordinator scope)"
)
},
status_code=400,
)
inherited_pid = False
body_parent = body.get("parent_ws_id") or None
if body_parent is not None:
from turnstone.core.storage._registry import get_storage as _get_storage_for_parent
_pstorage = _get_storage_for_parent()
parent_row = _pstorage.get_workstream(body_parent) if _pstorage else None
if parent_row is None:
return JSONResponse(
{"error": "parent_ws_id does not reference a known workstream"},
status_code=400,
)
if (
parent_row.get("kind") != WorkstreamKind.COORDINATOR
or (parent_row.get("user_id") or "") != uid
):
return JSONResponse(
{"error": "parent_ws_id must reference a coordinator you own"},
status_code=403,
)
# Inherit the parent coordinator's project unless the spawn set one
# explicitly (the spawn_workstream(project=…) escape hatch), so a child
# recalls the same shared project bucket as its coordinator. Covers
# spawn_workstream and spawn_batch, and survives the routing proxy
# (which forwards the body verbatim).
if not (body.get("project_id") or "") and parent_row.get("project_id"):
body["project_id"] = parent_row.get("project_id")
inherited_pid = True
# Project attach gate (explicit or parent-inherited): a private
# project accepts new workstreams only from its owner/members, and a
# nonexistent EXPLICIT project_id is a caller error rather than a
# silent dangling link. Re-checking the inherited value is deliberate
# — a coordinator owner whose membership was revoked fails the child
# spawn loudly here instead of minting rows they can no longer see.
# The one asymmetry: an INHERITED project that no longer exists is
# not the spawner's error — project deletion leaves the parent's
# link dangling by design and must not disable spawn_workstream, so
# the child simply isn't attached.
attach_pid = str(body.get("project_id") or "")
if attach_pid:
from turnstone.core.auth import ensure_project_attachable
denied = ensure_project_attachable(uid, attach_pid)
if denied is not None:
status, message = denied
if inherited_pid and status == 400:
body["project_id"] = ""
else:
return JSONResponse({"error": message}, status_code=status)
notify_targets_raw = body.get("notify_targets", "[]")
if isinstance(notify_targets_raw, list):
notify_targets_raw = json.dumps(notify_targets_raw)
_, nt_err = _validate_notify_targets(notify_targets_raw)
if nt_err:
return JSONResponse({"error": nt_err}, status_code=400)
return None
def _interactive_create_build_kwargs(
request: Request,
body: dict[str, Any],
uid: str,
skill_data: dict[str, Any] | None,
skill_id: str,
applied_skill_version: int,
) -> dict[str, Any]:
"""Build kwargs for ``mgr.create`` from a parsed interactive create body.
Wired onto :attr:`SessionEndpointConfig.create_build_kwargs`. The
factory threads the resolved skill_data + skill_id + version
through; this builder picks the right model (skill override
beats body) and assembles the full kwargs dict that
``SessionManager.create`` accepts (including the kind-specific
``judge_model`` / ``client_type`` / ``parent_ws_id`` extras).
"""
resolved_model = body.get("model") or None
if skill_data and skill_data.get("model"):
resolved_model = skill_data["model"]
requested_ws_id = body.get("ws_id", "") or ""
if not isinstance(requested_ws_id, str):
requested_ws_id = ""
# Use the canonical skill name from the resolved row rather than
# ``body["skill"]`` so a whitespace-padded request body
# (``"skill": " my-skill "``) doesn't persist a name that fails
# later session-side lookups. The factory strips the lookup key
# but the raw value used to flow through unchanged.
canonical_skill = str(skill_data["name"]) if skill_data and skill_data.get("name") else None
return {
"user_id": uid,
"name": body.get("name", ""),
"model": resolved_model,
"skill": canonical_skill,
"skill_id": skill_id,
"skill_version": applied_skill_version,
"ws_id": requested_ws_id,
"client_type": body.get("client_type", "") or "",
"judge_model": body.get("judge_model", "") or None,
"parent_ws_id": body.get("parent_ws_id") or None,
"project_id": body.get("project_id") or None,
}
async def _interactive_create_post_install(
request: Request,
ws: Workstream,
body: dict[str, Any],
uid: str,
skill_data: dict[str, Any] | None,
applied_skill_version: int,
attachment_ids: list[str],
) -> dict[str, Any]:
"""Tail end of interactive create: per-WebUI bookkeeping + dispatch.
Wired onto :attr:`SessionEndpointConfig.create_post_install`.
Runs after the workstream is fully built, attachments saved,
and audit emitted. Sequence:
1. Cast ``ws.ui`` to :class:`WebUI` (defence in depth — the
interactive adapter's session factory is the only path that
reaches this handler).
2. Apply ``auto_approve`` from server-wide ``skip_permissions``
or per-request body.
3. Register the watch runner for the workstream's session.
4. Broadcast ``ws_created`` on the global SSE queue. Held until
this point so a rejected attachment validation produces no
phantom create→close pair on the SSE stream.
5. Atomic resume: if ``body["resume_ws"]`` is set, fork the
referenced session into the new ws_id, push history into the
UI listener queue, and rebroadcast ``ws_rename`` so the tab
picks up the fork's display name.
6. Apply the skill's session config (temperature / reasoning /
max_tokens / approval policy / metadata).
7. Resolve notify_targets (schedule targets win over skill
fallback).
8. Pin the workstream's routing to this node when no caller-
supplied ``ws_id`` was provided (direct creates).
9. Spawn the initial-message worker thread when ``initial_message``
is set, resolving any staged uploads from the buffer onto that
first turn (then draining them so a freshly-opened pane's
rehydrate can't observe them as still-pending).
Returns ``{resumed, message_count}`` for the response. On the
no-resume path both default to ``False`` / ``0``.
"""
from turnstone.core.memory import get_workstream_display_name
if not isinstance(ws.ui, WebUI):
raise TypeError(f"Expected WebUI, got {type(ws.ui).__name__}")
skip: bool = request.app.state.skip_permissions
if skip or body.get("auto_approve", False):
ws.ui.auto_approve = True
runner = getattr(request.app.state, "watch_runner", None)
if runner and ws.session:
ws.session.set_watch_runner(runner)
gq: queue.Queue[dict[str, Any]] = request.app.state.global_queue
# Emit ``ws_created`` on the global queue for SSE consumers
# (console). Held until past attachment validation in the
# factory so a rejected upload doesn't flash a workstream that
# never really existed.
display_name = get_workstream_display_name(ws.id) or ws.name
with contextlib.suppress(queue.Full):
gq.put_nowait(
{
"type": "ws_created",
"ws_id": ws.id,
"name": display_name,
"model": ws.session.model if ws.session else "",
"model_alias": ws.session.model_alias if ws.session else "",
"kind": ws.kind,
"parent_ws_id": ws.parent_ws_id,
# Owner id propagates through the cluster event
# stream so console-side fan-out can enforce tenant
# isolation — a coordinator must never receive
# child_ws_* events for workstreams it doesn't own.
"user_id": ws.user_id,
# Project id likewise: the console's per-connection SSE
# tenancy filter gates ws_created on it — omitting it
# here made freshly-created private-project workstreams
# fail open on live cluster views (its open/resume and
# node-snapshot siblings already carry it).
"project_id": ws.project_id,
}
)
# Atomic workstream resume during creation.
resumed = False
message_count = 0
resume_ws_id = body.get("resume_ws", "") or ""
if resume_ws_id and ws.session is not None:
from turnstone.core.memory import resolve_workstream
target_id = resolve_workstream(resume_ws_id)
if target_id and ws.session.resume(target_id, fork=True):
resumed = True
message_count = len(ws.session.messages)
user_name = body.get("name", "").strip()
if user_name:
from turnstone.core.memory import set_workstream_alias
set_workstream_alias(ws.id, user_name)
ws.name = user_name
ui = ws.ui
if isinstance(ui, WebUI):
# clear_ui signals the frontend to re-fetch history via REST.
ui._enqueue({"type": "clear_ui"})
with contextlib.suppress(queue.Full):
gq.put_nowait({"type": "ws_rename", "ws_id": ws.id, "name": ws.name})
# Apply skill session config (only for new workstreams with a skill).
if skill_data and not resumed and ws.session:
sess = ws.session
if skill_data.get("temperature") is not None:
sess.temperature = skill_data["temperature"]
if skill_data.get("reasoning_effort"):
sess.reasoning_effort = skill_data["reasoning_effort"]
if skill_data.get("max_tokens") is not None:
sess.max_tokens = skill_data["max_tokens"]
if skill_data.get("token_budget", 0) > 0:
sess._token_budget = skill_data["token_budget"]
if skill_data.get("agent_max_turns") is not None:
sess.agent_max_turns = skill_data["agent_max_turns"]
if skill_data.get("auto_approve"):
ws.ui.auto_approve = True
allowed = skill_data.get("allowed_tools", "")
if allowed and allowed != "[]":
import json as _json
try:
tools_list = _json.loads(allowed)
except (ValueError, TypeError):
tools_list = [t.strip() for t in allowed.split(",") if t.strip()]
if tools_list:
ws.ui.auto_approve_tools = set(tools_list)
# Tag each as skill-sourced so the dashboard can show
# "auto-approved by skill X" instead of a generic
# auto-approval pill — distinguishes the (often
# surprising) skill-template path from a deliberate
# operator "Approve + Always" click.
ws.ui._auto_approve_tools_source = {t: AutoApproveReason.SKILL for t in tools_list}
sess._notify_on_complete = skill_data.get("notify_on_complete", "[]")
sess._applied_skill_id = skill_data["template_id"]
sess._applied_skill_version = applied_skill_version
if skill_data.get("content"):
sess._applied_skill_content = skill_data["content"]
sess._save_config()
# notify_targets: schedule targets override skill targets. The
# validator already gated malformed input as 400; here we just
# canonicalise (list → JSON-encoded string) and apply the skill
# fallback if the caller didn't supply targets.
notify_targets_raw = body.get("notify_targets", "[]")
if isinstance(notify_targets_raw, list):
notify_targets_raw = json.dumps(notify_targets_raw)
nt_str, _ = _validate_notify_targets(notify_targets_raw)
if nt_str == "[]" and skill_data:
skill_notify = skill_data.get("notify_on_complete", "[]")
if skill_notify and skill_notify != "{}" and skill_notify != "[]":
fallback_str, fallback_err = _validate_notify_targets(skill_notify)
if not fallback_err:
nt_str = fallback_str
ws.notify_targets = nt_str
# Pin locally-created workstreams so the console routes to this node.
requested_ws_id = body.get("ws_id", "") or ""
if not requested_ws_id:
node_id = getattr(request.app.state, "node_id", "")
if node_id:
try:
from turnstone.core.storage import get_storage as _gs
_gs().set_workstream_override(ws.id, node_id, reason="local")
except Exception:
log.debug("Failed to set routing override for %s", ws.id, exc_info=True)
# Initial-message worker thread.
initial_message = body.get("initial_message", "").strip()
if initial_message and ws.session is not None:
from turnstone.core.attachments import (
resolve_staged_attachments as _resolve_staged,
)
session = ws.session
send_id = uuid.uuid4().hex
resolved_atts: list[Any] = []
if attachment_ids:
# Resolve (peek) the staged uploads, then drain them from the buffer
# now. The inlined first turn is their only consumer and it always
# commits at create (no queue rejection by construction), so leaving
# them staged would let the freshly-opened pane's rehydrate race the
# worker's write-time drain and paint them as still-pending composer
# chips. ``_append_user_turn``'s own per-id discard then no-ops.
resolved_atts, _ord, _drop = _resolve_staged(attachment_ids, ws.id, uid)
if _ord:
from turnstone.core.attachment_buffer import get_attachment_buffer
_buf = get_attachment_buffer()
for _aid in _ord:
_buf.discard(_aid, ws_id=ws.id, user_id=uid)
def _run_initial() -> None:
try:
session.send(
initial_message,
attachments=resolved_atts or None,
send_id=send_id if resolved_atts else None,
)
except (Exception, GenerationCancelled):
if isinstance(ws.ui, WebUI):
ws.ui.on_stream_end()
ws.ui.on_state_change("idle")
finally:
try:
last_content = _extract_last_assistant_content(session)
_fire_notify_targets(ws, last_content)
except Exception:
log.warning("notify_completion.hook_error", ws_id=ws.id, exc_info=True)
with ws._lock:
# Only clear the flag if THIS thread is still the current
# worker — a force-cancel abandons this thread and a
# follow-up send may already have spawned a successor; an
# abandoned initial-send worker finishing late must not
# clobber that successor's running flag (mirrors the guard
# in session_worker._runner).
if ws.worker_thread is threading.current_thread():
ws._worker_running = False
# Inlined rather than via ``session_worker.send`` because at
# workstream creation no live worker can exist by
# construction — the enqueue branch of the shared dispatch
# is dead code here. ``_worker_running`` + ``ws.worker_thread``
# are set together under ``ws._lock`` so a path-keyed send
# arriving immediately after creation observes the running
# state via the shared session_worker gate instead of racing
# into a parallel worker.
with ws._lock:
ws._worker_running = True
t = threading.Thread(target=_run_initial, daemon=True, name=f"ws-init-{ws.id[:8]}")
ws.worker_thread = t
t.start()
return {"resumed": resumed, "message_count": message_count}
def _audit_workstream_created(
request: Request,
ws: Workstream,
body: dict[str, Any],
uid: str,
) -> None:
"""Audit emitter for the interactive ``workstream.created`` event.
Wired onto :func:`make_create_handler` as ``audit_emit``. Runs
after the workstream is built and attachments saved; failures
are caught + logged at ``warning`` by the factory without
changing the create handler's successful 200 response.
"""
from turnstone.core.audit import record_audit
_audit_storage = getattr(request.app.state, "auth_storage", None)
if _audit_storage is None:
return
_, _audit_ip = _audit_context(request)
record_audit(
_audit_storage,
uid,
"workstream.created",
"workstream",
ws.id,
{"kind": str(ws.kind), "parent_ws_id": ws.parent_ws_id},
_audit_ip,
)
async def delete_workstream_endpoint(request: Request) -> JSONResponse:
"""POST /v1/api/workstreams/{ws_id}/delete — permanently delete a saved workstream."""
from turnstone.core.audit import record_audit
from turnstone.core.log import get_logger
from turnstone.core.memory import delete_workstream
log = get_logger(__name__)
ws_id = request.path_params.get("ws_id", "")
if not ws_id:
log.warning("ws.delete.failed", reason="empty_ws_id")
return JSONResponse({"error": "ws_id is required"}, status_code=400)
# Cross-tenant delete would destroy another tenant's workstream,
# conversations, and attachments in one call. _require_ws_access
# returns 404 on mismatch so existence isn't enumerable.
owner_uid, err = _require_ws_access(request, ws_id)
if err:
return err
storage = getattr(request.app.state, "auth_storage", None)
kind: str = ""
parent_ws_id: str | None = None
name: str = ""
_, ip = _audit_context(request)
try:
# Snapshot row fields before the delete wipes the row. Inside
# the try so a transient storage error surfaces through the
# endpoint's redacted 500 handler below rather than as an
# unhandled exception. ``kind`` / ``parent_ws_id`` go into the
# audit record; ``name`` is forwarded ONLY to ``mgr.delete`` so
# the ``ws_closed`` event payload carries the same field other
# terminal transitions emit (interactive operators see the name
# in close toasts; coord-side ``child_ws_closed`` ignores it
# but the global queue contract is uniform). ``name`` is
# deliberately NOT in the audit detail — display names can be
# long / operator-noisy and aren't needed for forensic recall
# (ws_id + kind + parent are enough).
if storage is not None:
row = storage.get_workstream(ws_id) or {}
kind = row.get("kind", "")
parent_ws_id = row.get("parent_ws_id")
name = row.get("name", "") or ""
if delete_workstream(ws_id):
log.info("ws.deleted", ws_id=ws_id[:8])
# Fire ``ws_closed`` with ``reason='deleted'`` so the
# cluster collector → coord adapter chain re-emits as
# ``child_ws_closed`` and the operator's child-tree drops
# the row. Without this the row stays visible (with its
# last-known state) until a full reload — a model that
# spawns→completes→deletes children leaves an
# ever-growing tree on the dashboard. Best-effort: an
# emit failure must not roll back the storage delete or
# 500 the response.
mgr = getattr(request.app.state, "workstreams", None)
if mgr is not None:
try:
mgr.delete(ws_id, name=name)
except Exception:
log.warning("ws.delete.event_emit_failed", ws_id=ws_id[:8], exc_info=True)
if storage is not None:
record_audit(
storage,
_auth_user_id(request),
"workstream.deleted",
"workstream",
ws_id,
{"kind": str(kind), "parent_ws_id": parent_ws_id},
ip,
)
return JSONResponse({"deleted": ws_id})
log.warning("ws.delete.failed", reason="not_found", ws_id=ws_id[:8])
return JSONResponse({"error": "Workstream not found"}, status_code=404)
except Exception as e:
log.exception("ws.delete.error", ws_id=ws_id[:8], error=str(e))
return JSONResponse({"error": "Delete failed"}, status_code=500)
def _auth_user_id(request: Request) -> str:
"""Return the authenticated user's id (empty string when absent).
Thin shim over :func:`turnstone.core.web_helpers.auth_user_id` —
kept as a module-level alias so existing call sites don't need a
sweeping rename. The lifted helper is the canonical version
(shared by both kinds since P1.5).
"""
from turnstone.core.web_helpers import auth_user_id
return auth_user_id(request)
def _auth_scopes(request: Request) -> set[str]:
auth = getattr(getattr(request, "state", None), "auth_result", None)
return set(getattr(auth, "scopes", []) or [])
def _effective_user_filter(request: Request) -> str | None | _DenyFilter:
"""Resolve the effective ``user_id`` filter for a tenant-scoped aggregate.
Server-side analog of the console helper of the same name. Node
servers authenticate with JWTs that carry a ``sub`` (the workstream
owner) plus scopes; there is no "admin" role on the node side.
The service scope is the sole cluster-wide bypass (used by the
console routing proxy).
Returns:
- ``None`` — service-scoped caller; no tenant filter.
- ``str`` — end-user caller with a resolved uid; storage helpers
MUST receive ``user_id=<uid>`` and push the filter into SQL.
- :data:`DENY_EMPTY_SUB` — end-user caller whose ``sub`` claim is
blank. Callers MUST short-circuit with their endpoint's
empty-shape response.
Mirrors the class-level tenancy contract on
:class:`~turnstone.core.storage._protocol.StorageBackend`.
"""
if "service" in _auth_scopes(request):
return None
uid = _auth_user_id(request)
if not uid:
return DENY_EMPTY_SUB
return uid
def _require_ws_access(
request: Request,
ws_id: str,
*,
mgr: SessionManager | None = None,
) -> tuple[str, JSONResponse | None]:
"""Resolve ``ws_id`` to its owner, 404-ing when the row doesn't exist.
Thin shim over :func:`turnstone.core.web_helpers.resolve_workstream_owner` —
kept as a module-level alias for the many existing callers.
Both helpers preserve the same trusted-team semantics: any
authenticated caller resolves to the row's recorded owner (with
fallback to caller uid on unowned rows). The lifted version is
the canonical implementation post-P1.5.
"""
from turnstone.core.web_helpers import resolve_workstream_owner
return resolve_workstream_owner(request, ws_id, mgr=mgr, not_found_label="Workstream not found")
async def list_watches(request: Request) -> JSONResponse:
"""GET /v1/api/watches — list active watches, optionally filtered by ws_id."""
from turnstone.core.storage._registry import get_storage
storage = get_storage()
if not storage:
return JSONResponse({"watches": []})
ws_id = request.query_params.get("ws_id")
if ws_id:
watches = storage.list_watches_for_ws(ws_id)
else:
node_id = getattr(request.app.state, "node_id", "")
watches = storage.list_watches_for_node(node_id) if node_id else []
return JSONResponse({"watches": watches})
async def cancel_watch(request: Request) -> JSONResponse:
"""POST /v1/api/watches/{watch_id}/cancel — cancel an active watch."""
from turnstone.core.storage._registry import get_storage
watch_id = request.path_params["watch_id"]
storage = get_storage()
if not storage:
return JSONResponse({"error": "Storage unavailable"}, status_code=500)
watch = storage.get_watch(watch_id)
if not watch:
return JSONResponse({"error": "Watch not found"}, status_code=404)
# Verify node ownership in multi-node deployments
node_id = getattr(request.app.state, "node_id", "")
watch_node = watch.get("node_id", "")
if watch_node and node_id and watch_node != node_id:
return JSONResponse({"error": "Watch belongs to another node"}, status_code=403)
storage.update_watch(watch_id, active=False, next_poll="")
return JSONResponse({"status": "ok", "watch_id": watch_id})
# ---------------------------------------------------------------------------
# Memory endpoints
# ---------------------------------------------------------------------------
_VALID_MEMORY_TYPES = frozenset({"user", "general", "feedback", "reference"})
_VALID_MEMORY_SCOPES = frozenset({"global", "workstream", "user"})
_MAX_MEMORY_CONTENT = 65536 # hard upper bound; server may enforce lower via config
def _validate_scope_scope_id(
scope: str, scope_id: str, *, require_scope_id: bool = False
) -> JSONResponse | None:
"""Validate scope/scope_id consistency. Returns error response or None."""
scope = scope.strip()
scope_id = scope_id.strip()
if scope == "global" and scope_id:
return JSONResponse(
{"error": "scope_id is not allowed with global scope"},
status_code=400,
)
if scope_id and not scope:
return JSONResponse(
{"error": "scope is required when scope_id is provided"},
status_code=400,
)
if require_scope_id and scope in ("workstream", "user") and not scope_id:
return JSONResponse(
{"error": f"scope_id is required for {scope} scope"},
status_code=400,
)
return None
def _resolve_user_scope_id(
request: Request, provided_scope_id: str = ""
) -> tuple[str, JSONResponse | None]:
"""Resolve and validate scope_id for user-scoped memory.
Always binds to the authenticated user's identity. If a scope_id is
provided and doesn't match, returns 403 to prevent cross-user access.
"""
auth = getattr(getattr(request, "state", None), "auth_result", None)
uid: str = getattr(auth, "user_id", "") or ""
if not uid:
return "", JSONResponse(
{"error": "User scope requires authentication with a user identity"},
status_code=400,
)
if provided_scope_id and provided_scope_id != uid:
return "", JSONResponse(
{"error": "Cannot access another user's memories"},
status_code=403,
)
return uid, None
async def list_memories(request: Request) -> JSONResponse:
"""GET /v1/api/memories — list memories with optional filters."""
from turnstone.core.memory import list_structured_memories
mem_type = request.query_params.get("type", "")
scope = request.query_params.get("scope", "")
scope_id = request.query_params.get("scope_id", "")
try:
limit = min(int(request.query_params.get("limit", "100")), 200)
except (ValueError, TypeError):
return JSONResponse({"error": "limit must be an integer"}, status_code=400)
err = _validate_scope_scope_id(scope, scope_id)
if err:
return err
if scope == "user":
scope_id, err = _resolve_user_scope_id(request, scope_id)
if err:
return err
rows = list_structured_memories(mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit)
return JSONResponse({"memories": rows, "total": len(rows)})
async def save_memory(request: Request) -> JSONResponse:
"""POST /v1/api/memories — save (upsert) a structured memory."""
from turnstone.core.memory import save_structured_memory
from turnstone.core.web_helpers import read_json_or_400
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
name = str(body.get("name", "")).strip()
content = str(body.get("content", "")).strip()
if not name or len(name) > 256:
return JSONResponse({"error": "name is required (max 256 characters)"}, status_code=400)
if not content:
return JSONResponse({"error": "content is required"}, status_code=400)
if len(content) > _MAX_MEMORY_CONTENT:
return JSONResponse(
{"error": f"content exceeds {_MAX_MEMORY_CONTENT} character limit"},
status_code=400,
)
# None (field omitted) means "leave unset": the upsert keeps the stored
# value on update and defaults on insert; an explicit value overwrites.
raw_desc = body.get("description")
description = None if raw_desc is None else str(raw_desc)
raw_type = body.get("type")
mem_type = None if raw_type is None else str(raw_type)
scope = str(body.get("scope", "global"))
scope_id = str(body.get("scope_id", ""))
if mem_type is not None and mem_type not in _VALID_MEMORY_TYPES:
return JSONResponse(
{"error": f"invalid type: {mem_type}; must be one of {sorted(_VALID_MEMORY_TYPES)}"},
status_code=400,
)
if scope not in _VALID_MEMORY_SCOPES:
return JSONResponse(
{"error": f"invalid scope: {scope}; must be one of {sorted(_VALID_MEMORY_SCOPES)}"},
status_code=400,
)
if scope == "user":
scope_id, err = _resolve_user_scope_id(request, scope_id)
if err:
return err
err = _validate_scope_scope_id(scope, scope_id, require_scope_id=True)
if err:
return err
# The upsert RETURNINGs the full saved row, so no follow-up read is needed.
row, was_update = save_structured_memory(
name, content, description=description, mem_type=mem_type, scope=scope, scope_id=scope_id
)
if not row:
return JSONResponse({"error": "Failed to save memory"}, status_code=500)
return JSONResponse(row, status_code=200 if was_update else 201)
async def search_memories(request: Request) -> JSONResponse:
"""POST /v1/api/memories/search — search memories by query.
Uses POST for the request body but requires only read scope (non-mutating).
"""
from turnstone.core.memory import search_structured_memories as search_fn
from turnstone.core.web_helpers import read_json_or_400
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
query = str(body.get("query", "")).strip()
if not query:
return JSONResponse({"error": "query is required"}, status_code=400)
mem_type = str(body.get("type", ""))
scope = str(body.get("scope", ""))
scope_id = str(body.get("scope_id", ""))
try:
limit = min(int(body.get("limit", 20)), 50)
except (ValueError, TypeError):
return JSONResponse({"error": "limit must be an integer"}, status_code=400)
err = _validate_scope_scope_id(scope, scope_id)
if err:
return err
if scope == "user":
scope_id, err = _resolve_user_scope_id(request, scope_id)
if err:
return err
rows = search_fn(query, mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit)
return JSONResponse({"memories": rows, "total": len(rows)})
async def delete_memory_endpoint(request: Request) -> JSONResponse:
"""DELETE /v1/api/memories/{name} — delete a memory by name and scope."""
from turnstone.core.memory import delete_structured_memory, normalize_key
name = normalize_key(request.path_params["name"])
scope = request.query_params.get("scope", "global")
if scope not in _VALID_MEMORY_SCOPES:
return JSONResponse(
{"error": f"invalid scope: {scope}; must be one of {sorted(_VALID_MEMORY_SCOPES)}"},
status_code=400,
)
scope_id = request.query_params.get("scope_id", "")
if scope == "user":
scope_id, err = _resolve_user_scope_id(request, scope_id)
if err:
return err
err = _validate_scope_scope_id(scope, scope_id, require_scope_id=True)
if err:
return err
if delete_structured_memory(name, scope, scope_id):
return JSONResponse({"status": "ok", "name": name})
return JSONResponse({"error": f"Memory '{name}' not found"}, status_code=404)
# ---------------------------------------------------------------------------
# Project endpoints — shared resource containers (migration 062)
# ---------------------------------------------------------------------------
#
# User-facing CRUD over projects. Every handler gates first on the RBAC
# capability (``project.{create,read,write,delete}`` — admin-default) and then,
# for a specific project, on the per-project ACL via
# ``auth.user_can_access_project`` (or ownership for destructive / membership
# ops). Registered by both the standalone server and the console — projects
# are global / shared-DB, so the handlers are node-agnostic.
def _project_view(row: dict[str, Any]) -> dict[str, Any]:
"""Public-facing projection of a ``projects`` row."""
return {
"project_id": row.get("project_id", ""),
"name": row.get("name", ""),
"owner_id": row.get("owner_id", ""),
"visibility": row.get("visibility", "private"),
"state": row.get("state", "active"),
"created": row.get("created", ""),
"updated": row.get("updated", ""),
}
def _project_request_uid(request: Request) -> tuple[str, JSONResponse | None]:
"""Return the authenticated user_id, or a 401 JSONResponse."""
auth = getattr(getattr(request, "state", None), "auth_result", None)
uid: str = getattr(auth, "user_id", "") or ""
if not uid:
return "", JSONResponse({"error": "authentication required"}, status_code=401)
return uid, None
async def list_projects(request: Request) -> JSONResponse:
"""GET /v1/api/projects — projects the caller owns, is a member of, or public."""
from turnstone.core.auth import require_permission
from turnstone.core.storage import get_storage
err = require_permission(request, "project.read")
if err:
return err
uid, uerr = _project_request_uid(request)
if uerr:
return uerr
include_archived = request.query_params.get("include_archived", "").lower() in (
"1",
"true",
"yes",
)
storage = get_storage()
rows = storage.list_projects_for_user(uid, include_archived=include_archived) if storage else []
return JSONResponse({"projects": [_project_view(r) for r in rows], "total": len(rows)})
async def create_project(request: Request) -> JSONResponse:
"""POST /v1/api/projects — create a project owned by the caller."""
import uuid
from turnstone.core.auth import require_permission
from turnstone.core.storage import get_storage
from turnstone.core.web_helpers import read_json_or_400
err = require_permission(request, "project.create")
if err:
return err
uid, uerr = _project_request_uid(request)
if uerr:
return uerr
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
name = str(body.get("name", "")).strip()
if not name or len(name) > 200:
return JSONResponse({"error": "name is required (max 200 characters)"}, status_code=400)
visibility = str(body.get("visibility", "private")).strip().lower()
if visibility not in ("private", "public"):
return JSONResponse({"error": "visibility must be 'private' or 'public'"}, status_code=400)
storage = get_storage()
if storage is None:
return JSONResponse({"error": "storage unavailable"}, status_code=503)
project_id = uuid.uuid4().hex
storage.create_project(project_id, name, uid, visibility=visibility)
return JSONResponse(_project_view(storage.get_project(project_id) or {}), status_code=201)
async def get_project_endpoint(request: Request) -> JSONResponse:
"""GET /v1/api/projects/{project_id} — one project the caller can read."""
from turnstone.core.auth import require_permission, user_can_access_project
from turnstone.core.storage import get_storage
err = require_permission(request, "project.read")
if err:
return err
uid, uerr = _project_request_uid(request)
if uerr:
return uerr
project_id = request.path_params["project_id"]
storage = get_storage()
row = storage.get_project(project_id) if storage else None
if row is None:
return JSONResponse({"error": "project not found"}, status_code=404)
if not user_can_access_project(uid, project_id, write=False, storage=storage):
return JSONResponse({"error": "forbidden"}, status_code=403)
return JSONResponse(_project_view(row))
async def project_resources_endpoint(request: Request) -> JSONResponse:
"""GET /v1/api/projects/{project_id}/resources — the project's contents.
Workstreams, referenced attachments (metadata + a ws_id to build the
ws-scoped download URL against), and the project-scoped memory count —
the aggregate view behind the manage → governance → Projects shelf.
Same access gate as ``get_project_endpoint``: ``project.read`` plus the
per-project ACL.
"""
from turnstone.core.auth import require_permission, user_can_access_project
from turnstone.core.storage import get_storage
err = require_permission(request, "project.read")
if err:
return err
uid, uerr = _project_request_uid(request)
if uerr:
return uerr
project_id = request.path_params["project_id"]
storage = get_storage()
row = storage.get_project(project_id) if storage else None
if row is None:
return JSONResponse({"error": "project not found"}, status_code=404)
if not user_can_access_project(uid, project_id, write=False, storage=storage):
return JSONResponse({"error": "forbidden"}, status_code=403)
def _collect() -> dict[str, Any]:
# The attachment scan walks every conversation row in the
# project's workstreams — keep the whole collection off the
# event loop.
return {
"project_id": project_id,
"name": row.get("name", ""),
"workstreams": storage.list_workstreams_for_project(project_id),
"attachments": storage.list_project_attachments(project_id),
"memory_count": storage.count_structured_memories(scope="project", scope_id=project_id),
}
return JSONResponse(await asyncio.to_thread(_collect))
async def update_project_endpoint(request: Request) -> JSONResponse:
"""PATCH /v1/api/projects/{project_id} — rename / re-visibility / archive."""
from turnstone.core.auth import require_permission, user_can_access_project
from turnstone.core.storage import get_storage
from turnstone.core.web_helpers import read_json_or_400
err = require_permission(request, "project.write")
if err:
return err
uid, uerr = _project_request_uid(request)
if uerr:
return uerr
project_id = request.path_params["project_id"]
storage = get_storage()
row = storage.get_project(project_id) if storage else None
if storage is None or row is None:
return JSONResponse({"error": "project not found"}, status_code=404)
if not user_can_access_project(uid, project_id, write=True, storage=storage):
return JSONResponse({"error": "forbidden"}, status_code=403)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
fields: dict[str, Any] = {}
if "name" in body:
name = str(body.get("name", "")).strip()
if not name or len(name) > 200:
return JSONResponse({"error": "invalid name"}, status_code=400)
fields["name"] = name
if "visibility" in body:
vis = str(body.get("visibility", "")).strip().lower()
if vis not in ("private", "public"):
return JSONResponse({"error": "invalid visibility"}, status_code=400)
# Visibility is a confidentiality lever — ``public`` opens read to every
# project.read holder — so it's owner-only, like delete + member
# management. Write-tier members may still rename / archive.
if row.get("owner_id") != uid:
return JSONResponse(
{"error": "only the project owner may change visibility"},
status_code=403,
)
fields["visibility"] = vis
if "state" in body:
state = str(body.get("state", "")).strip().lower()
if state not in ("active", "archived"):
return JSONResponse({"error": "invalid state"}, status_code=400)
fields["state"] = state
if not fields:
return JSONResponse({"error": "no updatable fields provided"}, status_code=400)
storage.update_project(project_id, **fields)
return JSONResponse(_project_view(storage.get_project(project_id) or {}))
async def delete_project_endpoint(request: Request) -> JSONResponse:
"""DELETE /v1/api/projects/{project_id} — owner-only; destroys the container."""
from turnstone.core.auth import require_permission
from turnstone.core.storage import get_storage
err = require_permission(request, "project.delete")
if err:
return err
uid, uerr = _project_request_uid(request)
if uerr:
return uerr
project_id = request.path_params["project_id"]
storage = get_storage()
row = storage.get_project(project_id) if storage else None
if row is None:
return JSONResponse({"error": "project not found"}, status_code=404)
# Deletion is owner-only — it destroys the container and its scoped memory.
if row.get("owner_id") != uid:
return JSONResponse({"error": "only the project owner may delete it"}, status_code=403)
storage.delete_project(project_id)
return JSONResponse({"status": "ok", "project_id": project_id})
async def list_project_members_endpoint(request: Request) -> JSONResponse:
"""GET /v1/api/projects/{project_id}/members — member user_ids."""
from turnstone.core.auth import require_permission, user_can_access_project
from turnstone.core.storage import get_storage
err = require_permission(request, "project.read")
if err:
return err
uid, uerr = _project_request_uid(request)
if uerr:
return uerr
project_id = request.path_params["project_id"]
storage = get_storage()
if storage is None or storage.get_project(project_id) is None:
return JSONResponse({"error": "project not found"}, status_code=404)
if not user_can_access_project(uid, project_id, write=False, storage=storage):
return JSONResponse({"error": "forbidden"}, status_code=403)
return JSONResponse({"members": storage.list_project_members(project_id)})
async def add_project_member_endpoint(request: Request) -> JSONResponse:
"""POST /v1/api/projects/{project_id}/members — owner adds a member."""
from turnstone.core.auth import require_permission
from turnstone.core.storage import get_storage
from turnstone.core.web_helpers import read_json_or_400
err = require_permission(request, "project.write")
if err:
return err
uid, uerr = _project_request_uid(request)
if uerr:
return uerr
project_id = request.path_params["project_id"]
storage = get_storage()
row = storage.get_project(project_id) if storage else None
if row is None:
return JSONResponse({"error": "project not found"}, status_code=404)
if row.get("owner_id") != uid:
return JSONResponse({"error": "only the project owner may manage members"}, status_code=403)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
member = str(body.get("user_id", "")).strip()
if not member:
return JSONResponse({"error": "user_id is required"}, status_code=400)
storage.add_project_member(project_id, member)
return JSONResponse({"status": "ok", "members": storage.list_project_members(project_id)})
async def remove_project_member_endpoint(request: Request) -> JSONResponse:
"""DELETE /v1/api/projects/{project_id}/members/{user_id} — owner removes a member."""
from turnstone.core.auth import require_permission
from turnstone.core.storage import get_storage
err = require_permission(request, "project.write")
if err:
return err
uid, uerr = _project_request_uid(request)
if uerr:
return uerr
project_id = request.path_params["project_id"]
member = request.path_params["user_id"]
storage = get_storage()
row = storage.get_project(project_id) if storage else None
if row is None:
return JSONResponse({"error": "project not found"}, status_code=404)
if row.get("owner_id") != uid:
return JSONResponse({"error": "only the project owner may manage members"}, status_code=403)
storage.remove_project_member(project_id, member)
return JSONResponse({"status": "ok", "members": storage.list_project_members(project_id)})
async def auth_login(request: Request) -> Response:
"""POST /v1/api/auth/login — authenticate and return JWT."""
from turnstone.core.auth import handle_auth_login
return await handle_auth_login(request, JWT_AUD_SERVER, cookie_name=AUTH_COOKIE_SERVER)
async def auth_logout(request: Request) -> Response:
"""POST /v1/api/auth/logout — clear auth cookie."""
from turnstone.core.auth import handle_auth_logout
return await handle_auth_logout(request, cookie_name=AUTH_COOKIE_SERVER)
async def auth_status(request: Request) -> Response:
"""GET /v1/api/auth/status — public endpoint for login UI state detection."""
from turnstone.core.auth import handle_auth_status
return await handle_auth_status(request)
async def auth_setup(request: Request) -> Response:
"""POST /v1/api/auth/setup — create first admin user (public, one-time only)."""
from turnstone.core.auth import handle_auth_setup
return await handle_auth_setup(request, JWT_AUD_SERVER, cookie_name=AUTH_COOKIE_SERVER)
async def auth_whoami(request: Request) -> Response:
"""GET /v1/api/auth/whoami — return authenticated user info."""
from turnstone.core.auth import handle_auth_whoami
return await handle_auth_whoami(request, cookie_name=AUTH_COOKIE_SERVER)
async def auth_refresh(request: Request) -> Response:
"""POST /v1/api/auth/refresh — extend the auth cookie's expiry.
Requires a currently-valid cookie (auth middleware enforces). Re-
resolves user permissions from storage so role changes propagate.
"""
from turnstone.core.auth import handle_auth_refresh
return await handle_auth_refresh(request, JWT_AUD_SERVER, cookie_name=AUTH_COOKIE_SERVER)
async def oidc_authorize(request: Request) -> Response:
"""GET /v1/api/auth/oidc/authorize — redirect to OIDC provider."""
from turnstone.core.auth import handle_oidc_authorize
return await handle_oidc_authorize(request, JWT_AUD_SERVER)
async def oidc_callback(request: Request) -> Response:
"""GET /v1/api/auth/oidc/callback — OIDC callback, exchange code for JWT."""
from turnstone.core.auth import handle_oidc_callback
return await handle_oidc_callback(request, JWT_AUD_SERVER, cookie_name=AUTH_COOKIE_SERVER)
async def mcp_oauth_authorize(request: Request) -> Response:
"""GET /v1/api/mcp/oauth/start — begin per-(user, server) OAuth flow."""
from turnstone.core.mcp_oauth import handle_mcp_oauth_authorize
return await handle_mcp_oauth_authorize(request)
async def mcp_oauth_callback(request: Request) -> Response:
"""GET /v1/api/mcp/oauth/callback — AS-redirected OAuth callback."""
from turnstone.core.mcp_oauth import handle_mcp_oauth_callback
return await handle_mcp_oauth_callback(request)
async def mcp_oauth_list_connections(request: Request) -> Response:
"""GET /v1/api/mcp/oauth/connections — list this user's MCP server consents."""
from turnstone.core.mcp_oauth import handle_mcp_oauth_list_connections
return await handle_mcp_oauth_list_connections(request)
async def mcp_oauth_revoke_connection(request: Request) -> Response:
"""DELETE /v1/api/mcp/oauth/connections/{server_name} — revoke a consent."""
from turnstone.core.mcp_oauth import handle_mcp_oauth_revoke_connection
return await handle_mcp_oauth_revoke_connection(request)
async def mcp_oauth_list_pending(request: Request) -> Response:
"""GET /v1/api/mcp/oauth/pending — list deferred-consent records (Phase 9)."""
from turnstone.core.mcp_oauth import handle_mcp_oauth_list_pending
return await handle_mcp_oauth_list_pending(request)
async def mcp_oauth_clear_pending(request: Request) -> Response:
"""DELETE /v1/api/mcp/oauth/pending/{server_name} — dismiss a deferred-consent record."""
from turnstone.core.mcp_oauth import handle_mcp_oauth_clear_pending
return await handle_mcp_oauth_clear_pending(request)
async def mcp_oauth_clear_all_pending(request: Request) -> Response:
"""DELETE /v1/api/mcp/oauth/pending — bulk-dismiss deferred-consent records."""
from turnstone.core.mcp_oauth import handle_mcp_oauth_clear_all_pending
return await handle_mcp_oauth_clear_all_pending(request)
def list_interface_settings(request: Request) -> JSONResponse:
"""GET /v1/api/admin/settings — return interface settings from ConfigStore.
This lightweight endpoint mirrors the console's admin settings endpoint
so that the main UI can load interface preferences (theme, close_tab_action)
when accessed directly or through the console proxy. Only returns the
``interface.*`` settings — full admin management is on the console.
"""
from turnstone.core.settings_registry import SETTINGS
cs = getattr(request.app.state, "config_store", None)
settings: list[dict[str, Any]] = []
for key, defn in sorted(SETTINGS.items()):
if not key.startswith("interface."):
continue
value = cs.get(key) if cs else defn.default
settings.append(
{
"key": key,
"value": value,
"source": "storage" if cs and key in cs.stored_keys() else "default",
"type": defn.type,
"description": defn.description,
"section": defn.section,
}
)
return JSONResponse({"settings": settings})
async def update_interface_setting(request: Request, key: str = "") -> JSONResponse:
"""POST /v1/api/admin/settings/{key} — update an interface.* setting.
Lightweight endpoint so the main UI (served via the console proxy) can
persist interface preferences without needing a PUT route. Only
``interface.*`` keys are accepted; full admin management stays on the
console.
Writes with ``node_id=""`` (global scope) so the console admin page
and all nodes see the same value.
"""
from turnstone.core.log import get_logger
from turnstone.core.settings_registry import SETTINGS, serialize_value, validate_value
from turnstone.core.storage import get_storage as _get_storage
from turnstone.core.web_helpers import read_json_or_400
log = get_logger(__name__)
key = request.path_params.get("key", "")
if not key.startswith("interface."):
return JSONResponse({"error": "only interface.* settings accepted"}, status_code=400)
if key not in SETTINGS:
return JSONResponse({"error": f"unknown setting: {key}"}, status_code=400)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
if "value" not in body:
return JSONResponse({"error": "value is required"}, status_code=400)
try:
typed_value = validate_value(key, body["value"])
except (ValueError, KeyError) as e:
return JSONResponse({"error": str(e)}, status_code=400)
# Write to storage with global scope (node_id="") so the console
# admin page and all nodes read the same value.
storage = _get_storage()
if storage is None:
return JSONResponse({"error": "storage unavailable"}, status_code=503)
defn = SETTINGS[key]
storage.upsert_system_setting(
key=key,
value=serialize_value(typed_value),
node_id="",
is_secret=defn.is_secret,
)
# Update the local ConfigStore cache so this node sees the change
# immediately (without waiting for a config-reload).
cs = getattr(request.app.state, "config_store", None)
if cs is not None:
cs.reload()
log.info("interface_setting.updated", key=key, value=typed_value)
# Broadcast settings_changed so other connected clients pick it up
gq = getattr(request.app.state, "global_queue", None)
if gq is not None:
with contextlib.suppress(queue.Full):
gq.put_nowait({"type": "settings_changed"})
return JSONResponse({"status": "ok", "key": key, "value": typed_value})
def config_reload(request: Request) -> JSONResponse:
"""POST /v1/api/_internal/config-reload — invalidate config cache."""
cs = getattr(request.app.state, "config_store", None)
gq = getattr(request.app.state, "global_queue", None)
if not cs:
return JSONResponse({"status": "noop"})
cs.reload()
# Apply routing overrides to the live registry — admin settings updates
# fan out via this endpoint and would otherwise not affect task
# routing until a model-reload or restart.
registry = getattr(request.app.state, "registry", None)
if registry is not None:
_apply_routing_overrides(registry, cs)
# Broadcast settings_changed event to all connected clients
if gq is not None:
with contextlib.suppress(queue.Full):
gq.put_nowait({"type": "settings_changed"})
return JSONResponse({"status": "ok"})
# -- internal MCP management -----------------------------------------------
def internal_mcp_reload(request: Request) -> JSONResponse:
"""POST /v1/api/_internal/mcp-reload — re-read mcp_servers table and reconcile."""
from turnstone.core.storage._registry import get_storage
storage = get_storage()
mcp_mgr = getattr(request.app.state, "mcp_client", None)
if mcp_mgr is None:
# Create a new manager if none exists
from turnstone.core.mcp_client import MCPClientManager
mcp_mgr = MCPClientManager({})
mcp_mgr.start()
mcp_mgr.set_storage(storage)
mcp_mgr.set_app_state(request.app.state)
request.app.state.mcp_client = mcp_mgr
# Update shared ref so session_factory sees the new client
mcp_ref = getattr(request.app.state, "mcp_ref", None)
if mcp_ref is not None:
mcp_ref[0] = mcp_mgr
result = mcp_mgr.reconcile_sync(storage)
return JSONResponse({"status": "ok", **result})
_SERVER_STATUS_PUBLIC_KEYS: tuple[str, ...] = (
"connected",
"tools",
"resources",
"prompts",
"error",
"transport",
"circuit_open",
"consecutive_failures",
)
_READ_STATUS_PUBLIC_KEYS: tuple[str, ...] = tuple(
k for k in _SERVER_STATUS_PUBLIC_KEYS if k != "error"
)
def _strip_server_status(full: dict[str, Any]) -> dict[str, Any]:
"""Project a status dict to the approve-scope public-safe key set.
The full status dict embeds ``command`` (stdio argv) and ``url``
(remote MCP endpoint) which are admin-only context. Approve-scoped
callers (refresh/reconnect) get the verbose ``error`` text so an
operator triaging a failure sees the underlying exception.
Read-scope callers must use :func:`_strip_server_status_for_read`
instead — error strings can carry stdio binary paths
(``FileNotFoundError: ... '/usr/local/bin/...'``) or internal MCP
URLs (``httpx.ConnectError: ... 'https://internal/...'``) and
those are equivalent to leaking ``command``/``url``.
"""
return {k: full[k] for k in _SERVER_STATUS_PUBLIC_KEYS if k in full}
def _strip_server_status_for_read(full: dict[str, Any]) -> dict[str, Any]:
"""Project a status dict for read-scope callers.
Drops the verbose ``error`` text and replaces it with a coarse
``has_error: bool`` so dashboards can light up a failure indicator
without leaking the underlying exception detail.
"""
out = {k: full[k] for k in _READ_STATUS_PUBLIC_KEYS if k in full}
out["has_error"] = bool(full.get("error"))
return out
def _public_server_status(mcp_mgr: Any, name: str) -> dict[str, Any]:
"""Strip ``command``/``url`` from ``get_server_status`` before returning over the wire."""
# aggregate=True: the operator refresh/reconnect endpoints are approve-scoped
# cluster actions with no single requesting user, so oauth_user servers report
# the any-user warm-pool view (matching the admin console) rather than the
# per-user default — which, with user_id=None, would render a warm, in-use
# server as disconnected/empty right after a successful refresh.
return _strip_server_status(mcp_mgr.get_server_status(name, aggregate=True))
def internal_mcp_status(request: Request) -> JSONResponse:
"""GET /v1/api/_internal/mcp-status — return MCP server status.
Read-scoped. The full set of configured MCP server names is
enumerated to any caller with ``read`` scope: that is the
intentional trust boundary so dashboards can render per-server
indicators without admin scope. Verbose ``error`` text is dropped
in favour of ``has_error`` (see :func:`_strip_server_status_for_read`)
because error strings can carry stdio binary paths or internal
MCP URLs. Per-server error detail and ``command``/``url`` remain
on the approve-scoped ``mcp-refresh``/``mcp-reconnect`` endpoints.
"""
mcp_mgr = getattr(request.app.state, "mcp_client", None)
if mcp_mgr is None:
return JSONResponse({"servers": {}})
# Scope oauth_user server status to the requesting user — their per-user pool
# catalog (and its existence) must not leak to other read-scoped callers.
# _auth_user_id returns "" when unauthenticated, which the manager treats as
# "no user context" (oauth_user servers then report not-connected). Callers
# holding admin.mcp (the console cluster-health view, whose proxy forwards
# the admin's permissions) get the cross-user aggregate instead — they are
# already trusted to see consent counts + server config, so it is no new
# disclosure and it keeps the operator "in use by anyone" pill working.
auth_result = getattr(getattr(request, "state", None), "auth_result", None)
is_admin = auth_result is not None and auth_result.has_permission("admin.mcp")
all_status = mcp_mgr.get_all_server_status(_auth_user_id(request), aggregate=is_admin)
return JSONResponse(
{
"servers": {
name: _strip_server_status_for_read(status) for name, status in all_status.items()
}
}
)
def internal_mcp_refresh_one(request: Request) -> JSONResponse:
"""POST /v1/api/_internal/mcp-refresh/{name} — refresh a single MCP server's catalog."""
name = request.path_params["name"]
if "__" in name:
return JSONResponse({"status": "error", "error": "invalid name"}, status_code=400)
mcp_mgr = getattr(request.app.state, "mcp_client", None)
if mcp_mgr is None:
return JSONResponse({"status": "error", "error": "MCP client not running"}, status_code=503)
try:
mcp_mgr.refresh_sync(server_name=name)
except Exception as exc:
log.warning("internal_mcp_refresh_one failed for %s: %s", name, exc)
return JSONResponse({"status": "error", "error": "refresh failed"}, status_code=500)
# _refresh_all swallows per-server errors into _last_error rather than
# raising, so a 200-OK from refresh_sync isn't enough — re-check status
# and surface 500 if the refresh actually failed for this server.
status = _public_server_status(mcp_mgr, name)
if status.get("error"):
log.warning(
"internal_mcp_refresh_one: refresh reported error for %s: %s", name, status["error"]
)
return JSONResponse(
{"status": "error", "error": "refresh failed", "server": status},
status_code=500,
)
return JSONResponse({"status": "ok", "server": status})
def internal_mcp_reconnect_one(request: Request) -> JSONResponse:
"""POST /v1/api/_internal/mcp-reconnect/{name} — force-reconnect a single MCP server."""
name = request.path_params["name"]
if "__" in name:
return JSONResponse({"status": "error", "error": "invalid name"}, status_code=400)
mcp_mgr = getattr(request.app.state, "mcp_client", None)
if mcp_mgr is None:
return JSONResponse({"status": "error", "error": "MCP client not running"}, status_code=503)
try:
result = mcp_mgr.reconnect_sync(name)
except Exception as exc:
log.warning("internal_mcp_reconnect_one failed for %s: %s", name, exc)
return JSONResponse({"status": "error", "error": "reconnect failed"}, status_code=500)
if result.get("error"):
log.warning(
"internal_mcp_reconnect_one: reconnect reported error for %s: %s",
name,
result.get("error", ""),
)
return JSONResponse(
{
"status": "error",
"error": "reconnect failed",
"server": _public_server_status(mcp_mgr, name),
},
status_code=500,
)
return JSONResponse({"status": "ok", "server": _public_server_status(mcp_mgr, name)})
# -- internal model management -----------------------------------------------
def _effective_routing(
cs: Any,
base_models: dict[str, Any],
base_default: str,
base_task_model: str | None,
base_task_effort: str | None,
) -> tuple[str, str | None, str | None]:
"""Compute (default, task_model, task_effort) after layering ConfigStore
overrides on top of the supplied base values.
Aliases require existence in *base_models* (silently dropped otherwise);
effort values were validated against SettingDef choices on write, so a
truthiness check is sufficient at apply time.
Returns the base values unchanged when *cs* is None.
"""
eff_default = base_default
eff_task_model = base_task_model
eff_task_effort = base_task_effort
if cs is not None:
cs_default = cs.get("model.default_alias")
if cs_default and cs_default in base_models:
eff_default = cs_default
cs_task_alias = cs.get("model.task_alias")
if cs_task_alias and cs_task_alias in base_models:
eff_task_model = cs_task_alias
cs_task_effort = cs.get("model.task_effort")
if cs_task_effort:
eff_task_effort = cs_task_effort
return eff_default, eff_task_model, eff_task_effort
def _broadcast_agent_tool_schema_refresh(app_state: Any) -> None:
"""Tell every active session on this node to re-render its task_agent
tool description. Best-effort: a session that lacks the
method (older code path or test stub) is skipped silently.
Called after a registry reload that may have added/removed model
aliases, so the calling LLMs see an updated `model` parameter
description on their next turn.
"""
mgr = getattr(app_state, "workstreams", None)
if mgr is None:
return
try:
workstreams = mgr.list_all()
except Exception:
return
for ws in workstreams:
session = getattr(ws, "session", None)
refresh = getattr(session, "refresh_agent_tool_schemas", None)
if refresh is None:
continue
with contextlib.suppress(Exception):
refresh()
def _apply_routing_overrides(registry: Any, cs: Any) -> bool:
"""Apply ConfigStore routing overrides to a live *registry* in place.
Used by the startup path and by ``config_reload`` (admin settings
update fan-out) — both keep the existing model definitions and only
rewrite routing fields. Returns True when a reload happened.
"""
if not registry.models:
# Degraded (empty) registry — no aliases to route to yet. Routing
# overrides take effect once models are added (internal_model_reload).
return False
eff = _effective_routing(
cs,
registry.models,
registry.default,
registry.task_model,
registry.task_effort,
)
if (
eff[0] != registry.default
or eff[1] != registry.task_model
or eff[2] != registry.task_effort
):
registry.reload(
registry.models,
eff[0],
registry.fallback,
registry.agent_model,
task_model=eff[1],
task_effort=eff[2],
)
return True
return False
def internal_model_reload(request: Request) -> JSONResponse:
"""POST /v1/api/_internal/model-reload — rebuild registry from DB + config."""
from turnstone.core.model_registry import load_model_registry
from turnstone.core.storage._registry import get_storage
registry = getattr(request.app.state, "registry", None)
cli_args = getattr(request.app.state, "cli_model_args", None)
if registry is None or cli_args is None:
return JSONResponse({"status": "error", "reason": "no registry"}, status_code=503)
storage = get_storage()
new_registry = load_model_registry(
base_url=cli_args["base_url"],
api_key=cli_args["api_key"],
model=cli_args["model"],
context_window=cli_args["context_window"],
provider=cli_args["provider"],
storage=storage,
)
cs = getattr(request.app.state, "config_store", None)
if cs is not None:
cs.reload() # Ensure latest settings from DB
eff_default, eff_task_model, eff_task_effort = _effective_routing(
cs,
new_registry.models,
new_registry.default,
new_registry.task_model,
new_registry.task_effort,
)
if eff_default != new_registry.default:
log.info(
"ConfigStore override: using '%s' as default model (registry had '%s')",
eff_default,
new_registry.default,
)
# No-op fast path: skip reload when nothing changed (avoids client churn
# on broadcast model-reloads where this node has no pending changes).
unchanged = (
new_registry.models == registry.models
and new_registry.fallback == registry.fallback
and new_registry.agent_model == registry.agent_model
and eff_default == registry.default
and eff_task_model == registry.task_model
and eff_task_effort == registry.task_effort
)
if unchanged:
new_registry.shutdown()
return JSONResponse({"status": "ok", "aliases": registry.list_aliases(), "noop": True})
try:
registry.reload(
new_registry.models,
eff_default,
new_registry.fallback,
new_registry.agent_model,
task_model=eff_task_model,
task_effort=eff_task_effort,
)
except ValueError as exc:
return JSONResponse({"status": "error", "reason": str(exc)}, status_code=422)
finally:
new_registry.shutdown()
# Ensure health trackers exist for any newly-added backends
health_reg = getattr(request.app.state, "health_registry", None)
if health_reg:
for alias in registry.list_aliases():
cfg = registry.get_config(alias)
health_reg.get_tracker(provider=cfg.provider, base_url=cfg.base_url)
# Push the new alias list into active sessions so the task_agent
# `model` parameter description reflects the current registry.
_broadcast_agent_tool_schema_refresh(request.app.state)
# Refresh the per-node ``models`` metadata entry the coord reads on
# ``list_nodes``. Without this, the heartbeat loop's 30s tick would
# be the coord's first chance to see new aliases an admin just added.
node_id = getattr(request.app.state, "node_id", "")
if node_id:
_publish_models_metadata(request.app.state, storage, node_id)
return JSONResponse({"status": "ok", "aliases": registry.list_aliases()})
def internal_model_status(request: Request) -> JSONResponse:
"""GET /v1/api/_internal/model-status — return this node's model aliases."""
registry = getattr(request.app.state, "registry", None)
if registry is None:
return JSONResponse({"models": {}})
models: dict[str, dict[str, Any]] = {}
for alias in registry.list_aliases():
cfg = registry.get_config(alias)
models[alias] = {
"model": cfg.model,
"provider": cfg.provider,
"source": cfg.source,
"context_window": cfg.context_window,
"enabled": True,
"temperature": cfg.temperature,
"max_tokens": cfg.max_tokens,
"reasoning_effort": cfg.reasoning_effort,
}
return JSONResponse({"models": models})
def _collect_node_models_metadata(app_state: Any) -> tuple[str, str, str] | None:
"""Build the ``("models", json_value, "auto")`` node_metadata entry.
Each model alias on the live registry is projected to
``{alias, provider, healthy}`` — the alias is what the coordinator
passes back as ``spawn_workstream(model=...)``, ``provider`` lets
coordinators classify or filter (e.g. "any anthropic node"), and
``healthy`` reflects the backend's :class:`BackendHealthTracker`
state at call time. The underlying model identifier (``cfg.model``)
is intentionally omitted — coordinators kept reaching for the
provider-side string when they should have been passing the local
alias, and dropping it removes the footgun. Operators who need
the model string can hit ``/v1/api/_internal/model-status`` on the
node directly.
Trackers are eagerly seeded for every alias at server startup and
on every model-reload, so ``health_reg.get_tracker(...)`` returns
the existing tracker rather than minting a fresh one in steady
state. In the unlikely race where a tracker hasn't been seeded
yet, the freshly created tracker reports ``is_healthy=True``
(default state) — which matches the prior "default to True when
no tracker" behavior, just routed through the tracker object.
Returns ``None`` when the registry has not yet been built (caller
should skip the write rather than zero out a previous snapshot).
"""
registry = getattr(app_state, "registry", None)
if registry is None:
return None
health_reg = getattr(app_state, "health_registry", None)
aliases_info: list[dict[str, Any]] = []
# Iterate aliases in a stable order — ``list_aliases`` returns dict
# insertion order, so two structurally identical registries built
# from different sources (config.toml vs. DB rows in different
# commit order) would otherwise serialize to different JSON and
# defeat the publish-cache hit-rate that the
# ``turnstone_node_models_publish_total`` metric tracks.
for alias in sorted(registry.list_aliases()):
try:
cfg = registry.get_config(alias)
except (ValueError, KeyError):
continue
healthy = True
if health_reg is not None:
# Direct keyed lookup — ``get_tracker_for_alias`` would
# do a second ``registry.get_config(alias)`` internally,
# but ``cfg`` is already in hand here.
tracker = health_reg.get_tracker(provider=cfg.provider, base_url=cfg.base_url)
healthy = tracker.is_healthy
aliases_info.append(
{
"alias": alias,
"provider": cfg.provider,
"healthy": healthy,
}
)
return ("models", json.dumps(aliases_info), "auto")
def _publish_models_metadata(app_state: Any, storage: Any, node_id: str) -> None:
"""Refresh the per-node ``models`` row when the projection changed.
Caches the last-written JSON on ``app_state._last_models_payload``
so back-to-back heartbeat ticks with no health flip don't churn
the row — without this, the ``updated`` timestamp on every node's
``models`` row advances every 30s across the whole cluster.
Records the cache outcome on the metrics collector so
``turnstone_node_models_publish_total{outcome=...}`` exposes the
hit/miss ratio to Prometheus. Storage-error attempts don't
record either outcome — the next call will retry and the
counters reflect actual cache decisions, not transient DB
failures.
Sync — callers on the asyncio loop wrap with ``asyncio.to_thread``.
Concurrent callers (heartbeat tick vs. ``internal_model_reload``)
can race on the cache attribute; the worst case is a redundant
write, never a stale row, so we skip the lock.
"""
from turnstone.core.storage._registry import StorageUnavailableError
try:
entry = _collect_node_models_metadata(app_state)
except Exception:
log.warning("server.node_models_projection_failed", exc_info=True)
return
if entry is None:
return
payload = entry[1]
if payload == getattr(app_state, "_last_models_payload", None):
_metrics.record_node_models_publish(written=False)
return
try:
storage.set_node_metadata_bulk(node_id, [entry])
except StorageUnavailableError:
return # storage layer already logged
except Exception:
log.exception("server.node_models_publish_failed")
return
app_state._last_models_payload = payload
_metrics.record_node_models_publish(written=True)
# ---------------------------------------------------------------------------
# Global SSE fan-out
# ---------------------------------------------------------------------------
def _emit_health_changed(
status: str, gq: queue.Queue[dict[str, Any]], app_state: Any = None
) -> None:
"""Push a health_changed event onto the global SSE queue.
Called from the BackendHealthTracker callback on state transitions.
*status* is ``"healthy"`` or ``"degraded"``.
Also updates the global ``turnstone_backend_up`` metric using the
effective default backend's health (not the backend that triggered
this callback, which may be a non-default fallback).
"""
if app_state is not None:
_update_backend_metric(app_state)
with contextlib.suppress(queue.Full):
gq.put_nowait(
{
"type": "health_changed",
"backend_status": status,
}
)
def _update_backend_metric(app_state: Any) -> None:
"""Update ``turnstone_backend_up`` from the effective default's tracker.
Called on any backend state change. Only the effective default
backend drives this global metric — fallback backend transitions
do not affect it.
"""
health_reg = getattr(app_state, "health_registry", None)
registry = getattr(app_state, "registry", None)
if not health_reg or not registry:
return
config_store = getattr(app_state, "config_store", None)
effective = None
if config_store:
effective = config_store.get("model.default_alias") or None
tracker = None
if effective:
tracker = health_reg.get_tracker_for_alias(registry, effective)
if tracker is None:
tracker = health_reg.get_tracker_for_alias(registry, registry.default)
if tracker is not None:
_metrics.set_backend_status(tracker.is_healthy)
def _aggregate_emitter_thread(
mgr: SessionManager,
global_queue: queue.Queue[dict[str, Any]],
interval: float = 10.0,
) -> None:
"""Periodically emit aggregate token/tool_call totals on the global SSE queue.
Runs as a daemon thread so the console receives periodic updates without
having to poll ``/v1/api/dashboard``.
"""
while True:
time.sleep(interval)
total_tokens = 0
total_tool_calls = 0
active_count = 0
try:
for ws in mgr.list_all():
ui = ws.ui
if hasattr(ui, "_ws_lock"):
with ui._ws_lock: # type: ignore[union-attr]
tok = ui._ws_prompt_tokens + ui._ws_completion_tokens # type: ignore[union-attr]
tc = sum(ui._ws_tool_calls.values()) # type: ignore[union-attr]
else:
tok = 0
tc = 0
total_tokens += tok
total_tool_calls += tc
if ws.state.value != "idle":
active_count += 1
with contextlib.suppress(queue.Full):
global_queue.put_nowait(
{
"type": "aggregate",
"total_tokens": total_tokens,
"total_tool_calls": total_tool_calls,
"active_count": active_count,
"total_count": len(mgr.list_all()),
}
)
except Exception:
log.debug("Aggregate emitter error", exc_info=True)
def _idle_cleanup_thread(
mgr: SessionManager,
timeout_sec: float,
global_queue: queue.Queue[dict[str, Any]],
rate_limiter: Any = None,
) -> None:
"""Periodically close IDLE workstreams and clean up rate limiter buckets.
``mgr.close_idle`` fires the adapter's ``emit_closed`` for each
victim, which pushes ``ws_closed`` onto ``global_queue`` with
``reason="closed"``. The old manual emission here (``reason="idle"``)
is gone — the frontend didn't differentiate "idle" from "closed"
anyway and the duplicate event caused spurious UI flicker.
"""
del global_queue # adapter handles the emission
check_every = min(300.0, timeout_sec / 4) # check at 1/4 of timeout, max 5 min
while True:
time.sleep(check_every)
mgr.close_idle(timeout_sec)
if rate_limiter is not None:
rate_limiter.cleanup()
def _global_fanout_thread(
source_queue: queue.Queue[dict[str, Any]],
listeners: list[queue.Queue[dict[str, Any]]],
lock: threading.Lock,
event_buffer: collections.deque[tuple[int, dict[str, Any]]],
counter_holder: list[int],
) -> None:
"""Read events from ``source_queue``, stamp + buffer + fan out.
Stamps every event with a monotonic ``_event_id`` (the holder list
is a single-element mutable int — Python idiom for a shared int
under a lock), appends ``(event_id, event)`` to the global ring
buffer, snapshots the listener list, and fans out — all under
``lock`` so a concurrent reader registering itself as a listener
sees a consistent ``(counter, listeners, buffer)`` triple and no
event lands in ONLY the buffer or ONLY the listener queue across
the registration boundary. Mirrors :meth:`SessionUIBase._enqueue`'s
contract for the global lane.
"""
while True:
try:
event = source_queue.get()
with lock:
counter_holder[0] += 1
event_id = counter_holder[0]
stamped = {**event, "_event_id": event_id}
event_buffer.append((event_id, stamped))
snapshot = list(listeners)
for lq in snapshot:
with contextlib.suppress(queue.Full):
lq.put_nowait(stamped) # drop if a listener is backed up
except Exception:
log.debug("Global fan-out error", exc_info=True)
# ---------------------------------------------------------------------------
# Lifespan context manager
# ---------------------------------------------------------------------------
@asynccontextmanager
async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
"""Start background threads and handle shutdown."""
# Dedicated executor for SSE queue polling so it doesn't compete
# with the default asyncio executor (which caps at ~32 workers).
app.state.sse_executor = ThreadPoolExecutor(max_workers=200, thread_name_prefix="sse")
# Start global event fan-out thread
fanout = threading.Thread(
target=_global_fanout_thread,
args=(
app.state.global_queue,
app.state.global_listeners,
app.state.global_listeners_lock,
app.state.global_event_buffer,
app.state.global_event_id_holder,
),
daemon=True,
)
fanout.start()
# Start aggregate emitter thread for SSE consumers
agg_emitter = threading.Thread(
target=_aggregate_emitter_thread,
args=(app.state.workstreams, app.state.global_queue),
daemon=True,
)
agg_emitter.start()
# Start idle cleanup thread if configured
if app.state.idle_timeout > 0:
cleanup = threading.Thread(
target=_idle_cleanup_thread,
args=(
app.state.workstreams,
app.state.idle_timeout * 60,
app.state.global_queue,
app.state.rate_limiter,
),
daemon=True,
)
cleanup.start()
# Start watch runner (periodic command polling)
if app.state.watch_runner:
app.state.watch_runner.start()
# Start the buffered state-writer flusher
state_writer = getattr(app.state, "state_writer", None)
if state_writer is not None:
state_writer.start()
# (The attachment orphan-reservation sweep is gone — pending uploads now
# live in the per-node in-memory buffer with its own TTL eviction, so
# there are no persisted reservations to reclaim.)
from turnstone.core.oidc import initialize_oidc_state
await initialize_oidc_state(app.state)
# MCP-OAuth token-at-rest encryption — fail-loud on misconfiguration
# when any mcp_servers row has auth_type='oauth_user'.
from turnstone.core.mcp_crypto import initialize_mcp_crypto_state
initialize_mcp_crypto_state(app.state, node_id=getattr(app.state, "node_id", ""))
# Per-(user, server) OAuth flow state — long-lived HTTP client +
# in-process refresh lock + metadata cache.
from turnstone.core.mcp_oauth import initialize_mcp_oauth_state
await initialize_mcp_oauth_state(app.state)
# TLS: start auto-renewal if client was initialized
tls_client = getattr(app.state, "tls_client", None)
if tls_client is not None:
try:
await tls_client.start_renewal()
except Exception:
log.warning("TLS auto-renewal startup failed", exc_info=True)
# Register in service registry and start heartbeat
_heartbeat_task: asyncio.Task[None] | None = None
_svc_node_id: str = getattr(app.state, "node_id", "")
_svc_url: str = getattr(app.state, "advertise_url", "")
if _svc_node_id and _svc_url:
from turnstone.core.storage import get_storage as _get_svc_storage
_svc_storage = _get_svc_storage()
_svc_storage.register_service("server", _svc_node_id, _svc_url)
log.info("server.service_registered", node_id=_svc_node_id, url=_svc_url)
# Collect and store node metadata (auto + config).
# ``collect_node_info`` runs synchronous probes (sysfs reads,
# /proc reads, IMDS HTTP requests). Off-load to a worker
# thread so the IMDS path's worst-case latency (~1 s on a
# misidentified-cloud host) doesn't block the event loop
# during the rest of the lifespan startup work.
try:
from turnstone.core.config import load_config as _load_meta_config
from turnstone.core.node_info import collect_node_info
_auto_info = await asyncio.to_thread(collect_node_info)
_meta_entries: list[tuple[str, str, str]] = [
(k, json.dumps(v), "auto") for k, v in _auto_info.items()
]
_cfg_meta = _load_meta_config("metadata")
_meta_entries.extend((k, json.dumps(v), "config") for k, v in _cfg_meta.items())
# Project the live model registry into a ``models`` entry so
# coord-side ``list_nodes`` can surface healthy aliases per
# node without a fan-out HTTP probe. Re-collected on each
# heartbeat tick so health flips converge within ~30s.
# Wrapped in its own try/except so a projection failure
# doesn't take out the auto+config metadata write — losing
# the discovery surface is recoverable on the next heartbeat
# tick, but losing ``arch`` / ``os`` / ``cpu_count`` blinds
# the cluster's capability filters until the next restart.
try:
_models_entry = _collect_node_models_metadata(app.state)
except Exception:
log.warning("server.node_models_projection_failed", exc_info=True)
_models_entry = None
if _models_entry is not None:
_meta_entries.append(_models_entry)
if _meta_entries:
# Clear stale auto/config rows from a prior run before upserting
_svc_storage.delete_node_metadata_by_source(_svc_node_id, "auto")
_svc_storage.delete_node_metadata_by_source(_svc_node_id, "config")
_svc_storage.set_node_metadata_bulk(_svc_node_id, _meta_entries)
log.info(
"server.node_metadata_stored",
node_id=_svc_node_id,
count=len(_meta_entries),
)
# Seed the publish-cache so the first heartbeat tick
# doesn't redundant-write the same payload we just put
# in the bulk above.
if _models_entry is not None:
app.state._last_models_payload = _models_entry[1]
except Exception:
log.warning("server.node_metadata_failed", node_id=_svc_node_id, exc_info=True)
async def _heartbeat_loop() -> None:
"""Periodically update service heartbeat and refresh models metadata.
The ``models`` entry on ``node_metadata`` doubles as the
coord-side discovery surface for healthy model aliases per
node — refreshed every 30s so health flips and registry
reloads converge promptly without a fan-out HTTP probe on
the coord's ``list_nodes`` path. The publish step short-
circuits when the projection is byte-identical to the
last write (cache lives on ``app.state``), so a stable
cluster doesn't pay UPSERT churn here.
"""
from turnstone.core.storage._registry import StorageUnavailableError
while True:
await asyncio.sleep(30)
try:
await asyncio.to_thread(_svc_storage.heartbeat_service, "server", _svc_node_id)
except StorageUnavailableError:
pass # already logged by storage layer
except Exception:
log.exception("server.heartbeat_failed")
# Both projection and write happen in the worker thread
# — keeps the registry-lock acquisition off the loop and
# bundles the round-trip into a single offload.
await asyncio.to_thread(
_publish_models_metadata, app.state, _svc_storage, _svc_node_id
)
_heartbeat_task = asyncio.create_task(_heartbeat_loop())
yield
# Shutdown
if _heartbeat_task is not None:
_heartbeat_task.cancel()
# Wait for the cancel to land before we run the metadata
# delete below — a heartbeat tick mid-write would otherwise
# complete its ``set_node_metadata_bulk`` AFTER our
# ``delete_node_metadata_by_source(..., "auto")`` and
# resurrect the row we just cleared.
with contextlib.suppress(asyncio.CancelledError, Exception):
await _heartbeat_task
if _svc_node_id and _svc_url:
from turnstone.core.storage import get_storage as _get_svc_dereg
try:
_dereg_storage = _get_svc_dereg()
await asyncio.to_thread(_dereg_storage.deregister_service, "server", _svc_node_id)
await asyncio.to_thread(
_dereg_storage.delete_node_metadata_by_source, _svc_node_id, "auto"
)
await asyncio.to_thread(
_dereg_storage.delete_node_metadata_by_source, _svc_node_id, "config"
)
log.info("server.service_deregistered", node_id=_svc_node_id)
except Exception:
log.exception("server.deregister_failed")
tls_client = getattr(app.state, "tls_client", None)
if tls_client is not None:
await tls_client.stop_renewal()
if app.state.watch_runner:
app.state.watch_runner.stop()
from turnstone.core.idle_nudge_watcher import shutdown_idle_nudge_watchers
shutdown_idle_nudge_watchers(app)
# Drain + stop the buffered state-writer. shutdown() joins a
# daemon thread and runs synchronous DB writes; offload to a
# worker thread so we don't block the lifespan event loop and
# delay other teardown tasks.
state_writer = getattr(app.state, "state_writer", None)
if state_writer is not None:
await asyncio.to_thread(state_writer.shutdown)
# health_registry is stateless (no background threads) — nothing to stop
if app.state.mcp_client:
app.state.mcp_client.shutdown()
if app.state.registry:
app.state.registry.shutdown()
# Close in reverse order of initialization (mcp_oauth → mcp_crypto →
# oidc). The OAuth flow holds a long-lived httpx.AsyncClient that
# depends on no later-initialised state, but reversing init order
# is the conventional LIFO discipline.
from turnstone.core.mcp_oauth import close_mcp_oauth_state
await close_mcp_oauth_state(app.state)
from turnstone.core.mcp_crypto import close_mcp_crypto_state
close_mcp_crypto_state(app.state)
from turnstone.core.oidc import close_oidc_state
await close_oidc_state(app.state)
app.state.sse_executor.shutdown(wait=True, cancel_futures=True)
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
def _build_middleware(cors_origins: list[str] | None = None) -> list[Middleware]:
"""Build the middleware stack with optional CORS."""
stack: list[Middleware] = [
Middleware(LogContextMiddleware),
Middleware(MetricsMiddleware),
]
if cors_origins:
from turnstone.core.web_helpers import cors_middleware
stack.append(cors_middleware(cors_origins))
stack.extend(
[
Middleware(
AuthMiddleware,
jwt_audience=JWT_AUD_SERVER,
jwt_version=jwt_version_slot(),
cookie_name=AUTH_COOKIE_SERVER,
),
Middleware(RateLimitMiddleware),
]
)
return stack
def create_app(
*,
workstreams: SessionManager,
global_queue: queue.Queue[dict[str, Any]],
global_listeners: list[queue.Queue[dict[str, Any]]],
global_listeners_lock: threading.Lock,
skip_permissions: bool,
jwt_secret: str = "",
auth_storage: Any = None,
health_registry: Any = None,
rate_limiter: Any = None,
mcp_client: Any = None,
mcp_ref: list[Any] | None = None,
registry: Any = None,
idle_timeout: int = 0,
node_id: str = "",
cors_origins: list[str] | None = None,
watch_runner: Any = None,
judge_config: Any = None,
config_store: Any = None,
advertise_url: str = "",
state_writer: Any = None,
) -> Starlette:
"""Create and configure the Starlette ASGI application."""
_spec = build_server_spec()
_openapi_handler = make_openapi_handler(_spec)
_docs_handler = make_docs_handler()
# Workstream HTTP tree — owned by the shared registrar in
# ``turnstone.core.session_routes`` so the console mounts the same
# shape against its coord manager. The lifted handler factories
# (``make_approve_handler``, ``make_close_handler``) capture the
# kind-specific ``SessionEndpointConfig`` via closure.
def _interactive_attachment_owner(
request: Request, ws_id: str, _mgr: SessionManager
) -> tuple[str, JSONResponse | None]:
"""Resolve attachment owner for interactive workstreams via
:func:`_require_ws_access`. Mirrors the pre-P1.5 inline logic
in ``send_message`` — uses the storage path (``mgr`` not
passed) so tests with MagicMock managers don't trip on a
magic-mocked ``ws.user_id``."""
return _require_ws_access(request, ws_id)
def _interactive_spawn_metrics(_request: Request, ui: Any) -> None:
"""Per-conversation metrics fired once per send that spawns a
fresh worker. Coord wires its own
:func:`turnstone.console.server._coord_spawn_metrics` (the
Prometheus-free analog) post the rich ``ws_state`` payload
lift — both kinds need the per-UI counter writes so the
cluster broadcast renders the same per-turn shape.
The per-UI counters live on :class:`SessionUIBase`
(inherited by both :class:`WebUI` and
:class:`turnstone.console.coordinator_ui.ConsoleCoordinatorUI`),
so the ``hasattr`` guards survive only as defence against a
future ``SessionUI`` subclass that doesn't extend the base.
"""
_metrics.record_message_sent()
if (
hasattr(ui, "_ws_lock")
and hasattr(ui, "_ws_messages")
and hasattr(ui, "_ws_turn_tool_calls")
):
with ui._ws_lock:
ui._ws_messages += 1
ui._ws_turn_tool_calls = 0
from turnstone.core.attachments import classify_upload as _classify_upload
interactive_attachment_helpers = AttachmentUploadHelpers(
classify_upload=_classify_upload,
)
from turnstone.core.memory import (
get_workstream_display_names as _get_ws_display_names,
)
from turnstone.core.memory import resolve_workstream as _resolve_workstream_alias
interactive_endpoint_config = SessionEndpointConfig(
permission_gate=None, # interactive auth is enforced at the middleware layer
manager_lookup=_interactive_manager_lookup,
tenant_check=_interactive_tenant_check,
not_found_label="Workstream not found",
audit_action_prefix="workstream",
supports_attachments=True,
attachment_owner_resolver=_interactive_attachment_owner,
attachment_helpers=interactive_attachment_helpers,
spawn_metrics=_interactive_spawn_metrics,
emit_message_queued=True,
cancel_forensics=_capture_cancel_forensics,
open_resolve_alias=_resolve_workstream_alias,
open_post_load=_interactive_open_post_load,
events_replay=_interactive_events_replay,
# Pre-lift ``events_sse`` used the dedicated 200-thread
# ``sse_executor`` so SSE polling stayed isolated from
# every other ``asyncio.to_thread`` caller in the process
# (storage, router, audit). Restore that isolation under
# the lifted contract. The console's coord endpoint wires
# its own ``coord_sse_executor`` on the same lookup hook —
# see ``turnstone/console/server.py``.
sse_executor_lookup=lambda request: request.app.state.sse_executor,
create_supports_attachments=True,
create_supports_user_id_override=True,
create_validate_request=_interactive_create_validate_request,
create_build_kwargs=_interactive_create_build_kwargs,
create_post_install=_interactive_create_post_install,
# Bulk display-name resolution for the active list — one
# ``SELECT ... WHERE ws_id IN (...)`` for the whole snapshot
# instead of N per-row queries. Returns a {ws_id: title-or-None}
# dict; the lifted body falls back to ``ws.name`` per-row.
list_resolve_titles=_get_ws_display_names,
# Explicit kind classifier for the lifted list/saved factory's
# storage filter — required to avoid silently filtering for
# the wrong kind when a future kind is added.
list_kind=WorkstreamKind.INTERACTIVE,
# No state filter: the interactive saved sidebar shows every
# persisted workstream the storage layer doesn't already
# tombstone (deleted rows are excluded at the SQL level).
saved_state_filter=None,
# No in-memory exclusion: an interactive workstream that's
# both saved AND loaded is a normal display state.
saved_loaded_lookup=None,
)
# ``accepted_permissions`` gates the lifted body on any one of the
# named perms when ``cfg.permission_gate`` is ``None`` (interactive
# case) — for the interactive kind it IS the primary gate, not a
# fallback. Coord's ``permission_gate`` (admin.coordinator) takes
# precedence on the coord-config side; here we accept ``admin.
# coordinator`` as a parallel allow so a coord session spawning an
# interactive child workstream isn't blocked by the operator-style
# perm requirement. Was a pre-existing security smell: the
# ``workstreams.create`` / ``workstreams.close`` / ``tools.approve``
# perms were declared and seeded into builtin-operator's baseline
# but never wired to a gate — any authenticated user could hit
# these endpoints regardless of role. See PR adding 057_role_
# permission_overrides for the audit that surfaced this.
approve_handler = make_approve_handler(
interactive_endpoint_config,
accepted_permissions=("tools.approve", "admin.coordinator"),
)
close_handler = make_close_handler(
interactive_endpoint_config,
audit_emit=_audit_close_workstream,
supports_close_reason=True,
accepted_permissions=("workstreams.close", "admin.coordinator"),
)
cancel_handler = make_cancel_handler(interactive_endpoint_config)
rewind_handler = make_rewind_handler(
interactive_endpoint_config,
audit_emit=_audit_rewind_workstream,
accepted_permissions=("conversation.modify",),
)
retry_handler = make_retry_handler(
interactive_endpoint_config,
dispatch_retry=_interactive_dispatch_retry,
audit_emit=_audit_retry_workstream,
accepted_permissions=("conversation.modify",),
)
open_handler = make_open_handler(
interactive_endpoint_config,
audit_emit=_audit_workstream_opened,
)
events_handler = make_events_handler(interactive_endpoint_config)
send_handler = make_send_handler(interactive_endpoint_config)
dequeue_handler = make_dequeue_handler(interactive_endpoint_config)
attachment_handlers = make_attachment_handlers(interactive_endpoint_config)
create_handler = make_create_handler(
interactive_endpoint_config,
audit_emit=_audit_workstream_created,
accepted_permissions=("workstreams.create", "admin.coordinator"),
)
list_handler = make_list_handler(interactive_endpoint_config)
saved_handler = make_saved_handler(interactive_endpoint_config)
history_handler = make_history_handler(interactive_endpoint_config)
export_handler = make_export_handler(interactive_endpoint_config)
detail_handler = make_detail_handler(interactive_endpoint_config)
refresh_title_handler = make_refresh_title_handler(interactive_endpoint_config)
set_title_handler = make_set_title_handler(interactive_endpoint_config)
v1_routes: list[Any] = [
Route("/api/events/global", global_events_sse),
]
register_session_routes(
v1_routes,
prefix="/api/workstreams",
handlers=SharedSessionVerbHandlers(
list_workstreams=list_handler, # lifted: shared body
list_saved=saved_handler, # lifted: shared body
create=create_handler, # lifted: shared body
delete=delete_workstream_endpoint,
detail=detail_handler, # lifted: shared body (interactive feature gain)
open=open_handler, # lifted: shared body
close=close_handler, # lifted: shared body
refresh_title=refresh_title_handler, # lifted: shared body
set_title=set_title_handler, # lifted: shared body
send=send_handler, # lifted: shared body (P1.5)
dequeue=dequeue_handler, # lifted (P1.5) — DELETE /send
approve=approve_handler, # lifted: shared body
cancel=cancel_handler, # lifted: shared body
rewind=rewind_handler, # lifted: shared body (#549)
retry=retry_handler, # lifted: shared body (#549)
events=events_handler, # lifted: shared body
history=history_handler, # lifted: shared body (interactive feature gain)
export=export_handler, # lifted: shared body (#613, conversation-only)
attachments=attachment_handlers, # lifted: shared body (P1.5)
),
)
v1_routes.append(Route("/api/dashboard", dashboard))
v1_routes.append(
Route(
"/api/workstreams/{ws_id}/speech-to-text",
speech_to_text,
methods=["POST"],
)
)
v1_routes.append(
Route(
"/api/workstreams/{ws_id}/speech-to-text/stream",
speech_to_text_stream,
methods=["POST"],
)
)
v1_routes.append(Route("/api/tts", text_to_speech, methods=["POST"]))
app = Starlette(
routes=[
Route("/", index),
Mount(
"/v1",
routes=[
*v1_routes,
Route("/api/skills", list_skills_summary),
Route("/api/models", list_available_models),
Route("/api/command", command, methods=["POST"]),
Route("/api/watches", list_watches),
Route("/api/watches/{watch_id}/cancel", cancel_watch, methods=["POST"]),
Route("/api/memories", list_memories),
Route("/api/memories", save_memory, methods=["POST"]),
Route("/api/memories/search", search_memories, methods=["POST"]),
Route("/api/memories/{name}", delete_memory_endpoint, methods=["DELETE"]),
Route("/api/projects", list_projects),
Route("/api/projects", create_project, methods=["POST"]),
Route("/api/projects/{project_id}", get_project_endpoint),
Route(
"/api/projects/{project_id}",
update_project_endpoint,
methods=["PATCH"],
),
Route(
"/api/projects/{project_id}",
delete_project_endpoint,
methods=["DELETE"],
),
Route(
"/api/projects/{project_id}/resources",
project_resources_endpoint,
),
Route(
"/api/projects/{project_id}/members",
list_project_members_endpoint,
),
Route(
"/api/projects/{project_id}/members",
add_project_member_endpoint,
methods=["POST"],
),
Route(
"/api/projects/{project_id}/members/{user_id}",
remove_project_member_endpoint,
methods=["DELETE"],
),
Route("/api/auth/login", auth_login, methods=["POST"]),
Route("/api/auth/logout", auth_logout, methods=["POST"]),
Route("/api/auth/status", auth_status),
Route("/api/auth/setup", auth_setup, methods=["POST"]),
Route("/api/auth/whoami", auth_whoami),
Route("/api/auth/refresh", auth_refresh, methods=["POST"]),
Route("/api/auth/oidc/authorize", oidc_authorize),
Route("/api/auth/oidc/callback", oidc_callback),
Route("/api/mcp/oauth/start", mcp_oauth_authorize),
Route("/api/mcp/oauth/callback", mcp_oauth_callback),
Route("/api/mcp/oauth/connections", mcp_oauth_list_connections),
Route(
"/api/mcp/oauth/connections/{server_name}",
mcp_oauth_revoke_connection,
methods=["DELETE"],
),
Route("/api/mcp/oauth/pending", mcp_oauth_list_pending),
Route(
"/api/mcp/oauth/pending",
mcp_oauth_clear_all_pending,
methods=["DELETE"],
),
Route(
"/api/mcp/oauth/pending/{server_name}",
mcp_oauth_clear_pending,
methods=["DELETE"],
),
Route("/api/admin/settings", list_interface_settings),
Route(
"/api/admin/settings/{key:path}",
update_interface_setting,
methods=["POST", "PUT"],
),
Route("/api/_internal/config-reload", config_reload, methods=["POST"]),
Route("/api/_internal/mcp-reload", internal_mcp_reload, methods=["POST"]),
Route("/api/_internal/mcp-status", internal_mcp_status),
Route(
"/api/_internal/mcp-refresh/{name}",
internal_mcp_refresh_one,
methods=["POST"],
),
Route(
"/api/_internal/mcp-reconnect/{name}",
internal_mcp_reconnect_one,
methods=["POST"],
),
Route(
"/api/_internal/model-reload",
internal_model_reload,
methods=["POST"],
),
Route("/api/_internal/model-status", internal_model_status),
],
),
Route("/health", health),
Route("/metrics", metrics_endpoint),
Route("/openapi.json", _openapi_handler),
Route("/docs", _docs_handler),
Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"),
Mount("/shared", app=StaticFiles(directory=str(_SHARED_DIR)), name="shared"),
],
middleware=_build_middleware(cors_origins),
lifespan=_lifespan,
)
app.state.workstreams = workstreams
app.state.state_writer = state_writer
# Idle-driven wake trigger: dispatches a synthetic empty-user-turn
# send when an interactive workstream goes IDLE with queued nudges.
# Feature-inert until a producer enqueues against the interactive
# SessionManager (PR 4 watch switchover); installed here for
# symmetry with the coord-side install in console/server.py.
from turnstone.core.idle_nudge_watcher import install_idle_nudge_watcher
install_idle_nudge_watcher(app, workstreams)
app.state.global_queue = global_queue
app.state.global_listeners = global_listeners
app.state.global_listeners_lock = global_listeners_lock
# Per-node global SSE replay ring buffer + monotonic event counter.
# Mirrors :attr:`SessionUIBase._event_buffer` / ``._event_id`` for
# the global lane; ``_global_fanout_thread`` stamps every event
# with ``_event_id`` under ``global_listeners_lock`` and appends
# to this buffer. Cap sized to cover ~20 seconds of typical
# cluster broadcast rate (state changes + activity ticks across
# ~100 ws = up to a few hundred events/sec); operators can raise
# via ``TURNSTONE_SSE_EVENT_BUFFER_MAX`` (shared with per-ws cap).
from turnstone.core.session_ui_base import _EVENT_BUFFER_MAX
app.state.global_event_buffer = collections.deque(maxlen=_EVENT_BUFFER_MAX)
# Single-element list as a mutable int holder so the fanout
# thread can ``counter_holder[0] += 1`` under the lock.
app.state.global_event_id_holder = [0]
app.state.skip_permissions = skip_permissions
app.state.jwt_secret = jwt_secret
app.state.auth_storage = auth_storage
app.state.health_registry = health_registry
app.state.rate_limiter = rate_limiter
app.state.mcp_client = mcp_client
app.state.mcp_ref = mcp_ref
app.state.registry = registry
app.state.idle_timeout = idle_timeout
app.state.node_id = node_id
app.state.watch_runner = watch_runner
app.state.judge_config = judge_config
app.state.config_store = config_store
app.state.advertise_url = advertise_url
from turnstone.core.auth import LoginRateLimiter
app.state.login_limiter = LoginRateLimiter()
# OIDC configuration (opt-in via env vars)
from turnstone.core.oidc import load_oidc_config
oidc_config = load_oidc_config()
app.state.oidc_config = oidc_config
app.state.jwks_data = None # populated after async discovery
return app
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="turnstone web server — browser-based chat UI.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""\
Examples:
turnstone-server # auto-detect model, serve on :8080
turnstone-server --port 3000 # custom port
turnstone-server --model kappa_20b_131k # explicit model
turnstone-server --skip-permissions # auto-approve all tools
"""),
)
parser.add_argument(
"--base-url",
default="http://localhost:8000/v1",
help="OpenAI-compatible API base URL (default: http://localhost:8000/v1)",
)
parser.add_argument(
"--model",
default=None,
help="Model name (default: auto-detect from server)",
)
parser.add_argument(
"--skill",
default=None,
help="Skill name (replaces default skills)",
)
parser.add_argument(
"--provider",
default="openai",
choices=["openai", "anthropic"],
help="LLM provider for the default model (default: openai)",
)
parser.add_argument(
"--resume",
default=None,
metavar="WS",
help="Resume a previous workstream by alias or ws_id",
)
parser.add_argument(
"--api-key",
default=None,
help="API key (default: $OPENAI_API_KEY, or 'dummy' for local servers)",
)
parser.add_argument(
"--host",
default="0.0.0.0",
help="Host to bind to (default: 0.0.0.0)",
)
parser.add_argument(
"--port",
type=int,
default=8080,
help="Port to listen on (default: 8080)",
)
parser.add_argument(
"--skip-permissions",
action="store_true",
help="Auto-approve all tool calls without prompting (same as tools.skip_permissions)",
)
# MCP config path is bootstrap-critical (needed before ConfigStore for tool loading)
parser.add_argument(
"--mcp-config",
default=None,
metavar="PATH",
help="Path to MCP server config file (standard mcpServers JSON format)",
)
from turnstone.core.log import add_log_args
add_log_args(parser)
from turnstone.core.config import add_config_arg, apply_config
add_config_arg(parser)
# Only load bootstrap sections from config.toml — all other settings
# are managed by ConfigStore (database-backed) after storage init.
apply_config(parser, ["api", "server", "database"])
args = parser.parse_args()
from turnstone.core.log import configure_logging_from_args
configure_logging_from_args(args, "server")
import socket
# Initialize storage backend
from turnstone.core.storage import init_storage
db_backend = getattr(args, "db_backend", None) or os.environ.get(
"TURNSTONE_DB_BACKEND", "sqlite"
)
db_url = getattr(args, "db_url", None) or os.environ.get("TURNSTONE_DB_URL", "")
db_path = getattr(args, "db_path", None) or os.environ.get("TURNSTONE_DB_PATH", "")
db_pool_size = int(
getattr(args, "db_pool_size", None) or os.environ.get("TURNSTONE_DB_POOL_SIZE", "2")
)
init_storage(
db_backend,
path=db_path,
url=db_url,
pool_size=db_pool_size,
sslmode=getattr(args, "db_sslmode", None) or os.environ.get("TURNSTONE_DB_SSLMODE", ""),
sslrootcert=getattr(args, "db_sslrootcert", None)
or os.environ.get("TURNSTONE_DB_SSLROOTCERT", ""),
sslcert=getattr(args, "db_sslcert", None) or os.environ.get("TURNSTONE_DB_SSLCERT", ""),
sslkey=getattr(args, "db_sslkey", None) or os.environ.get("TURNSTONE_DB_SSLKEY", ""),
)
# Server-owned node identity (needed before ConfigStore for node_id scoping)
def _default_node_id() -> str:
"""Generate a node_id: ``{hostname}_{4hex}``, or a UUID on failure."""
suffix = uuid.uuid4().hex[:4]
try:
host = socket.gethostname()
if host and host != "localhost":
return f"{host}_{suffix}"
except OSError:
pass # hostname unavailable, fall back to UUID
return uuid.uuid4().hex[:12]
_node_id = os.environ.get("TURNSTONE_NODE_ID") or _default_node_id()
from turnstone.core.log import ctx_node_id
ctx_node_id.set(_node_id)
# Database-backed config store — single source of truth for non-bootstrap
# settings. Created early so all subsequent init code can read from it.
from turnstone.core.config_store import ConfigStore
from turnstone.core.storage import get_storage as _get_cs_storage
config_store = ConfigStore(storage=_get_cs_storage(), node_id=_node_id)
# Warn about config.toml keys that are now managed by ConfigStore
from turnstone.core.config import warn_migrated_settings
warn_migrated_settings()
# Prune stale / empty workstreams on startup
from turnstone.core.memory import prune_workstreams
prune_workstreams(retention_days=config_store.get("session.retention_days"), log_fn=print)
# Create client and detect model
provider_name = args.provider
api_key = (
args.api_key
or os.environ.get("ANTHROPIC_API_KEY" if provider_name == "anthropic" else "OPENAI_API_KEY")
or "dummy"
)
base_url = args.base_url
if provider_name == "anthropic" and base_url == "http://localhost:8000/v1":
base_url = "https://api.anthropic.com"
from turnstone.core.providers import create_client
client = create_client(provider_name, base_url=base_url, api_key=api_key)
cli_model = args.model
effective_model = cli_model or None
if effective_model:
model = effective_model
detected_ctx = None
else:
from turnstone.core.model_registry import detect_model
model, detected_ctx = detect_model(client, provider=provider_name, fatal=False)
if model is None:
# LLM backend unreachable — no CLI model specified.
# Set empty so load_model_registry skips the CLI "default"
# entry and relies on DB / config.toml models instead.
model = ""
# Use detected context window, fall back to 32768
if detected_ctx:
context_window = detected_ctx
log.info("Context window: %s (detected from backend)", f"{context_window:,}")
else:
context_window = 32768
# Build model registry (reads [models.*] + database model definitions)
from turnstone.core.model_registry import load_model_registry
from turnstone.core.storage._registry import get_storage as _get_storage
registry = load_model_registry(
base_url=base_url,
api_key=api_key,
model=model,
context_window=context_window,
provider=provider_name,
storage=_get_storage(),
# A node boots even with no models configured yet: it registers and
# shows in the console, and models added in the admin panel hot-reload
# in (internal_model_reload). Requests fail cleanly until then.
allow_empty=True,
)
# Apply runtime overrides from ConfigStore for default alias plus the
# per-kind sub-agent routing. Only triggers a reload when at least one
# ConfigStore value differs from what the registry loaded from disk.
# ConfigStore returns the SettingDef default ("" for these keys) when
# unset — distinct from the registry's None for unconfigured fields.
config_store.reload() # symmetry with internal_model_reload's cs.reload()
_apply_routing_overrides(registry, config_store)
# Initialize MCP client (connects to configured MCP servers, if any)
from turnstone.core.mcp_client import create_mcp_client
mcp_config_cli = args.mcp_config # CLI-only (no config.toml for this)
mcp_client = create_mcp_client(
mcp_config_cli or config_store.get("mcp.config_path") or None,
storage=_get_storage(),
)
# Mutable ref so session_factory always sees the latest MCP client,
# including ones created by internal_mcp_reload after startup.
_mcp_ref: list[Any] = [mcp_client]
# Per-backend passive health tracking (no active probes / circuit breakers)
from turnstone.core.healthcheck import HealthTrackerRegistry
# Set up global event queue for state-change broadcasts (created early so
# the health tracker callback can reference it).
global_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=10000)
global_listeners: list[queue.Queue[dict[str, Any]]] = []
global_listeners_lock = threading.Lock()
WebUI._global_queue = global_queue
# Mutable ref so the health callback can access app.state after app
# creation (same pattern as _mcp_ref).
_app_ref: list[Any] = [None]
health_registry = HealthTrackerRegistry(
failure_threshold=config_store.get("health.failure_threshold"),
on_state_changed=lambda _backend, state: _emit_health_changed(
state, global_queue, _app_ref[0].state if _app_ref[0] else None
),
)
# Eagerly create trackers for all registered backends. Sessions use
# read-only lookups (get_tracker_for_alias) and never create trackers
# on the hot path, so every backend must be registered here.
for _alias in registry.list_aliases():
_cfg = registry.get_config(_alias)
health_registry.get_tracker(provider=_cfg.provider, base_url=_cfg.base_url)
# Per-IP rate limiter
from turnstone.core.ratelimit import RateLimiter
rate_limiter = RateLimiter(
enabled=config_store.get("ratelimit.enabled"),
rate=config_store.get("ratelimit.requests_per_second"),
burst=config_store.get("ratelimit.burst"),
trusted_proxies=config_store.get("ratelimit.trusted_proxies"),
)
# Config builders — shared between startup logging and session factory.
# Re-read from ConfigStore each call so hot-reload works.
from turnstone.core.judge import JudgeConfig
from turnstone.core.memory_relevance import MemoryConfig
def _build_judge_config() -> JudgeConfig:
return JudgeConfig(
enabled=config_store.get("judge.enabled"),
model=config_store.get("judge.model"),
smart_approvals=config_store.get("judge.smart_approvals"),
confidence_threshold=config_store.get("judge.confidence_threshold"),
max_context_ratio=config_store.get("judge.max_context_ratio"),
timeout=config_store.get("judge.timeout"),
read_only_tools=config_store.get("judge.read_only_tools"),
output_guard=config_store.get("judge.output_guard"),
output_guard_budget_seconds=config_store.get("judge.output_guard_budget_seconds"),
output_guard_llm=config_store.get("judge.output_guard_llm"),
output_guard_model=config_store.get("judge.output_guard_model"),
output_guard_llm_timeout=config_store.get("judge.output_guard_llm_timeout"),
redact_secrets=config_store.get("judge.redact_secrets"),
)
def _build_memory_config() -> MemoryConfig:
return MemoryConfig(
relevance_k=config_store.get("memory.relevance_k"),
fetch_limit=config_store.get("memory.fetch_limit"),
max_content=config_store.get("memory.max_content"),
nudge_cooldown=config_store.get("memory.nudge_cooldown"),
nudges=config_store.get("memory.nudges"),
)
judge_config = _build_judge_config()
if judge_config.enabled:
log.info(
"Judge: enabled (model=%s, threshold=%.2f)",
judge_config.model or model,
judge_config.confidence_threshold,
)
# Session factory — captures shared config (including config_store for hot-reload)
def _effective_default_alias() -> str:
"""Return the runtime-effective default model alias.
Checks ConfigStore for a ``model.default_alias`` override first,
then falls back to the registry's static default.
"""
cs_alias: str = config_store.get("model.default_alias")
if cs_alias and registry.has_alias(cs_alias):
return cs_alias
return registry.default
def session_factory(
ui: SessionUI | None,
model_alias: str | None = None,
ws_id: str | None = None,
*,
skill: str | None = None,
client_type: str = "",
judge_model: str | None = None,
kind: WorkstreamKind = WorkstreamKind.INTERACTIVE,
parent_ws_id: str | None = None,
project_id: str = "",
) -> ChatSession:
assert ui is not None
# Resolve the effective alias once and use it consistently
# for both client resolution and ChatSession.model_alias.
# Unknown aliases here raise ValueError — the create handler
# maps that to a 503 with operator-friendly text so a typo or
# removed alias in body.model surfaces instead of silently
# starting on the default. SessionManager.open's rehydrate
# path is the one place where unknown aliases must NOT fail
# loud; the manager filters those out via its model_validator
# before the alias reaches this factory.
model_alias = model_alias or _effective_default_alias()
r_client, r_model, r_cfg = registry.resolve(model_alias)
# Read MCP client from shared ref — may have been replaced after startup
# by internal_mcp_reload (Sync to Nodes) when no --mcp-config was passed.
live_mcp_client = _mcp_ref[0]
uid = getattr(ui, "_user_id", "") or ""
# Resolve username from user_id for system message context
_username = ""
if uid:
try:
from turnstone.core.storage._registry import get_storage as _gs
_st = _gs()
if _st:
_u = _st.get_user(uid)
if _u:
_username = _u.get("username", "")
except Exception:
log.debug("Failed to resolve username for uid %s", uid, exc_info=True)
# Re-resolve from ConfigStore so new workstreams pick up hot-reloaded settings.
live_memory_config = _build_memory_config()
live_judge_config = _build_judge_config()
if live_judge_config and judge_model:
import dataclasses
# Override the config-default judge model with the per-call
# alias, but DON'T replace the alias with the resolved
# underlying model id. IntentJudge.__init__ does the full
# resolution (alias → client + provider + model) and
# pre-rewriting ``model`` to the underlying id strands the
# alias context — IntentJudge then has no way to recover the
# alias's provider/client and falls back to the session's
# provider with a model name that provider may not support
# (silent ``llm_fallback`` verdicts). ``registry.resolve``
# is called purely as a typo / unknown-alias guard.
try:
registry.resolve(judge_model)
live_judge_config = dataclasses.replace(
live_judge_config,
model=judge_model,
)
except Exception as e:
log.warning("Failed to resolve judge_model %r: %s", judge_model, e)
# Per-model sampling overrides take priority over global defaults
eff_temperature = (
r_cfg.temperature
if r_cfg.temperature is not None
else config_store.get("model.temperature")
)
eff_max_tokens = (
r_cfg.max_tokens
if r_cfg.max_tokens is not None
else config_store.get("model.max_tokens")
)
eff_reasoning_effort = (
r_cfg.reasoning_effort
if r_cfg.reasoning_effort is not None
else config_store.get("model.reasoning_effort")
)
return ChatSession(
client=r_client,
model=r_model,
ui=ui,
instructions=config_store.get("session.instructions") or None,
temperature=eff_temperature,
max_tokens=eff_max_tokens,
tool_timeout=config_store.get("tools.timeout"),
reasoning_effort=eff_reasoning_effort,
context_window=r_cfg.context_window,
compact_max_tokens=config_store.get("session.compact_max_tokens"),
auto_compact_pct=config_store.get("session.auto_compact_pct"),
agent_max_turns=config_store.get("tools.agent_max_turns"),
tool_truncation=config_store.get("tools.truncation"),
mcp_client=live_mcp_client,
registry=registry,
model_alias=model_alias,
health_registry=health_registry,
node_id=_node_id,
ws_id=ws_id,
tool_search=config_store.get("tools.search"),
tool_search_threshold=config_store.get("tools.search_threshold"),
tool_search_max_results=config_store.get("tools.search_max_results"),
web_search_backend=config_store.get("tools.web_search_backend"),
skill=skill or args.skill or None,
judge_config=live_judge_config,
user_id=uid,
memory_config=live_memory_config,
config_store=config_store,
client_type=ClientType(client_type)
if client_type in {ct.value for ct in ClientType}
else ClientType.WEB,
username=_username,
kind=kind,
parent_ws_id=parent_ws_id,
project_id=project_id,
)
# Create WatchRunner (periodic command polling, server-level)
from turnstone.core.storage import get_storage as _get_storage
from turnstone.core.watch import WatchRunner
# Create session manager first (watch restore_fn captures it).
interactive_adapter = InteractiveAdapter(
global_queue=global_queue,
ui_factory=lambda ws: WebUI(
ws_id=ws.id,
user_id=ws.user_id,
kind=ws.kind,
parent_ws_id=ws.parent_ws_id,
),
session_factory=session_factory,
)
from turnstone.core.state_writer import StateWriter
state_writer = StateWriter(_get_storage())
manager = SessionManager(
interactive_adapter,
storage=_get_storage(),
max_active=config_store.get("server.max_workstreams"),
node_id=_node_id,
state_writer=state_writer,
# InteractiveAdapter satisfies SessionEventEmitter for the
# ``ws_closed`` transport path; emit_created / emit_state /
# emit_rehydrated are no-ops because those events fire from
# out-of-band paths (create handler + WebUI._broadcast_state).
event_emitter=interactive_adapter,
# Filter out persisted aliases that no longer resolve so a
# workstream pinned to a since-removed alias still rehydrates
# (on the registry default) instead of 500-ing on every reopen.
model_validator=registry.has_alias,
)
interactive_adapter.attach(manager)
WebUI._workstream_mgr = manager
def _watch_restore_fn(ws_id: str) -> Any:
"""Restore an evicted workstream so a watch can deliver results.
Returns the per-ws dispatch closure ``set_watch_runner`` registered
on the rehydrated session, so ``WatchRunner._dispatch_result`` can
re-deliver the current message into the rehydrated workstream's
:class:`NudgeQueue` without a second pass through ``restore_fn``.
"""
try:
ws = manager.create(user_id="", name="watch-restore")
# Restored workstreams run unattended — auto-approve tool calls
# to avoid blocking forever on approval with no connected user.
if isinstance(ws.ui, WebUI):
ws.ui.auto_approve = True
if ws.session:
ws.session.resume(ws_id)
ws.session.set_watch_runner(_watch_runner)
return _watch_runner.get_dispatch_fn(ws.session._ws_id)
except RuntimeError:
log.warning("watch_restore: cannot restore ws %s (all slots active)", ws_id)
return None
_watch_runner = WatchRunner(
storage=_get_storage(),
node_id=_node_id,
tool_timeout=config_store.get("tools.timeout"),
restore_fn=_watch_restore_fn,
)
# ``--resume`` lazily creates a workstream scoped to the resumed
# content. Without ``--resume`` no default workstream is spawned;
# the web UI handles the 0-ws state and users create workstreams
# on demand via POST /v1/api/workstreams.
if args.resume:
from turnstone.core.memory import resolve_workstream
target_id = resolve_workstream(args.resume)
if not target_id:
log.error("Workstream not found: %s", args.resume)
sys.exit(1)
ws = manager.create(user_id="", name="resumed")
if not isinstance(ws.ui, WebUI):
raise TypeError(f"Expected WebUI, got {type(ws.ui).__name__}")
if args.skip_permissions or config_store.get("tools.skip_permissions"):
ws.ui.auto_approve = True
assert ws.session is not None
ws.session.set_watch_runner(_watch_runner)
if not ws.session.resume(target_id):
log.error("Workstream '%s' has no messages.", args.resume)
sys.exit(1)
log.info("Resumed workstream %s (%d messages)", target_id, len(ws.session.messages))
# Record detected model and judge status in metrics
_metrics.model = model
_metrics.set_judge_enabled(judge_config.enabled if judge_config else False)
# Auth config
from turnstone.core.auth import load_jwt_secret
from turnstone.core.storage import get_storage
jwt_secret = load_jwt_secret()
log.info("Auth: enabled (JWT)")
# Build the ASGI app
from turnstone.core.web_helpers import parse_cors_origins
cors_origins = parse_cors_origins()
# Construct advertise URL for service registration. Priority:
# 1. TURNSTONE_ADVERTISE_URL env var (required in Docker/k8s where
# gethostname() returns a container ID that peers can't resolve)
# 2. Explicit --host (not a wildcard bind address)
# 3. socket.gethostname() (bare-metal fallback; getfqdn() does
# reverse DNS which often truncates the hostname)
_advertise_url = os.environ.get("TURNSTONE_ADVERTISE_URL", "")
if not _advertise_url:
_advertise_host = args.host if args.host not in ("0.0.0.0", "::") else socket.gethostname()
_advertise_url = f"http://{_advertise_host}:{args.port}"
_skip_perms = args.skip_permissions or config_store.get("tools.skip_permissions")
app = create_app(
workstreams=manager,
global_queue=global_queue,
global_listeners=global_listeners,
global_listeners_lock=global_listeners_lock,
skip_permissions=_skip_perms,
jwt_secret=jwt_secret,
auth_storage=get_storage(),
health_registry=health_registry,
rate_limiter=rate_limiter,
mcp_client=mcp_client,
mcp_ref=_mcp_ref,
registry=registry,
idle_timeout=config_store.get("server.workstream_idle_timeout"),
node_id=_node_id,
cors_origins=cors_origins,
watch_runner=_watch_runner,
judge_config=judge_config,
config_store=config_store,
advertise_url=_advertise_url,
state_writer=state_writer,
)
# Wire app ref so health callbacks can access app.state for metrics
_app_ref[0] = app
# Store CLI model args for hot-reload (internal_model_reload reads these)
app.state.cli_model_args = {
"base_url": base_url,
"api_key": api_key,
"model": model,
"context_window": context_window,
"provider": provider_name,
"_user_specified_model": bool(effective_model),
}
log.info("Server starting on http://%s:%s", args.host, args.port)
log.info("Model: %s", model)
if registry.count > 1:
others = [a for a in registry.list_aliases() if a != registry.default]
log.info("Models: %s (default), %s", registry.default, ", ".join(others))
if mcp_client:
mcp_tools = mcp_client.get_tools()
if mcp_tools:
log.info("MCP tools: %d from %d server(s)", len(mcp_tools), mcp_client.server_count)
mcp_client.set_storage(get_storage())
mcp_client.set_app_state(app.state)
log.info(
"Health tracking: failure_threshold=%s",
config_store.get("health.failure_threshold"),
)
if rate_limiter.enabled:
log.info(
"Rate limiter: %s req/s, burst=%s",
config_store.get("ratelimit.requests_per_second"),
config_store.get("ratelimit.burst"),
)
log.info("Max workstreams: %s", config_store.get("server.max_workstreams"))
log.info("Node ID: %s", _node_id)
# TLS: request cert from console ACME if enabled
ssl_kwargs: dict[str, Any] = {}
if config_store.get("tls.enabled"):
try:
import asyncio
from turnstone.core.tls import (
TLS_INIT_RETRY_ATTEMPTS,
TLSClient,
build_cert_hostnames,
prepare_pem_runtime_dir,
)
# The advertised host (the name the collector + routing proxy dial)
# is placed first so it becomes the cert's primary domain / SAN and
# a stable store key. Deriving SANs from gethostname() alone (the
# container ID) omits the advertised name and breaks every mTLS
# handshake's hostname check.
hostnames = build_cert_hostnames(
_advertise_url,
bind_host=args.host,
extra_sans=os.environ.get("TURNSTONE_TLS_SANS", ""),
)
tls_client = TLSClient(
storage=get_storage(),
hostnames=hostnames,
# A bare-metal node can't resolve the in-network console URL the
# services table advertises (http://console:8090), so honor an
# explicit override pointing at the published ACME endpoint.
# Empty (the in-cluster default) falls back to service discovery.
console_url=os.environ.get("TURNSTONE_CONSOLE_URL", ""),
)
asyncio.run(tls_client.init(attempts=TLS_INIT_RETRY_ATTEMPTS))
bundle = tls_client.bundle
if bundle:
from lacme.mtls import write_pem_files_persistent
# Fixed parent dir (vs. a random tmpdir) so the container
# healthcheck can find the cert and probe over mTLS.
pem_paths = write_pem_files_persistent(
bundle,
ca_pem=tls_client.ca_pem,
directory=prepare_pem_runtime_dir(),
)
ssl_kwargs.update(pem_paths.as_uvicorn_kwargs())
if tls_client.ca_pem:
import ssl as _ssl
ssl_kwargs["ssl_cert_reqs"] = _ssl.CERT_REQUIRED
# Store client on app state for lifespan renewal
app.state.tls_client = tls_client
pem_dir_state = {"dir": pem_paths.cert.parent}
def _reload_server_cert(new_bundle: Any) -> None:
"""Swap a renewed cert into uvicorn's live SSL context.
uvicorn loads its cert once at boot and never reloads, so
without this the served cert would expire mid-process and
break every mTLS peer. The on-disk runtime PEMs (the
healthcheck's client identity) expire on the same clock,
so they are refreshed alongside.
"""
from turnstone.core.tls import refresh_runtime_pems, swap_context_cert
try:
new_paths = refresh_runtime_pems(
new_bundle,
ca_pem=tls_client.ca_pem,
previous=pem_dir_state["dir"],
)
pem_dir_state["dir"] = new_paths.cert.parent
except Exception:
log.warning("TLS runtime PEM refresh failed", exc_info=True)
cfg = getattr(app.state, "uvicorn_config", None)
live_ctx = getattr(cfg, "ssl", None) if cfg is not None else None
if live_ctx is None:
return # listener not started yet — boot cert still valid
swap_context_cert(live_ctx, new_bundle, ca_pem=tls_client.ca_pem)
log.info("TLS cert reloaded into listener: %s", new_bundle.domain)
tls_client.set_cert_reload_hook(_reload_server_cert)
# Update advertise URL to HTTPS now that TLS is active
if _advertise_url.startswith("http://"):
app.state.advertise_url = _advertise_url.replace("http://", "https://", 1)
else:
app.state.advertise_url = _advertise_url
app.state.tls_state = "active"
log.info("TLS enabled — serving HTTPS")
else:
app.state.tls_state = "fallback"
log.warning("TLS enabled but no cert available")
except Exception as exc:
# Surfaced as tls:"fallback" in /health — a node serving plain
# HTTP while TLS is configured should be visible, not silent.
app.state.tls_state = "fallback"
log.warning(
"TLS initialization failed — serving plain HTTP: %s: %s",
type(exc).__name__,
exc,
)
log.debug("TLS init traceback", exc_info=True)
print("Press Ctrl+C to stop.")
import uvicorn
uvicorn_config = uvicorn.Config(
app, host=args.host, port=args.port, log_level="warning", **ssl_kwargs
)
# Expose the config so the TLS renewal hook can hot-swap the cert on the
# live SSL context (``config.ssl``); uvicorn has no built-in SSL reload.
app.state.uvicorn_config = uvicorn_config
uvicorn.Server(uvicorn_config).run()
if __name__ == "__main__":
main()