Files
turnstone/turnstone/server.py
T
Patrick Buckley 33865ca9d2 fix(reasoning): apply full-stack review findings
Multi-stage /review on the full Phase 1+2+3+4 stack surfaced 9 findings
(0 critical, 3 major, 5 minor, 1 nit, 1 uncertain).  All applied.

Major

* perf-1 (session_routes.py:2402): make_history_handler ran sync
  storage.load_workstream_config inside async def history on the cold-
  workstream path, blocking the event loop on every dashboard /history
  request for non-resident workstreams.  Every other storage call in
  the same handler correctly used asyncio.to_thread.  Wrap the sync
  call in asyncio.to_thread (preserving the existing try/except so a
  DB failure still degrades to the conservative-default branch instead
  of bubbling out).

* q-2 (test_reasoning_audit_log_discipline.py): the security-sensitive
  test (reasoning text never lands at INFO+ severity) only covered the
  4 Phase 1 surfaces.  Phase 2 added the strip predicate in
  AnthropicProvider._convert_messages and Phase 3 added 3 more code
  paths that touch reasoning text — none guarded.  Added 4 parallel
  tests using the existing capture-and-walk infrastructure:
  OpenAIResponsesProvider.extract_reasoning_text,
  OpenAIChatCompletionsProvider.extract_reasoning_text,
  ChatSession._stream_response (drives the synth-block stamp via a
  fake reasoning-emitting stream), AnthropicProvider._convert_messages
  with replay_reasoning_to_model=False (drives the Phase 2 strip
  predicate).

* q-1 (model_registry.py:42): the persist_reasoning flag name implied
  storage-control but actually gates UI rehydration only — operators
  flipping it could reasonably expect "stop persisting reasoning" but
  storage of reasoning bytes happens in provider_data regardless.
  Renamed everywhere to surface_persisted_reasoning: ModelConfig
  field, migration 052 column (renaming in-place since 052 is not yet
  on main), schema, MODEL_DEFINITION_MUTABLE allowlist, _postgresql.py
  + _sqlite.py CRUD impls, _protocol.py create_model_definition
  signature, 3 console_schemas Pydantic models, console/server.py
  admin POST + PUT, model_registry row mapper, history_decoration.py
  helper parameter, server.py _build_history local var,
  session_routes.py make_history_handler local var, sdk/events.py
  HistoryEvent docstring, admin.js form id + override pill label,
  index.html form input id + UI label + tooltip, coordinator.js (none
  needed), and every test that referenced the old field name.  The
  admin tooltip now reads "Storage of reasoning bytes is unaffected
  by this flag — they ride in provider_data regardless" so the
  decoupling stays explicit at the operator surface.

Minor

* bug-1 (history_decoration.py:336): dispatcher discriminated on
  provider_content[0]["type"] only.  Anthropic's redacted_thinking
  blocks (sealed by the safety system) can appear before, after, or
  interleaved with regular thinking blocks per the API docs.  When a
  redacted block lands first, the dispatcher returned "" and the UI
  silently lost the surrounding thinking text.  Registered
  "redacted_thinking" as a second key in _BLOCK_TYPE_PROVIDER_FACTORY
  pointing at the same AnthropicProvider factory — the existing
  extractor's type=="thinking" filter already correctly skips redacted
  blocks while walking the full list.  Regression test added.

* q-3 (_protocol.py:155): replay_reasoning_to_model defaults split
  across 9 sites — operator-side defaults to False (matches DB
  server_default), provider-API defaults to True (back-compat with
  direct callers).  Original "pick False everywhere" fix would have
  silently flipped behaviour for any direct provider caller.  Instead
  documented the intentional bifurcation in the Protocol's
  create_streaming docstring.

* q-4+q-5 (_protocol.py:107 + 3 providers): MAX_REASONING_DISPLAY_BYTES
  was enforced via Python str slicing which counts code points, not
  UTF-8 bytes — 4-byte CJK/emoji glyphs would blow past the byte
  ceiling.  Renamed to MAX_REASONING_DISPLAY_CHARS to match actual
  behaviour.  Hoisted the 4-line truncation pattern into a shared
  _join_reasoning_with_cap helper in _protocol.py; each provider's
  extractor becomes a single line at the tail.

