Files
turnstone/turnstone/core/workstream.py
T
Patrick Buckley 376da3d084 feat: prompt template tech debt — tests, read-only endpoints, double-… (#67)
* feat: prompt template tech debt — tests, read-only endpoints, double-load fix, server creation modal

Close test coverage gaps for prompt templates:
- Resume with deleted template: verifies graceful degradation (template_content=None, warning logged)
- Threading safety: concurrent set_template/init_system_messages with no race conditions
- Factory passthrough: template kwarg propagation through WorkstreamManager.create()

Add read-only template listing endpoints (read scope, no content exposed):
- GET /v1/api/templates — prompt template summaries (name, category, is_default, origin)
- GET /v1/api/ws-templates — enabled workstream template summaries (name, description, model)
- Available on both server and console; Python + TypeScript SDK methods added
- Console creation modal switched from admin endpoint to read-scope endpoint

Eliminate double-load inefficiency in workstream creation:
- Template validation moved before mgr.create() (no create-then-rollback on invalid template)
- template kwarg plumbed through WorkstreamManager.create() and session factory
- _SessionFactory Protocol added for proper mypy typing

Add workstream creation modal to server web UI:
- Name, model, template dropdown, ws_template/profile dropdown
- Instrument panel aesthetic: gradient top border, blur backdrop, amber accent
- Focus trap, Escape/Enter keyboard handling, loading state, error display
- WCAG AA contrast compliance, reduced-motion support

* fix: add list_ws_templates SDK methods + regenerate OpenAPI snapshots

Add list_ws_templates() to Python SDK (async + sync) and listWsTemplates()
to TypeScript SDK for the new GET /v1/api/ws-templates server endpoint.
Add WsTemplateSummary + ListWsTemplateSummaryResponse TypeScript types.
Regenerate openapi-server.json and openapi-console.json snapshots.

Addresses Copilot review feedback on PR #67.

* fix: skip template pre-validation when resuming a workstream

When resume_ws is set, the request's template field is irrelevant —
resume() restores the template from workstream_config. Pre-validating
a stale template name would incorrectly return 400 before the resume
even runs.

Addresses Copilot review feedback on PR #67.
2026-03-15 02:09:31 -07:00

360 lines
14 KiB
Python

"""Workstream manager — concurrent independent conversations.
A workstream is an independent conversation with its own ChatSession and UI
adapter. The WorkstreamManager coordinates multiple workstreams, tracks their
states, and lets frontends (CLI, Web) multiplex user attention across them.
"""
from __future__ import annotations
import enum
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Protocol
from turnstone.core.session import ChatSession, SessionUI
class _SessionFactory(Protocol):
def __call__(
self,
ui: SessionUI | None,
model_alias: str | None = ...,
ws_id: str | None = ...,
*,
template: str | None = ...,
) -> ChatSession: ...
# ---------------------------------------------------------------------------
# State enum
# ---------------------------------------------------------------------------
class WorkstreamState(enum.Enum):
IDLE = "idle" # waiting for user input
THINKING = "thinking" # LLM is streaming
RUNNING = "running" # tools executing
ATTENTION = "attention" # blocked on approval / plan review
ERROR = "error" # last operation failed
# ---------------------------------------------------------------------------
# Workstream dataclass
# ---------------------------------------------------------------------------
@dataclass
class Workstream:
id: str = field(default_factory=lambda: uuid.uuid4().hex)
name: str = ""
state: WorkstreamState = WorkstreamState.IDLE
session: ChatSession | None = None
ui: SessionUI | None = None
worker_thread: threading.Thread | None = None
error_message: str = ""
last_active: float = field(default_factory=time.monotonic, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
def __post_init__(self) -> None:
if not self.name:
self.name = f"ws-{self.id[:4]}"
# ---------------------------------------------------------------------------
# Manager
# ---------------------------------------------------------------------------
class WorkstreamManager:
"""Manages multiple concurrent workstreams, each with its own ChatSession."""
def __init__(
self,
session_factory: _SessionFactory,
*,
max_workstreams: int = 10,
node_id: str | None = None,
):
"""
Args:
session_factory: callable(ui, model_alias, ws_id, *, template) -> ChatSession.
Captures shared config (registry, temperature, …) so the
manager can create ChatSession instances without knowing
those details. *model_alias* selects a model from the
registry (None = default). *ws_id* is the persistent
identity used for all storage operations.
max_workstreams: Maximum number of concurrent workstreams. When at
capacity, ``create()`` will auto-evict the oldest IDLE
workstream before raising.
node_id: Server node identity (persisted with workstreams).
"""
if max_workstreams < 1:
raise ValueError(f"max_workstreams must be >= 1, got {max_workstreams}")
self._session_factory: _SessionFactory = session_factory
self._node_id = node_id
self._max_workstreams: int = max_workstreams
self._workstreams: dict[str, Workstream] = {}
self._order: list[str] = [] # creation order
self._active_id: str | None = None
self._lock = threading.Lock()
self._on_state_change: Callable[[str, WorkstreamState], None] | None = None
self._evictions: int = 0
self._last_evicted: Workstream | None = None
@property
def eviction_count(self) -> int:
"""Number of workstreams auto-evicted by ``create()``."""
return self._evictions
@property
def last_evicted(self) -> Workstream | None:
"""The most recently evicted workstream, or ``None``."""
return self._last_evicted
# -- creation / destruction ---------------------------------------------
def create(
self,
name: str = "",
ui_factory: Callable[..., SessionUI] | None = None,
model: str | None = None,
template: str | None = None,
) -> Workstream:
"""Create a new workstream. Returns the new ws.
If the manager is at capacity, the oldest IDLE workstream is
automatically evicted. If **all** workstreams are non-idle, a
``RuntimeError`` is raised.
Args:
model: Optional model alias from the registry. ``None`` uses the
default model.
template: Optional prompt template name passed through to session
factory.
"""
# Fast-fail capacity check (avoids expensive ChatSession creation when full).
first_evicted: Workstream | None = None
with self._lock:
if len(self._workstreams) >= self._max_workstreams:
first_evicted = self._evict_oldest_idle_locked()
if first_evicted is None:
raise RuntimeError(f"All {self._max_workstreams} workstreams are active")
# Cleanup first-phase eviction outside the lock (may trigger callbacks).
if first_evicted is not None:
self._cleanup_ui(first_evicted)
self._last_evicted = first_evicted
from turnstone.core.metrics import metrics as _m1
_m1.record_eviction()
# Create workstream and ChatSession outside the lock (construction is
# expensive — involves LLM client setup and DB writes).
ws = Workstream(name=name)
if ui_factory:
ws.ui = ui_factory(ws.id)
ws.session = self._session_factory(ws.ui, model, ws.id, template=template)
# Authoritative insert under lock with re-check (another thread may
# have filled capacity while we were unlocked).
second_evicted: Workstream | None = None
with self._lock:
if len(self._workstreams) >= self._max_workstreams:
second_evicted = self._evict_oldest_idle_locked()
if second_evicted is None:
raise RuntimeError(f"All {self._max_workstreams} workstreams are active")
self._workstreams[ws.id] = ws
self._order.append(ws.id)
if self._active_id is None:
self._active_id = ws.id
# Persist to storage only after successful insertion
from turnstone.core.memory import register_workstream
register_workstream(ws.id, node_id=self._node_id, name=ws.name)
# Cleanup second-phase eviction outside the lock.
if second_evicted is not None:
self._cleanup_ui(second_evicted)
self._last_evicted = second_evicted
from turnstone.core.metrics import metrics as _m2
_m2.record_eviction()
return ws
# -- eviction helpers ---------------------------------------------------
def _evict_oldest_idle_locked(self) -> Workstream | None:
"""Find and remove the oldest IDLE workstream.
**Must be called while ``self._lock`` is held.** Returns the evicted
``Workstream`` (caller is responsible for UI cleanup) or ``None`` if no
IDLE workstreams exist.
"""
oldest: Workstream | None = None
for wid in self._order:
ws = self._workstreams[wid]
if ws.state == WorkstreamState.IDLE and (
oldest is None or ws.last_active < oldest.last_active
):
oldest = ws
if oldest is None:
return None
del self._workstreams[oldest.id]
self._order.remove(oldest.id)
if self._active_id == oldest.id:
self._active_id = self._order[0] if self._order else None
self._evictions += 1
return oldest
@staticmethod
def _cleanup_ui(ws: Workstream) -> None:
"""Unblock pending approval/plan/foreground events on a workstream."""
if ws.ui:
if hasattr(ws.ui, "_approval_event"):
ws.ui._approval_result = False, None # type: ignore[attr-defined]
ws.ui._approval_event.set()
if hasattr(ws.ui, "_plan_event"):
ws.ui._plan_result = "reject" # type: ignore[attr-defined]
ws.ui._plan_event.set()
if hasattr(ws.ui, "_fg_event"):
ws.ui._fg_event.set()
# Notify SSE listeners so generators exit promptly
if hasattr(ws.ui, "_listeners_lock"):
import contextlib
import queue as _queue
with ws.ui._listeners_lock:
for lq in ws.ui._listeners: # type: ignore[attr-defined]
try:
lq.put_nowait({"type": "ws_closed"})
except _queue.Full:
with contextlib.suppress(_queue.Empty):
lq.get_nowait()
with contextlib.suppress(_queue.Full):
lq.put_nowait({"type": "ws_closed"})
ws.ui._listeners.clear() # type: ignore[attr-defined]
# Release MCP listener registration
if ws.session and hasattr(ws.session, "close"):
ws.session.close()
def close(self, ws_id: str) -> bool:
"""Close a workstream. Returns False if it's the last one."""
with self._lock:
if len(self._workstreams) <= 1:
return False
ws = self._workstreams.pop(ws_id, None)
if ws is None:
return False
self._order.remove(ws_id)
if self._active_id == ws_id:
self._active_id = self._order[0]
# Unblock any waiting approval/plan events so worker thread can exit
self._cleanup_ui(ws)
from turnstone.core.memory import update_workstream_state
update_workstream_state(ws_id, "closed")
return True
# -- lookup -------------------------------------------------------------
def get(self, ws_id: str) -> Workstream | None:
with self._lock:
return self._workstreams.get(ws_id)
@property
def active_id(self) -> str | None:
return self._active_id
def get_active(self) -> Workstream | None:
with self._lock:
return self._workstreams.get(self._active_id) if self._active_id else None
def list_all(self) -> list[Workstream]:
"""Return workstreams in creation order."""
with self._lock:
return [self._workstreams[wid] for wid in self._order if wid in self._workstreams]
def index_of(self, ws_id: str) -> int:
"""1-based index of a workstream, or 0 if not found."""
with self._lock:
try:
return self._order.index(ws_id) + 1
except ValueError:
return 0
@property
def count(self) -> int:
with self._lock:
return len(self._workstreams)
# -- switching ----------------------------------------------------------
def switch(self, ws_id: str) -> Workstream | None:
"""Switch active workstream. Returns new active or None."""
with self._lock:
if ws_id in self._workstreams:
self._active_id = ws_id
return self._workstreams[ws_id]
return None
def switch_by_index(self, index: int) -> Workstream | None:
"""Switch by 1-based index (creation order)."""
with self._lock:
if 1 <= index <= len(self._order):
ws_id = self._order[index - 1]
self._active_id = ws_id
return self._workstreams.get(ws_id)
return None
# -- state management ---------------------------------------------------
def set_state(self, ws_id: str, state: WorkstreamState, error_msg: str = "") -> None:
"""Update a workstream's state. Called by UI adapters."""
ws = self._workstreams.get(ws_id)
if ws:
with ws._lock:
ws.state = state
ws.last_active = time.monotonic()
ws.error_message = error_msg
from turnstone.core.memory import update_workstream_state
update_workstream_state(ws_id, state.value)
if self._on_state_change:
self._on_state_change(ws_id, state)
def close_idle(self, max_age_seconds: float) -> list[str]:
"""Close IDLE workstreams inactive for more than *max_age_seconds*.
Skips the last workstream and any workstream not in IDLE state.
Returns a list of closed ws_ids.
"""
now = time.monotonic()
with self._lock:
snapshot = list(self._workstreams.values())
expired = sorted(
[
ws
for ws in snapshot
if ws.state == WorkstreamState.IDLE and (now - ws.last_active) > max_age_seconds
],
key=lambda ws: ws.last_active, # oldest first
)
# Never leave zero workstreams
max_closeable = max(0, len(snapshot) - 1)
to_close = [ws.id for ws in expired[:max_closeable]]
closed = []
for ws_id in to_close:
ws = self._workstreams.get(ws_id)
# Re-check state to guard against race between collection and close
if ws and ws.state == WorkstreamState.IDLE and self.close(ws_id):
closed.append(ws_id)
return closed