* q-6 (tests/_session_helpers.py): _NullUI + _make_session were
  duplicated verbatim between test_session_replay_reasoning.py and
  test_session_synth_reasoning_block.py.  Hoisted to a shared
  tests/_session_helpers.py module (importable, leading underscore so
  pytest doesn't try to collect it).  test_model_registry.py's
  _make_session has a different signature (registry/model_alias args
  + _FakeUI) and is not a candidate for sharing.

Nit

* q-7 (history_decoration.py:286): _make_provider_factory used a
  dict-as-cell workaround for closure read-only scope.  Replaced with
  the more idiomatic nonlocal pattern.

Lint + test gate

* ruff check + ruff format -- clean.
* mypy -- no issues across all 191 source files.
* pytest -m 'not live' -- 6115 passed (3 deselected).  Net +5 tests
  (4 audit-log discipline + 1 redacted_thinking dispatcher).

Refinements vs the dedupe output (caught during sanity rendering
the report)

* perf-1 fix preserved the try/except wrapper.  The original "wrap in
  to_thread" one-liner would have let an OperationalError bubble out
  instead of degrading to the fallback branch.

* q-3 fix explicitly documented the bifurcation rather than
  collapsing both sides to False.  "Pick False everywhere" would
  silently flip back-compat behaviour for direct provider callers.

* q-1 fix included the admin.js:5292 fallback site
  (m.persist_reasoning !== false) that the original threaded-change
  list missed.

* q-6 fix verified the third _make_session in test_model_registry.py
  is structurally different (different signature + different UI
  helper) and intentionally NOT a dedupe target.
2026-05-09 02:45:13 -07:00

4804 lines
197 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 contextlib
import functools
import hashlib
import json
import os
import queue
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
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 (
DENY_EMPTY_SUB,
JWT_AUD_SERVER,
AuthMiddleware,
_DenyFilter,
jwt_version_slot,
)
from turnstone.core.history_decoration import (
decorate_tool_call as _decorate_tool_call,
)
from turnstone.core.history_decoration import (
extract_advisories_from_tool_envelope,
)
from turnstone.core.history_decoration import (
extract_reasoning_text_from_provider_content as _extract_reasoning_text,
)
from turnstone.core.history_decoration import (
load_verdict_indexes as _load_verdict_indexes,
)
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_history_handler,
make_list_handler,
make_open_handler,
make_saved_handler,
make_send_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.watch import WATCH_REMINDER_OPTIONAL_KEYS
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
# ---------------------------------------------------------------------------
# Orphan-attachment-reservation sweep cadence. Threshold is measured
# against the storage layer's `reserved_at` column (time the row last
# transitioned into reserved state, NOT upload time), so a 1-hour cap
# is safely longer than any realistic single send without risking the
# unreservation of attachments uploaded long ago but reserved fresh.
_ORPHAN_SWEEP_INTERVAL_S = 30 * 60
_ORPHAN_SWEEP_THRESHOLD_S = 1 * 3600
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_plan_review(self, content: str) -> str:
self._plan_event.clear()
self._pending_plan_review = {"type": "plan_review", "content": content}
self._enqueue(self._pending_plan_review)
if not self._plan_event.wait(timeout=self._APPROVAL_WAIT_TIMEOUT):
log.warning("Plan review timed out for ws_id=%s", self.ws_id)
self._plan_result = ""
self._pending_plan_review = None
return self._plan_result
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`` / ``resolve_plan`` inherited from
# :class:`SessionUIBase`. Intent-verdict decision propagation lives
# in the base now — both interactive and coord share the same
# bookkeeping.
# ---------------------------------------------------------------------------
# History builder
# ---------------------------------------------------------------------------
# Verdict + output-assessment decoration helpers (``_decorate_tool_call``,
# ``_load_verdict_indexes``) are imported at module top alongside the
# rest of ``turnstone.core.*``. Both this builder and
# :func:`make_history_handler` (the /history REST endpoint coord uses
# as its primary history loader) share them so the two surfaces don't
# drift on the wire shape they emit.
def _build_history(
session: ChatSession,
has_pending_approval: bool = False,
*,
verdicts: dict[str, dict[str, Any]] | None = None,
assessments: dict[str, dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""Build a history replay list from ChatSession messages.
When ``has_pending_approval`` is True, the last assistant entry's
tool_calls are marked ``"pending": True`` so the client renders them
as awaiting approval rather than as already-approved.
Tool results whose content starts with "Denied by user" are marked
``"denied": True``, and the corresponding assistant entry that
issued the tool calls is also marked ``"denied": True`` so the
client can render the correct badge.
``verdicts`` and ``assessments`` are optional pre-loaded
``{call_id → row}`` dicts (see :func:`_load_verdict_indexes`).
Async callers should pre-load via ``asyncio.to_thread`` and pass
them in to avoid blocking the event loop on storage I/O. When
omitted, the storage call runs inline (sync call sites).
"""
# Metacognitive nudges live on the message dict's ``_reminders``
# side-channel — user messages carry user-channel nudges
# (correction / denial / resume / start / completion), tool
# messages carry tool-channel nudges (tool_error / repeat). Both
# are surfaced separately on each entry so the UI can render them
# as their own bubble (live via ``user_reminder`` /
# ``tool_reminder`` SSE events; replay via this propagation).
# ``content`` never carries the ``<system-reminder>`` envelope —
# that splice is transient, applied to a wire-bound copy in
# ``ChatSession._apply_reminders_for_provider``.
#
# Verdict + output-assessment lookup tables — populated either
# inline (sync call sites) or pre-loaded by an async caller via
# asyncio.to_thread (see _load_verdict_indexes). Pre-loading is
# what keeps _build_history off the event loop's hot path on the
# SSE replay generator path.
if verdicts is not None and assessments is not None:
verdicts_by_call_id = verdicts
assessments_by_call_id = assessments
else:
ws_id = getattr(session, "_ws_id", "") or ""
verdicts_by_call_id, assessments_by_call_id = _load_verdict_indexes(ws_id)
# Active-model reasoning-persistence flag — defaults True so that a
# registry/alias lookup miss still surfaces reasoning bubbles. The
# default-True semantic mirrors the migration's server_default for
# ``model_definitions.surface_persisted_reasoning`` and matches the conservative
# rehydration default (Phase 1 spec).
surface_persisted_reasoning = True
registry = getattr(session, "_registry", None)
model_alias = getattr(session, "_model_alias", "") or ""
if registry is not None and model_alias:
try:
surface_persisted_reasoning = bool(
registry.get_config(model_alias).surface_persisted_reasoning
)
except Exception:
# Unknown alias / partially-built registry / dataclass drift —
# fall back to the conservative default rather than failing
# the entire history build.
surface_persisted_reasoning = True
history = []
for msg in session.messages:
content = msg.get("content")
attachments_meta: list[dict[str, Any]] = []
# User messages with attachments carry list content (text +
# image_url / document parts). The UI wants a plain-text bubble
# plus a derived pill cluster — split the list content here so
# the client never has to interpret provider-shaped parts.
if msg.get("role") == "user" and isinstance(content, list):
text_parts: list[str] = []
for part in content:
if not isinstance(part, dict):
continue
ptype = part.get("type")
if ptype == "text":
text_parts.append(str(part.get("text", "")))
elif ptype == "image_url":
attachments_meta.append({"kind": "image", "filename": "", "mime_type": ""})
elif ptype == "document":
d = part.get("document", {})
attachments_meta.append(
{
"kind": "text",
"filename": str(d.get("name", "")),
"mime_type": str(d.get("media_type", "")),
}
)
content = "\n".join(text_parts)
# Prefer the authoritative side-channel (set by
# reconstruct_messages on history replay) — it carries image
# filenames that the image_url part itself can't express.
side_meta = msg.get("_attachments_meta")
if isinstance(side_meta, list) and side_meta:
attachments_meta = [
{
"kind": str(m.get("kind") or ""),
"filename": str(m.get("filename") or ""),
"mime_type": str(m.get("mime_type") or ""),
}
for m in side_meta
if isinstance(m, dict)
]
entry = {"role": msg["role"], "content": content}
if attachments_meta:
entry["attachments"] = attachments_meta
# Surface the ``_source`` side-channel so the frontend can apply
# the ``.msg.user.system-nudge`` class on history replay (today
# only the wake-driven empty user turn carries ``"system_nudge"``).
# Persisted via the conversations._source column added in
# migration 050; legacy rows without the column lack the key
# entirely.
if msg.get("_source"):
entry["source"] = str(msg["_source"])
# Surface the ``_reminders`` side-channel so a tab reconnecting
# via /history renders the same metacognitive nudge bubble the
# originating tab saw live (user-channel reminders via
# ``user_reminder`` SSE; tool-channel via ``tool_reminder``).
# Persisted via the conversations._reminders column added in
# migration 050 — multi-tab / multi-device tabs reconnecting
# later see the same shape now, not just the originating tab.
reminders = msg.get("_reminders")
if isinstance(reminders, list):
# Filter first so an all-malformed _reminders doesn't set the
# field to []; absent vs. empty-list should mean the same
# thing on the wire. Project on a known set of keys —
# narrows the blast radius if a future producer accidentally
# stuffs sensitive fields into the dict. ``watch_triggered``
# carries the structured watch-card fields (watch_name,
# command, poll_count, max_polls, is_final) and other
# producers leave them unset.
clean_reminders: list[dict[str, Any]] = []
for r in reminders:
if not isinstance(r, dict):
continue
rtype = str(r.get("type") or "")
rtext = str(r.get("text") or "")
if not rtype and not rtext:
continue
clean: dict[str, Any] = {"type": rtype, "text": rtext}
for opt_key in WATCH_REMINDER_OPTIONAL_KEYS:
if opt_key in r:
clean[opt_key] = r[opt_key]
clean_reminders.append(clean)
if clean_reminders:
entry["reminders"] = clean_reminders
# Surface stored reasoning text on assistant messages for UI
# rehydration (page-refresh path). Sourced from the in-memory
# ``_provider_content`` lane on ``session.messages`` (set
# post-commit at ``session.py:3768-3771``). The lane itself is
# never copied into ``entry`` — the wire payload stays tight.
if msg.get("role") == "assistant" and surface_persisted_reasoning:
reasoning_text = _extract_reasoning_text(msg.get("_provider_content"))
if reasoning_text:
entry["reasoning"] = reasoning_text
if msg.get("tool_calls"):
tc_entries: list[dict[str, Any]] = []
for tc in msg["tool_calls"]:
tc_entry: dict[str, Any] = {
"id": tc.get("id", "") or "",
"name": tc["function"]["name"],
"arguments": tc["function"].get("arguments", ""),
}
# Decorate with persisted verdict + output_assessment
# via the shared helper (also used by
# ``make_history_handler``). Skips unflagged
# ("risk_level == 'none'") rows so the wire stays
# tight; ships only the fields the UI renders.
_decorate_tool_call(
tc_entry,
verdicts_by_call_id,
assessments_by_call_id,
)
tc_entries.append(tc_entry)
entry["tool_calls"] = tc_entries
# Detect denied/blocked/errored tool results by their content prefix.
if msg.get("role") == "tool":
content = msg.get("content", "")
# Propagate tool_call_id so replayHistory can anchor the
# rendered output to the specific .ts-approval-tool element
# by data-call-id (mirrors the live appendToolOutput path).
# Without this, multi-tool batches render every result at
# the bottom of the block rather than under each header.
result_call_id = msg.get("tool_call_id")
if result_call_id:
entry["tool_call_id"] = str(result_call_id)
# Extract advisories from a wrapped ``<tool_output>`` envelope
# (Seam 1 queued-message splice). ``session.messages`` never
# carries an ``advisories`` key on its own — only
# ``decorate_history_messages`` mutates dicts to add it for
# the REST ``/history`` path, and the SSE replay surface
# bypasses that decoration entirely. Calling the same
# idempotent extraction helper here pins both surfaces to
# the same wire shape: cleaned content + extracted advisories
# ride as a user bubble after the tool block. No-ops cleanly
# for plain (unwrapped) content.
extracted_advisories: list[dict[str, str]] = []
if isinstance(content, str):
try:
extracted = extract_advisories_from_tool_envelope(content)
except Exception:
extracted = None
if extracted is not None:
cleaned, extracted_advisories = extracted
content = cleaned
entry["content"] = cleaned
elif isinstance(content, list):
# List-typed tool output (image / structured MCP
# results) carries any Seam 1 splice as an appended
# text part produced by ``wrap_tool_result("", ...)``
# — the inner cleaned content is empty by construction,
# so the part exists only to carry advisories. Walk
# the parts, extract advisories from any wrap
# envelope, and drop those parts from the projected
# list. Without this, the JS replay would render the
# raw envelope as a text bubble inside the tool block
# AND fail to render the queued message as a user
# bubble (no ``advisories`` array).
new_parts: list[Any] = []
changed = False
for part in content:
text = (
part.get("text")
if isinstance(part, dict) and part.get("type") == "text"
else None
)
if isinstance(text, str) and text.startswith("<tool_output>\n"):
try:
extracted = extract_advisories_from_tool_envelope(text)
except Exception:
extracted = None
# Drop the part ONLY when both (a) the parser
# accepted the envelope structure AND (b) the
# cleaned inner content is empty AND (c) at
# least one advisory came out. This is the
# signature of an injected
# ``wrap_tool_result("", advisories)`` carrier
# — the inner is empty by construction. A
# tool legitimately emitting an envelope with
# non-empty body or no advisory blocks is left
# in place rather than silently dropped.
if extracted is not None:
cleaned_text, advisories_from_part = extracted
if not cleaned_text and advisories_from_part:
extracted_advisories.extend(advisories_from_part)
changed = True
continue
new_parts.append(part)
if changed:
content = new_parts
entry["content"] = new_parts
if isinstance(content, str):
if content.startswith("Denied by user") or content.startswith("Blocked"):
entry["denied"] = True
# Use persisted flag if available, fall back to text
# heuristic for historical data that predates is_error.
if (
msg.get("is_error")
or content.startswith("Error")
or content.startswith("Command timed out")
or content.startswith("Search timed out")
or content.startswith("Unknown tool:")
or content.startswith("JSON parse error:")
or content.startswith("MCP prompt timed out")
or content.startswith("MCP prompt error")
):
entry["is_error"] = True
# Surface advisories on the wire so the JS replay can render
# them as user bubbles after the tool block. Project on a
# known set of keys — narrows the blast radius if a future
# producer stuffs sensitive fields into the dict. Mirrors
# the ``reminders`` filter above for the same reason. The
# extracted-from-envelope list is the production-realistic
# source: ``decorate_history_messages`` populates the
# ``advisories`` key only on the REST ``/history`` path, but
# ``session.messages`` (the SSE-replay source) never has it.
if extracted_advisories:
clean_advisories: list[dict[str, Any]] = []
for a in extracted_advisories:
if not isinstance(a, dict):
continue
atype = str(a.get("type") or "")
atext = str(a.get("text") or "")
if not atype or not atext:
continue
clean_advisories.append(
{
"type": atype,
"text": atext,
"priority": str(a.get("priority") or "notice"),
}
)
if clean_advisories:
entry["advisories"] = clean_advisories
history.append(entry)
# Propagate denial from tool results to their parent assistant entry.
last_assistant_idx: int | None = None
for idx, entry in enumerate(history):
if entry.get("tool_calls"):
last_assistant_idx = idx
elif entry.get("role") == "tool" and entry.get("denied") and last_assistant_idx is not None:
history[last_assistant_idx]["denied"] = True
# Mark last assistant tool call as pending if approval is outstanding.
if has_pending_approval:
for entry in reversed(history):
if entry.get("tool_calls"):
entry["pending"] = True
break
return history
# ---------------------------------------------------------------------------
# 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,
)
async def _interactive_events_replay_prepare(ws: Workstream, ui: Any, request: Request) -> None:
"""Async pre-step run before ``_interactive_events_replay`` iterates.
Loads ``intent_verdicts`` + ``output_assessments`` for the
workstream off the event loop (via ``asyncio.to_thread``) and
stashes the result on ``request.state.verdict_indexes``. The sync
replay generator reads from there and passes the dicts into
``_build_history`` so the storage I/O never blocks the event loop
on the SSE replay path.
Best-effort: if the workstream has no session or no ws_id, leaves
``request.state.verdict_indexes`` unset and ``_build_history``
falls back to the inline storage call (sync path).
"""
del ui # not needed; lookup is keyed on ws.session._ws_id
session = ws.session
if session is None:
return
ws_id = getattr(session, "_ws_id", "") or ""
if not ws_id:
return
indexes = await asyncio.to_thread(_load_verdict_indexes, ws_id)
request.state.verdict_indexes = indexes
def _interactive_events_replay(
ws: Workstream, ui: Any, request: Request
) -> Iterable[dict[str, Any]]:
"""Initial SSE replay payload for interactive ``events`` connections.
Pre-lift ``events_sse`` yielded five things on connect: a
``connected`` event with model + skip_permissions; a ``status``
event with the workstream's last token usage + context %; the
full conversation ``history`` (with pending-approval flagging on
the last assistant entry's tool calls); the pending approval
prompt + cached intent verdicts (if a prompt is pending); the
pending plan-review (if a review is pending). The lifted
``make_events_handler`` body delegates that yield sequence to
this callback so the kind-specific shape stays in this module.
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)
# History replay — pending-approval flag rides on the last
# assistant entry's tool_calls so the client renders them as
# awaiting approval rather than already approved. Verdict /
# assessment indexes were pre-loaded off the event loop by
# _interactive_events_replay_prepare; passing them in here keeps
# _build_history's storage I/O out of the sync generator path.
pending_approval = getattr(ui, "_pending_approval", None)
cached_indexes = getattr(request.state, "verdict_indexes", None)
if isinstance(cached_indexes, tuple) and len(cached_indexes) == 2:
verdicts, assessments = cached_indexes
else:
verdicts, assessments = None, None
history = _build_history(
session,
has_pending_approval=pending_approval is not None,
verdicts=verdicts,
assessments=assessments,
)
if history:
yield {"type": "history", "messages": history}
# Pending approval re-injection (so a reconnecting tab sees the
# prompt) + cached LLM verdicts received since the prompt fired.
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}
# Pending plan-review re-injection.
pending_plan = getattr(ui, "_pending_plan_review", None)
if pending_plan is not None:
yield pending_plan
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. Replay clear_ui + history onto the per-workstream UI listener
queue so a freshly-connected browser tab sees the conversation
state. 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"})
history = _build_history(session)
if history:
ui._enqueue({"type": "history", "messages": history})
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,
}
)
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,
"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,
)
# -- Atomic snapshot + 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
# Hold the listeners lock while building the snapshot AND registering.
# The fanout thread also acquires this lock when snapshotting the listener
# list, so events that land on global_queue during snapshot build will be
# distributed to our queue after we release — gap-free.
with listeners_lock:
snapshot = _build_node_snapshot(request.app.state)
listeners.append(client_queue)
async def event_generator() -> AsyncGenerator[dict[str, str], None]:
_metrics.record_sse_connect()
try:
# Emit snapshot as first event
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 {"data": json.dumps(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.memory import get_workstream_display_name
mgr: SessionManager = request.app.state.workstreams
# No per-user filter — see list_workstreams above for the rationale
# (trusted-team deployment shape; mutations stay owner-gated).
wss = mgr.list_all()
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,
"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)."""
import json as _json
from turnstone.core.storage._registry import get_storage
try:
storage = get_storage()
except Exception:
return JSONResponse({"error": "Storage not available"}, status_code=503)
rows = storage.list_prompt_templates()
skills = []
for r in rows:
if not r.get("enabled", True):
continue
tags: list[str] = []
with contextlib.suppress(ValueError, TypeError):
tags = _json.loads(r.get("tags", "[]"))
skills.append(
{
"name": r["name"],
"category": r.get("category", ""),
"description": r.get("description", ""),
"tags": tags,
"is_default": r.get("is_default", False),
"activation": r.get("activation", "named"),
"origin": r.get("origin", "manual"),
"author": r.get("author", ""),
"version": r.get("version", "1.0.0"),
}
)
return JSONResponse({"skills": skills})
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,
}
)
# Include effective defaults for clients (web UI, channel gateway).
cs = getattr(request.app.state, "config_store", None)
default_alias = ""
channel_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 ""
if not default_alias:
default_alias = registry.default
# Clear defaults that point to unknown/disabled aliases.
enabled_aliases = set(registry.list_aliases())
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 = ""
return JSONResponse(
{
"models": models,
"default_alias": default_alias,
"channel_default_alias": channel_default_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,
}
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")
async def plan_feedback(request: Request) -> JSONResponse:
"""POST /v1/api/plan — respond to a plan review."""
from turnstone.core.web_helpers import read_json_or_400
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
feedback = body.get("feedback", "")
ws_id = body.get("ws_id")
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)
ui.resolve_plan(feedback)
return JSONResponse({"status": "ok"})
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()
if cmd_word in ("/rewind", "/retry"):
from turnstone.core.auth import require_permission
err = require_permission(request, "conversation.modify")
if err:
ui.on_error("Permission denied: conversation.modify required")
return err
# Prevent rewind/retry while a generation is in progress.
# Gate on ``_worker_running`` (not ``worker_thread.is_alive()``)
# for parity with session_worker.send: spawn paths set the
# flag before assigning ws.worker_thread, so a reader using
# the old gate could see a stale dead thread while a new
# worker is in the middle of starting.
with ws._lock:
if ws._worker_running:
ui._enqueue(
{
"type": "busy_error",
"message": "Cannot rewind/retry while processing.",
}
)
return JSONResponse({"status": "busy"})
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":
ui._enqueue({"type": "clear_ui"})
history = await asyncio.to_thread(_build_history, ws.session)
if history:
ui._enqueue({"type": "history", "messages": history})
elif cmd_word in ("/rewind", "/retry"):
# Refresh frontend with truncated history
ui._enqueue({"type": "clear_ui"})
history = await asyncio.to_thread(_build_history, ws.session)
if history:
ui._enqueue({"type": "history", "messages": history})
# Audit trail
storage = getattr(request.app.state, "auth_storage", None)
if storage:
from turnstone.core.audit import record_audit
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
f"conversation.{cmd_word[1:]}",
"workstream",
ws.id,
{"command": cmd, "ws_id": ws.id},
ip,
)
# Dispatch deferred retry in background thread
retry_msg = ws.session._pending_retry
if retry_msg:
ws.session._pending_retry = None
session = ws.session
def run_retry() -> None:
me = threading.current_thread()
try:
session.send(retry_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")
finally:
with ws._lock:
ws._worker_running = False
# Inlined rather than via ``session_worker.send`` because
# retry-when-busy is a hard reject (UI error, no fallback
# queue) — the shared dispatcher's enqueue/spawn shape
# doesn't fit. We gate on ``_worker_running`` for parity
# with that dispatcher so the two paths can't race into
# parallel workers on the same ChatSession.
with ws._lock:
if ws._worker_running:
ui.on_error("Cannot retry: workstream is busy")
else:
ws._worker_running = True
t = threading.Thread(target=run_retry, daemon=True)
ws.worker_thread = t
t.start()
# 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 msg in reversed(session.messages):
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,
)
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,
)
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,
}
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, reserving any uploaded attachments for that first
turn.
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,
}
)
# 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):
ui._enqueue({"type": "clear_ui"})
history = await asyncio.to_thread(_build_history, ws.session)
if history:
ui._enqueue({"type": "history", "messages": history})
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 (
reserve_and_resolve_attachments as _reserve_and_resolve,
)
session = ws.session
send_id = uuid.uuid4().hex
resolved_atts: list[Any] = []
if attachment_ids:
resolved_atts, _ord, _drop = _reserve_and_resolve(attachment_ids, send_id, ws.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 attachment_ids:
from turnstone.core.memory import (
unreserve_attachments as _unreserve,
)
with contextlib.suppress(Exception):
_unreserve(send_id, ws.id, uid)
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:
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)
async def refresh_workstream_title(request: Request, ws_id: str = "") -> JSONResponse:
"""POST /v1/api/workstreams/{ws_id}/refresh-title — regenerate workstream title via LLM."""
from turnstone.core.log import get_logger
from turnstone.core.memory import get_workstream_display_name
log = get_logger(__name__)
ws_id = request.path_params.get("ws_id", "")
log.info("ws.title.refresh_requested", ws_id=ws_id[:8] if ws_id else "empty")
mgr = request.app.state.workstreams
_owner, err = _require_ws_access(request, ws_id, mgr=mgr)
if err:
return err
ws = mgr.get(ws_id)
if not ws or not ws.session:
log.warning(
"ws.title.refresh_failed",
ws_id=ws_id[:8] if ws_id else "empty",
reason="workstream_not_found",
)
return JSONResponse({"error": "Workstream not found or not active"}, status_code=404)
# Fetch current title so the LLM can generate something different
current_title = get_workstream_display_name(ws_id) or ""
log.info("ws.title.refresh_triggered", ws_id=ws_id[:8], current_title=current_title[:50])
ws.session.request_title_refresh(current_title)
return JSONResponse({"status": "ok"})
async def set_workstream_title(request: Request, ws_id: str = "") -> JSONResponse:
"""POST /v1/api/workstreams/{ws_id}/title — set workstream title manually.
Stores the user-chosen title as the workstream *alias* so it takes
priority over the LLM auto-generated title in the display name
fallback chain (alias -> title -> name).
"""
from turnstone.core.log import get_logger
from turnstone.core.memory import set_workstream_alias
from turnstone.core.web_helpers import read_json_or_400
log = get_logger(__name__)
ws_id = request.path_params.get("ws_id", "")
log.info("ws.title.set_requested", ws_id=ws_id[:8] if ws_id else "empty")
if not ws_id:
return JSONResponse({"error": "ws_id is required"}, status_code=400)
mgr = request.app.state.workstreams
_owner, err = _require_ws_access(request, ws_id, mgr=mgr)
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
title = str(body.get("title", "")).strip()
if not title:
return JSONResponse({"error": "title is required"}, status_code=400)
title = title[:80]
if not set_workstream_alias(ws_id, title):
log.warning("ws.title.set_alias_conflict", ws_id=ws_id[:8], title=title[:50])
return JSONResponse(
{"error": "That name is already used by another workstream"},
status_code=409,
)
log.info("ws.title.set_alias_updated", ws_id=ws_id[:8])
ws = mgr.get(ws_id)
if ws and ws.session and ws.session.ui:
ws.session.ui.on_rename(title)
log.info("ws.title.set_success", ws_id=ws_id[:8], title=title)
return JSONResponse({"status": "ok", "title": title})
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", "project", "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,
)
description = str(body.get("description", ""))
mem_type = str(body.get("type", "project"))
scope = str(body.get("scope", "global"))
scope_id = str(body.get("scope_id", ""))
if 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
# save_structured_memory normalises the name internally
from turnstone.core.memory import normalize_key
normalized_name = normalize_key(name)
memory_id, old_content = save_structured_memory(
name, content, description=description, mem_type=mem_type, scope=scope, scope_id=scope_id
)
if not memory_id:
return JSONResponse({"error": "Failed to save memory"}, status_code=500)
from turnstone.core.storage._registry import get_storage
storage = get_storage()
mem = storage.get_structured_memory(memory_id) if storage else None
if not mem:
return JSONResponse(
{"memory_id": memory_id, "name": normalized_name, "status": "saved"},
status_code=201,
)
status_code = 200 if old_content is not None else 201
return JSONResponse(mem, status_code=status_code)
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)
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)
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)
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)
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)
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)
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)
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)
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 plan/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."""
return _strip_server_status(mcp_mgr.get_server_status(name))
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": {}})
return JSONResponse(
{
"servers": {
name: _strip_server_status_for_read(status)
for name, status in mcp_mgr.get_all_server_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_plan_model: str | None,
base_task_model: str | None,
base_plan_effort: str | None,
base_task_effort: str | None,
) -> tuple[str, str | None, str | None, str | None, str | None]:
"""Compute (default, plan_model, task_model, plan_effort, 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_plan_model = base_plan_model
eff_task_model = base_task_model
eff_plan_effort = base_plan_effort
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_plan_alias = cs.get("model.plan_alias")
if cs_plan_alias and cs_plan_alias in base_models:
eff_plan_model = cs_plan_alias
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_plan_effort = cs.get("model.plan_effort")
if cs_plan_effort:
eff_plan_effort = cs_plan_effort
cs_task_effort = cs.get("model.task_effort")
if cs_task_effort:
eff_task_effort = cs_task_effort
return eff_default, eff_plan_model, eff_task_model, eff_plan_effort, eff_task_effort
def _broadcast_agent_tool_schema_refresh(app_state: Any) -> None:
"""Tell every active session on this node to re-render its plan_agent /
task_agent tool descriptions. 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.
"""
eff = _effective_routing(
cs,
registry.models,
registry.default,
registry.plan_model,
registry.task_model,
registry.plan_effort,
registry.task_effort,
)
if (
eff[0] != registry.default
or eff[1] != registry.plan_model
or eff[2] != registry.task_model
or eff[3] != registry.plan_effort
or eff[4] != registry.task_effort
):
registry.reload(
registry.models,
eff[0],
registry.fallback,
registry.agent_model,
plan_model=eff[1],
task_model=eff[2],
plan_effort=eff[3],
task_effort=eff[4],
)
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_plan_model, eff_task_model, eff_plan_effort, eff_task_effort = (
_effective_routing(
cs,
new_registry.models,
new_registry.default,
new_registry.plan_model,
new_registry.task_model,
new_registry.plan_effort,
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_plan_model == registry.plan_model
and eff_task_model == registry.task_model
and eff_plan_effort == registry.plan_effort
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,
plan_model=eff_plan_model,
task_model=eff_task_model,
plan_effort=eff_plan_effort,
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 plan_agent/task_agent
# `model` parameter descriptions reflect 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,
) -> None:
"""Reads events from the source queue and copies them to all listener queues."""
while True:
try:
event = source_queue.get()
with lock:
snapshot = list(listeners)
for lq in snapshot:
with contextlib.suppress(queue.Full):
lq.put_nowait(event) # 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,
),
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()
# Sweep stale attachment reservations left over from process crashes
# between reserve_attachments and consume/unreserve. Run once at
# startup (catches anything orphaned by the previous process), then
# periodically as defense-in-depth.
from turnstone.core.memory import sweep_orphan_reservations as _sweep_orphans
try:
n = await asyncio.to_thread(_sweep_orphans, _ORPHAN_SWEEP_THRESHOLD_S)
if n:
log.info("attachments.orphan_sweep.startup", swept=n)
except Exception:
log.warning("attachments.orphan_sweep.startup_failed", exc_info=True)
_orphan_sweep_stop = asyncio.Event()
async def _orphan_sweep_loop() -> None:
while not _orphan_sweep_stop.is_set():
try:
await asyncio.wait_for(_orphan_sweep_stop.wait(), timeout=_ORPHAN_SWEEP_INTERVAL_S)
return # stop event fired
except TimeoutError:
pass
try:
n = await asyncio.to_thread(_sweep_orphans, _ORPHAN_SWEEP_THRESHOLD_S)
if n:
log.info("attachments.orphan_sweep.periodic", swept=n)
except Exception:
log.warning("attachments.orphan_sweep.periodic_failed", exc_info=True)
_orphan_sweep_task = asyncio.create_task(_orphan_sweep_loop())
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)
# Stop the orphan-reservation sweep loop
_orphan_sweep_stop.set()
with contextlib.suppress(asyncio.CancelledError, Exception):
await _orphan_sweep_task
# 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()),
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_text_attachment as _classify_text_attachment,
)
from turnstone.core.attachments import (
sniff_image_mime as _sniff_image_mime,
)
from turnstone.core.attachments import (
upload_lock as _attachment_upload_lock,
)
interactive_attachment_helpers = AttachmentUploadHelpers(
sniff_image_mime=_sniff_image_mime,
classify_text_attachment=_classify_text_attachment,
upload_lock=_attachment_upload_lock,
)
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,
events_replay_prepare=_interactive_events_replay_prepare,
# 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,
)
approve_handler = make_approve_handler(interactive_endpoint_config)
close_handler = make_close_handler(
interactive_endpoint_config,
audit_emit=_audit_close_workstream,
supports_close_reason=True,
)
cancel_handler = make_cancel_handler(interactive_endpoint_config)
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,
)
list_handler = make_list_handler(interactive_endpoint_config)
saved_handler = make_saved_handler(interactive_endpoint_config)
history_handler = make_history_handler(interactive_endpoint_config)
detail_handler = make_detail_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_workstream_title,
set_title=set_workstream_title,
send=send_handler, # lifted: shared body (P1.5)
dequeue=dequeue_handler, # lifted (P1.5) — DELETE /send
approve=approve_handler, # lifted: shared body
cancel=cancel_handler, # lifted: shared body
events=events_handler, # lifted: shared body
history=history_handler, # lifted: shared body (interactive feature gain)
attachments=attachment_handlers, # lifted: shared body (P1.5)
),
)
v1_routes.append(Route("/api/dashboard", dashboard))
app = Starlette(
routes=[
Route("/", index),
Mount(
"/v1",
routes=[
*v1_routes,
Route("/api/skills", list_skills_summary),
Route("/api/models", list_available_models),
Route("/api/plan", plan_feedback, methods=["POST"]),
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/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/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
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 (no confirmation prompts)",
)
# 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(),
)
# 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"),
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"),
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,
) -> 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,
)
# 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 TLSClient
hostname = socket.gethostname()
fqdn = socket.getfqdn()
hostnames = [hostname, "localhost", "127.0.0.1"]
if fqdn != hostname:
hostnames.append(fqdn)
# Only add bind host if it's a concrete address
if args.host not in ("0.0.0.0", "::", ""):
hostnames.append(args.host)
# Additional SANs from env (e.g. Docker service name)
extra_sans = os.environ.get("TURNSTONE_TLS_SANS", "")
if extra_sans:
hostnames.extend(s.strip() for s in extra_sans.split(",") if s.strip())
tls_client = TLSClient(
storage=get_storage(),
hostnames=hostnames,
)
asyncio.run(tls_client.init())
bundle = tls_client.bundle
if bundle:
from lacme.mtls import write_pem_files_persistent
pem_paths = write_pem_files_persistent(
bundle,
ca_pem=tls_client.ca_pem,
)
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
# 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
log.info("TLS enabled — serving HTTPS")
else:
log.warning("TLS enabled but no cert available")
except Exception as exc:
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.run(app, host=args.host, port=args.port, log_level="warning", **ssl_kwargs)
if __name__ == "__main__":
main()