Compare commits

..

15 Commits

Author SHA1 Message Date
Patrick Buckley 498f23c19e Bump version to 0.3.5 2026-03-04 00:21:28 -08:00
Patrick Buckley e057c364b8 Fix console proxy regressions and add workstream task field (#21) (#21)
* Fix console proxy regressions and add workstream task field (#21)

Bug fixes:
- Fix collector polling unversioned /api/dashboard (404 after API
  versioning PR) — nodes showed red/unreachable, no workstreams
- Fix SSE proxy dropping all data events — upstream sends \r\n line
  endings but proxy split on \n\n only; normalize before parsing
- Fix workstream state stuck on idle — on_state_change() only
  broadcasted via SSE but never updated ws.state on the Workstream
  object; dashboard polling now sees correct attention/running states
- Fix deep-link switchTab early return — when ?ws_id matched the
  only workstream, switchTab bailed (wsId === currentWsId) before
  establishing SSE connection; inline init instead of delegating
- Fix console banner covering dashboard overlay — inject <style>
  offsetting .dashboard-overlay below the 32px banner

Enhancements:
- Add turnstone branding to console proxy banner (turnstone │ Console │ node-id)
- Add initial_message field to CreateWorkstreamMessage protocol and
  console "New Workstream" modal (Task textarea, sent as first message)
- Refactor SSE proxy to use shared httpx client with 30s read timeout
  instead of per-request client creation
- Increase approval timeout default from 300s to 3600s (1 hour)

Updated: Python SDK, TypeScript SDK, OpenAPI specs, MQ client,
API schemas, MQ protocol diagram, SDK docs.

* Address PR #21 review feedback (4 items)

- Log unknown state strings in on_state_change instead of silently
  swallowing; remove unnecessary KeyError catch
- Wrap initial_message POST in _handle_create_ws with error handling
  so workstream creation success isn't masked by send failure
- Strip all \r from SSE chunks instead of replacing \r\n, fixing
  chunk-boundary split edge case
- Add tests for initial_message wiring in directed and pool targeting

* Refactor SSE proxy to use httpx-sse aconnect_sse

Replace manual SSE chunk buffering/parsing with httpx_sse.aconnect_sse()
which handles line endings, event types, and all SSE spec edge cases.
Eliminates the \r\n chunk-boundary bug class entirely. Event types are
now always forwarded (sse.event defaults to "message" per spec).
2026-03-04 00:18:48 -08:00
Patrick Buckley e785a94539 Fix Alembic migration auth failure with PostgreSQL
str(engine.url) in SQLAlchemy 2.x masks the password as '***',
causing SCRAM-SHA-256 authentication to fail when Alembic creates
its own engine from the config URL.
2026-03-03 23:09:31 -08:00
Patrick Buckley 2b58c127b1 Add pluggable storage backend (SQLite + PostgreSQL) and deployment packaging (#20)
* Add pluggable storage backend (SQLite + PostgreSQL) and deployment packaging

Database abstraction: StorageBackend protocol with 21 methods, SQLAlchemy Core
schema, SQLite backend (FTS5), PostgreSQL backend (tsvector/ILIKE), Alembic
migrations, singleton registry. memory.py reduced to thin facade. Session.py
open_db() calls replaced with generic KV methods. [database] config section
with env var support.

Deployment: Docker Compose production profile with PostgreSQL, Dockerfile with
postgres extras and migration entrypoint, Helm chart with bitnami subcharts,
Terraform AWS ECS/Fargate module with RDS + ElastiCache + ALB.

39 new storage tests (934 total). mypy strict clean. Docs and diagrams updated.

* Address PR #20 review feedback (16 items)

- Backends only call create_all() when Alembic migrations are disabled
- Helm configmap uses correct TURNSTONE_DB_BACKEND env var; DB URL
  constructed via env expansion with secret reference instead of ConfigMap
- Migration errors fail fast for PostgreSQL (only non-fatal for SQLite)
- save_memory/delete_memory wrapped in exception handling like other facade fns
- pool_size passed through from config/env to init_storage() in cli + server
- Terraform: DB URL moved to Secrets Manager, auth enabled flag set,
  optional TLS listeners with certificate_arn, Redis transit encryption on
- Docker entrypoint no longer suppresses migration output
- Diagram fixes: removed StaticPool claim, removed non-existent migration ref
- compose.yaml/README: clarified production profile requires DB env vars
2026-03-03 22:57:34 -08:00
Patrick Buckley 5ee539c983 Add Python and TypeScript client SDKs for server and console APIs (#19)
* Add Python and TypeScript client SDKs for server and console APIs

Python SDK (turnstone/sdk/) with sync + async clients for both server
and console APIs. Returns Pydantic models directly, streams SSE events
as typed dataclasses. 27 event types with registry-based deserialization.
High-level send_and_wait() for request-response patterns.

TypeScript SDK (sdk/typescript/) with zero browser dependencies. Uses
fetch + ReadableStream for SSE parsing. Discriminated union event types
with type guards. Same API surface as Python SDK.

63 Python tests, 21 TypeScript tests (vitest). Comprehensive docs at
docs/sdk.md with SDK architecture diagram.

* Address PR #19 review feedback + fix lint

- Fix consume_task leak in send_and_wait when send() raises (try/finally)
- Fix TS sendAndWait: open SSE before send, plumb AbortSignal for timeout
- Add signal param to TS streamSSE for cancellation support
- Fix SSE parser: join multi-line data: fields with \n per spec, handle CRLF
- Fix generate-types.py sys.path (parents[3] not parents[2])
- Document token ignored when httpx_client provided
- Document TS timeout units as milliseconds
- Fix stale docstring in test_sdk_sse.py
- Fix import sorting (ruff I001)
2026-03-03 21:22:24 -08:00
Patrick Buckley 62a4ceac96 Dev/api versioning openapi (#18)
* Add API versioning under /v1/ prefix with OpenAPI 3.1 spec

All API endpoints move to /v1/api/* (clean break, no unversioned
aliases). Non-API routes (/, /health, /metrics, /static, /shared,
/node proxy) stay unversioned.

New turnstone/api/ package:
- Pydantic v2 models for all request/response schemas (server +
  console) used for OpenAPI spec generation
- Programmatic OpenAPI 3.1 spec builder with EndpointSpec catalog
- /openapi.json serves machine-readable spec, /docs serves Swagger UI

Route changes:
- Both servers use Mount("/v1", routes=[...API routes...])
- Auth middleware strips /v1/ prefix before path classification
  (PUBLIC_PATHS/WRITE_PATHS stay unversioned internally)
- Console proxy handles /node/{id}/v1/api/ upstream forwarding
- Bridge and CLI HTTP clients updated to /v1/api/ paths
- /openapi.json and /docs added to PUBLIC_PATHS and rate limiter
  EXEMPT_PATHS

Security fix from review: required_role() now correctly handles
/node/{id}/v1/api/{path} proxy routes (previously the v1 segment
caused write-path detection to fail, allowing read-only token
escalation).

42 new tests (830 total). All frontend JS, docs, and diagrams updated.

* Fix mypy type errors in turnstone/api/ package

- Add generic type params to dict fields in console_schemas.py
- Add return type annotations to docs.py handler factories
- Move type-only imports (BaseModel, Callable, Awaitable) into
  TYPE_CHECKING blocks to satisfy TC002/TC003 ruff rules

* Address PR #18 review feedback + fix mypy errors

Review fixes:
- Add pydantic>=2.0 as explicit dependency in pyproject.toml
  (was only transitively available via openai/mcp)
- Auto-detect path parameters from {param} segments in OpenAPI
  spec builder (fixes missing required path params)
- Use startswith() with concrete prefix for proxy version
  detection instead of fragile substring check
- Make Swagger UI base URL configurable via swagger_ui_base_url
  parameter for air-gapped deployments

Mypy fixes:
- Add generic type params to dict fields in console_schemas
- Add return type annotations to docs.py handler factories
- Move type-only imports into TYPE_CHECKING blocks
2026-03-03 20:28:49 -08:00
Patrick Buckley 29c00c0cdf Extract shared frontend design system into turnstone/shared_static/ (#17)
* Extract shared frontend design system into turnstone/shared_static/

The server UI and console UI had ~60% CSS overlap and significant JS
duplication. Extract shared assets into a new turnstone/shared_static/
package mounted at /shared/ in both servers:

- base.css: design tokens, reset, typography, login/toast/kb overlays,
  dashboard table, state dots, health bar, scrollbar, reduced motion
- auth.js: authFetch, login overlay with focus trap, logout (hooks for
  page-specific post-login/logout callbacks)
- theme.js: dark/light toggle with system preference detection
- toast.js: notification queue with configurable timeout
- utils.js: escapeHtml, formatTokens, ctxClass, formatUptime, formatCount
- kb.js: keyboard shortcuts overlay with configurable content, focus
  management, and focus restore on dismiss

Console proxy updated: JS shim injection moved from proxy_static (app.js
prepend) to proxy_index (inline <script> in HTML) so it runs before any
external scripts. New /shared/ path rewriting and proxy_shared_static
route added. ~1540 lines removed from page-specific files, 775 lines in
shared package. 13 new tests (788 total).

* Fix /shared/ auth and remove __init__.py from shared_static

Address PR #17 review feedback:

1. Add /shared/ to PUBLIC_PREFIXES in auth.py so shared CSS/JS
   loads before authentication (required for login overlay to render)

2. Remove turnstone/shared_static/__init__.py to prevent exposing
   Python package internals (__init__.py, __pycache__) via the
   StaticFiles mount. Not needed for packaging since pyproject.toml
   uses explicit glob includes.

3 new auth tests for /shared/ public path access.
2026-03-03 20:11:49 -08:00
Patrick Buckley b6e0f0fcca Add node version tracking and drift detection to console dashboard (#16)
* Add node version tracking and drift detection to console dashboard

Surface the version field from each node's /health endpoint in the
console dashboard. Collector extracts version into get_overview()
(version_drift + versions fields), promotes it to top-level in
get_nodes(), and adds get_version_info() for per-node detail. Console
/health endpoint includes drift fields.

Frontend adds a VER column to the 7-column node table grid, shows
per-node version strings, tracks versions per group with "mixed" +
yellow drift badge when nodes disagree, and displays a DRIFT warning
or single version in the status bar. Column hidden on mobile (<700px).
ARIA labels include version info for accessibility.

10 new tests (745 total). Docs and diagram updated.

* Fix drift tooltip text: show 'Versions detected' not 'Nodes running'
2026-03-03 18:57:30 -08:00
Patrick Buckley 206e37e73e Fix circuit breaker, rate limiter, and Anthropic web search correctne… (#15)
* Fix circuit breaker, rate limiter, and Anthropic web search correctness (#15)

Three tech debt items addressing correctness and security gaps:

Circuit breaker HALF_OPEN single-request permit:
- Rename should_allow_request property to acquire_request_permit() method
  to make the side-effecting, non-idempotent nature explicit
- Add _half_open_permit flag: exactly one probe request in HALF_OPEN,
  subsequent callers blocked until probe completes
- Explicitly reset permit on all state transitions (record_success,
  record_failure) for clean state machine invariants
- Session uses BaseException catch to ensure record_failure always fires,
  preventing permanent circuit deadlock on probe crash

Rate limiter X-Forwarded-For support:
- Add resolve_client_ip() with rightmost-untrusted XFF parsing
- Configurable trusted_proxies via --ratelimit-trusted-proxies CLI flag
  and [ratelimit] trusted_proxies config (comma-separated CIDRs)
- IPv4-mapped IPv6 normalization (::ffff:x.x.x.x → IPv4) for dual-stack
- Clientless requests (request.client is None) pass through instead of
  sharing a single "unknown" bucket
- Log warning for invalid CIDR entries in trusted_proxies config
- Show trusted proxies in startup log when enabled

Anthropic web search multi-turn encrypted content:
- Capture raw provider content blocks during streaming via _block_to_dict()
  using model_dump(exclude_none=True) to avoid Anthropic API rejection
- Accumulate thinking_delta into raw_blocks (was silently empty on replay)
- Store _provider_content on assistant messages, pass through verbatim in
  _convert_messages() so encrypted_content/encrypted_index survive turns
- Persist to SQLite via new provider_data column (auto-migrated)
- Add thinking/signature to _block_to_dict fallback attribute list

23 new tests (735 total), ruff + mypy clean.

* Fix Copilot PR #15 review issues: provider data, circuit breaker, IP normalization

- Persist assistant message when provider_data exists even if text
  content is empty — prevents losing Anthropic web search encrypted
  content needed for multi-turn replay (session.py)
- Re-raise KeyboardInterrupt/SystemExit immediately after recording
  failure instead of attempting fallback models (session.py)
- Consume HALF_OPEN permit for the transition caller — prevents two
  concurrent probe requests when only one should be allowed
  (healthcheck.py)
- Normalize IPv4-mapped IPv6 addresses consistently in
  resolve_client_ip() — prevents duplicate rate-limit buckets for
  ::ffff:x.x.x.x vs x.x.x.x (ratelimit.py)
2026-03-03 18:28:39 -08:00
Patrick Buckley 6c5441435b Add console workstream creation + server reverse proxy (#14)
* Add console workstream creation + server reverse proxy (#14)

Enable the console dashboard to create workstreams and proxy server UIs,
so users only need network access to the console port.

Workstream creation via MQ:
- POST /api/cluster/workstreams/new with three targeting modes:
  specific node (directed queue), auto (best node by capacity),
  or general pool (shared queue, any bridge picks up)
- Console pushes CreateWorkstreamMessage to Redis; bridge handles
  the rest (server creation, ownership registration, SSE events)

Reverse proxy for server UIs:
- /node/{node_id}/ serves the server's HTML with static path rewriting
  and a console-return banner injected after <body>
- JS proxy shim prepended to app.js overrides fetch() and EventSource()
  to route root-relative URLs through /node/{id}/api/...
- SSE streams proxied via httpx.AsyncClient(timeout=None) with per-
  connection clients for long-lived streams
- GET/POST API requests forwarded with body and auth token

Security:
- Proxy write paths checked against WRITE_PATHS to prevent read-token
  escalation (read tokens cannot POST /api/send through proxy)
- html.escape() on node_id in banner HTML to prevent XSS
- String length limits on name/model inputs

Frontend:
- "+ new" button in header opens creation modal with node dropdown
  (Auto / General pool / specific nodes with capacity display)
- Modal has focus trap, backdrop dismiss, scroll lock, keyboard handling
- Workstream rows and node links deep-link via proxy paths
- Custom select arrow, Instrument Panel modal styling

Documentation:
- docs/console.md rewritten with proxy and creation API docs
- docs/architecture.md console section updated
- PlantUML diagrams 01, 11, 12 updated + PNGs re-rendered
- README.md updated

28 new tests (741 total), ruff + mypy clean.

* Fix Copilot PR #14 review issues: auth bypass, XSS, proxy robustness

- Normalize trailing slashes in required_role() to prevent write-role
  bypass via /api/send/ or /node/{id}/api/send/ (auth.py)
- Validate node_id format in proxy handlers (alphanumeric, dot, dash,
  underscore only) to prevent injection vectors
- Use json.dumps() for JS proxy shim prefix to prevent script injection
- URL-quote node_id in HTML attribute contexts (proxy_index, proxy_static)
- Check upstream status in _proxy_sse() — emit error event on non-200
  instead of keeping a dead SSE connection open
- Check upstream status in proxy_index() — propagate non-2xx errors
- Forward query string in _proxy_post() (consistency with _proxy_get)
- Handle JSON null values in create_workstream() — treat null as empty,
  reject non-string types with 400
- Fix docs/diagram LPUSH → RPUSH to match actual broker implementation
2026-03-03 18:25:18 -08:00
Patrick Buckley f02972c11d Add provider-native web search with Tavily fallback (#13)
* Add provider-native web search with Tavily fallback

Replace client-side Tavily web search with provider-native implementations:

- Anthropic: inject web_search_20250305 server-side tool, handle
  server_tool_use / web_search_tool_result streaming blocks, emit
  info_delta for search status display
- OpenAI: inject web_search_options for gpt-5-search-api, format
  url_citation annotations as footnote sources
- Local/vLLM: preserve existing Tavily-based web_search tool as fallback

Add supports_web_search to ModelCapabilities and info_delta to StreamChunk.
Remove end-of-life GPT-4o model entries from capability tables.
Update docs, diagrams, and README. 88 provider tests (32 new).

* Fix Copilot PR #13 review: capture streaming url_citation annotations

Accumulate url_citation annotations during OpenAI streaming and emit
formatted citations as a final info_delta chunk after the stream ends.
Previously annotations were only captured in non-streaming mode, so
search model users in the interactive path never saw citation sources.
2026-03-03 17:12:49 -08:00
Patrick Buckley d28879208f Add multi-provider LLM adapter with model capability flags (#12)
* Add multi-provider LLM adapter with model capability flags

Introduce a provider abstraction layer between ChatSession and LLM SDK
clients, enabling native support for Anthropic alongside OpenAI-compatible
APIs. Each provider translates at the API boundary while the internal
message format remains OpenAI-like throughout session history and persistence.

- LLMProvider protocol with StreamChunk/CompletionResult normalized types
- OpenAIProvider: GPT-4o, GPT-5.x, O-series capability tables with
  conditional temperature, reasoning_effort, and token param handling
- AnthropicProvider: native streaming, message/tool format conversion,
  adaptive vs manual thinking modes, effort parameter for 4.6 models
- ModelCapabilities per-model flags: temperature support, token param name,
  thinking mode, effort levels, context window, max output
- Smart auto-detect: latest Opus for Anthropic, latest base GPT for OpenAI
- --provider CLI flag for both turnstone and turnstone-server
- anthropic SDK as optional dependency (pip install turnstone[anthropic])
- 56 new provider tests, 672 total passing
- Updated architecture docs and 4 PlantUML diagrams

* Fix Copilot PR #12 review: reasoning_effort gating, Anthropic thinking, provider factory

- Default reasoning_effort_values to () so unknown/local models don't
  receive unsupported top-level reasoning_effort param. Models that need
  it (GPT-5.x, search models) have explicit capability declarations.
- Fix Anthropic _reasoning_params: "none" and "" effort now return {}
  instead of enabling thinking with 4096 budget.
- Use create_provider("openai") singleton instead of OpenAIProvider()
  in ChatSession fallback for consistency with registry path.
- Add 8 parameter gating tests: unknown model no reasoning_effort,
  GPT-5 no temperature, GPT-5.1 conditional temperature, O-series
  no temperature, Anthropic none/empty/low effort.
2026-03-03 16:52:02 -08:00
Patrick Buckley a1f00092f5 Migrate HTTP servers from stdlib to Starlette/ASGI + uvicorn (#11)
* Migrate HTTP servers from stdlib to Starlette/ASGI + uvicorn

Replace Python stdlib http.server (ThreadedHTTPServer, BaseHTTPRequestHandler)
with Starlette ASGI applications served by uvicorn across all three HTTP
entry points. SSE endpoints use sse-starlette EventSourceResponse with
async generators that bridge sync queue.Queue via run_in_executor().
Bridge SSE parser replaced with httpx-sse EventSource.

- turnstone/server.py: Starlette app factory with create_app(), pure ASGI
  middleware (auth, rate limit, metrics, CORS), async route handlers,
  lifespan context manager for startup/shutdown. WebUI and ChatSession
  remain fully synchronous — worker threads unchanged.
- turnstone/console/server.py: Same pattern, simpler (no ChatSession).
  Path params replace manual string slicing for node detail route.
- turnstone/mq/bridge.py: _iter_sse_data() uses httpx_sse.EventSource
  instead of hand-rolled line parser.
- Tests: All ThreadedHTTPServer fixtures replaced with
  starlette.testclient.TestClient via create_app() factories.
- Docs: Updated architecture.md, api-reference.md, README.md, and
  PlantUML diagrams (03, 11) + regenerated PNGs.

* Fix Copilot PR #11 review: TestClient cleanup, JSON error handling, SSE timeout

- Close TestClient in teardown for TestConsoleAuth and TestConsoleLogin to avoid lifespan/resource leaks
- Close TestClient via yield/finally in TestConsoleHTTPEndpoints fixture
- Add _read_json() helper for safe JSON body parsing (returns {} on invalid JSON instead of 500, matching old stdlib handler behavior)
- Apply same try/except pattern to console auth_login endpoint
- Increase SSE queue.get timeout from 1s to 5s to align with sse-starlette ping interval, reducing executor task churn
2026-03-02 23:34:22 -08:00
Patrick Buckley bb3b735d69 Refactor detect_model into shared function, auto-detect context window
Move detect_model() to turnstone.core.model_registry as a single
implementation replacing duplicates in cli.py, server.py, and eval.py.
Auto-detects context window from backend metadata (meta.n_ctx_train)
when available, falling back to the 131072 default otherwise.

Also replace vLLM-specific references in help text and comments with
generic "OpenAI-compatible API" / "model server" language.
2026-03-02 23:28:01 -08:00
Patrick Buckley 87ffad18c2 Use system role instead of developer for broader model compatibility
The developer message role is not supported by all chat templates
(e.g. Qwen). Switch to the standard system role which is universally
supported by OpenAI-compatible APIs.
2026-03-02 23:22:30 -08:00
166 changed files with 21161 additions and 4039 deletions
+9
View File
@@ -12,3 +12,12 @@ venv/
.mypy_cache/
.ruff_cache/
.hypothesis/
deploy/
docs/
tests/
sdk/
*.md
!README.md
!LICENSE
.coverage
.swp
+20 -77
View File
@@ -1,85 +1,28 @@
# =============================================================================
# Turnstone Docker Compose — Environment Configuration
# Copy to .env and fill in your values: cp .env.example .env
# Turnstone Environment Variables
# Copy to .env and adjust values for your deployment
# =============================================================================
# ---------------------------------------------------------------------------
# LLM Backend
# ---------------------------------------------------------------------------
# OpenAI-compatible API URL (vLLM, llama.cpp, OpenAI, etc.)
# -- LLM Backend --------------------------------------------------------------
LLM_BASE_URL=http://host.docker.internal:8000/v1
OPENAI_API_KEY=sk-...
# ANTHROPIC_API_KEY=sk-ant-... # Set instead for Anthropic provider
# TAVILY_API_KEY=tvly-... # For web search fallback (local models only)
# API key for the LLM backend ("dummy" for local servers without auth)
OPENAI_API_KEY=dummy
# -- Database (production profile) --------------------------------------------
# DB_BACKEND=postgresql
# POSTGRES_USER=turnstone
# POSTGRES_PASSWORD=changeme
# DATABASE_URL=postgresql+psycopg://turnstone:changeme@postgres:5432/turnstone
# Tavily API key for web_search tool (optional)
TAVILY_API_KEY=
# -- Redis ---------------------------------------------------------------------
# REDIS_PASSWORD=
# REDIS_PORT=6379
# ---------------------------------------------------------------------------
# Redis
# ---------------------------------------------------------------------------
# Redis password (leave empty for no authentication)
REDIS_PASSWORD=
# -- Authentication ------------------------------------------------------------
# TURNSTONE_AUTH_ENABLED=true
# TURNSTONE_AUTH_TOKEN=your-secret-token
# Host port for Redis
REDIS_PORT=6379
# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
# Host port for the turnstone web UI
SERVER_PORT=8080
# Set to any non-empty value to auto-approve all tool calls
SKIP_PERMISSIONS=
# ---------------------------------------------------------------------------
# Bridge
# ---------------------------------------------------------------------------
# Heartbeat TTL in seconds
HEARTBEAT_TTL=60
# Seconds to wait for external approval responses
APPROVAL_TIMEOUT=300
# ---------------------------------------------------------------------------
# Console (Cluster Dashboard)
# ---------------------------------------------------------------------------
# Host port for the cluster dashboard
CONSOLE_PORT=8090
# Seconds between node polling cycles
CONSOLE_POLL_INTERVAL=10
# ---------------------------------------------------------------------------
# Auth (optional)
# ---------------------------------------------------------------------------
# Set to "1" to require Bearer token authentication
TURNSTONE_AUTH_ENABLED=
# Bearer token for server/bridge/console authentication
TURNSTONE_AUTH_TOKEN=
# ---------------------------------------------------------------------------
# Simulator (used with: docker compose --profile sim up)
# ---------------------------------------------------------------------------
# Number of simulated nodes
SIM_NODES=100
# Scenario: steady, burst, node_failure, directed, lifecycle
SIM_SCENARIO=steady
# Scenario duration in seconds
SIM_DURATION=60
# Messages per second (steady scenario)
SIM_MPS=5.0
# Log level
SIM_LOG_LEVEL=INFO
# Random seed for reproducibility (leave empty for random)
SIM_SEED=
# Path to write JSON metrics report (leave empty to skip)
SIM_METRICS_FILE=
# -- Ports ---------------------------------------------------------------------
# SERVER_PORT=8080
# CONSOLE_PORT=8090
+11 -2
View File
@@ -25,22 +25,31 @@ FROM python:3.13-slim
LABEL org.opencontainers.image.title="turnstone" \
org.opencontainers.image.description="Multi-node AI orchestration platform"
# System dependencies for psycopg (PostgreSQL client library)
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Non-root user
RUN useradd --create-home --shell /bin/bash turnstone
# Install the wheel with all optional extras (redis for mq/console/sim)
# Install the wheel with all optional extras
COPY --from=builder /build/wheels/*.whl /tmp/wheels/
RUN pip install --no-cache-dir "$(ls /tmp/wheels/*.whl)[mq,console,sim]" \
RUN pip install --no-cache-dir "$(ls /tmp/wheels/*.whl)[mq,console,sim,postgres]" \
&& rm -rf /tmp/wheels
# Health check script (stdlib only, no pip deps needed)
COPY docker/healthcheck.py /usr/local/bin/healthcheck.py
# Entrypoint script — runs migrations before starting
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
# Data directory — SQLite DB is created in CWD
WORKDIR /data
RUN chown turnstone:turnstone /data
USER turnstone
ENTRYPOINT ["entrypoint.sh"]
# Default command (overridden per service in compose.yaml)
CMD ["turnstone-server", "--host", "0.0.0.0", "--port", "8080"]
+56 -19
View File
@@ -16,7 +16,7 @@ Turnstone gives LLMs tools — shell, files, search, web, planning — and orche
- **Interactive sessions** — terminal CLI or browser UI with parallel workstreams
- **Queue-driven agents** — trigger workstreams via message queue, stream progress, approve or auto-approve tool use
- **Multi-node clusters** — generic work load-balances across nodes, directed work routes to a specific server
- **Cluster dashboard** — real-time view of all nodes, workstreams, and resource utilization
- **Cluster dashboard** — real-time view of all nodes and workstreams, workstream creation with node targeting, reverse proxy for server UIs (only the console port needs network access)
- **Cluster simulator** — test the stack at scale (up to 1000 nodes) without an LLM backend
```
@@ -72,13 +72,20 @@ pip install turnstone[console]
turnstone-console --redis-host localhost --port 8090
```
Then open `http://localhost:8090` for the cluster-wide dashboard.
Then open `http://localhost:8090` for the cluster-wide dashboard. Create workstreams from the console and interact with any node's server UI through the built-in reverse proxy — no direct server port access required.
### Docker
```bash
cp .env.example .env # edit LLM_BASE_URL, OPENAI_API_KEY, etc.
docker compose up # starts redis + server + bridge + console
docker compose up # starts redis + server + bridge + console (SQLite)
```
For production with PostgreSQL:
```bash
# Requires POSTGRES_PASSWORD and DB_BACKEND=postgresql in .env (or exported)
docker compose --profile production up # adds PostgreSQL, uses it as database
```
Console dashboard at http://localhost:8090. See [docs/docker.md](docs/docker.md) for configuration, scaling, and profiles.
@@ -100,7 +107,7 @@ turnstone-sim --nodes 100 --scenario steady --duration 60 --mps 10
See [docs/simulator.md](docs/simulator.md) for scenarios, CLI reference, and metrics.
All frontends connect to any OpenAI-compatible API (vLLM, NVIDIA NIM/NGC, llama.cpp, OpenAI, etc.) and auto-detect the model.
All frontends connect to any OpenAI-compatible API (vLLM, NVIDIA NIM/NGC, llama.cpp, OpenAI, etc.) or Anthropic's native Messages API, and auto-detect the model.
## Architecture
@@ -108,12 +115,17 @@ All frontends connect to any OpenAI-compatible API (vLLM, NVIDIA NIM/NGC, llama.
turnstone/
├── core/ # UI-agnostic engine
│ ├── session.py # ChatSession — multi-turn loop, tool dispatch, agents
│ ├── providers/ # LLM provider adapters (OpenAI, Anthropic)
│ │ ├── _protocol.py # LLMProvider protocol, ModelCapabilities, StreamChunk
│ │ ├── _openai.py # OpenAI-compatible (OpenAI, vLLM, llama.cpp)
│ │ └── _anthropic.py # Anthropic Messages API (native streaming, thinking)
│ ├── tools.py # Tool definitions (auto-loaded from JSON)
│ ├── workstream.py # WorkstreamManager — parallel independent sessions
│ ├── mcp_client.py # MCP client manager (external tool servers)
│ ├── model_registry.py # ModelRegistry — named models, fallback routing, per-workstream selection
│ ├── config.py # Unified TOML config (~/.config/turnstone/config.toml)
│ ├── memory.py # SQLite persistence (memories, conversations, FTS5)
│ ├── memory.py # Persistence facade (delegates to storage/)
│ ├── storage/ # Pluggable storage backend (SQLite + PostgreSQL)
│ ├── metrics.py # Prometheus-compatible metrics collector
│ ├── healthcheck.py # Backend health monitor + circuit breaker
│ ├── ratelimit.py # Per-IP token-bucket rate limiter
@@ -128,7 +140,7 @@ turnstone/
│ └── client.py # TurnstoneClient — Python API for external systems
├── console/ # Cluster dashboard
│ ├── collector.py # ClusterCollector — aggregates all nodes via Redis + HTTP
│ ├── server.py # Dashboard HTTP server + SSE
│ ├── server.py # Dashboard Starlette/ASGI server + SSE
│ └── static/ # Cluster dashboard web UI
├── tools/ # Tool schemas (one JSON file per tool)
├── ui/ # Frontend assets and terminal rendering
@@ -141,11 +153,14 @@ turnstone/
│ ├── metrics.py # Latency, throughput, utilization collection
│ └── cli.py # CLI entry point (turnstone-sim)
├── cli.py # Terminal frontend (+ /cluster commands for console)
├── server.py # Web frontend (HTTP + SSE)
├── server.py # Web frontend (Starlette/ASGI + SSE)
└── eval.py # Evaluation and prompt optimization harness
├── api/ # OpenAPI spec generation (Pydantic v2 models)
├── sdk/ # Client SDKs (sync + async, Python)
docs/
├── architecture.md # System architecture and threading model
├── api-reference.md # Web server API and SSE event reference
├── sdk.md # Client SDK reference (Python + TypeScript)
├── console.md # Cluster dashboard service (turnstone-console)
├── docker.md # Docker Compose deployment and configuration
├── simulator.md # Cluster simulator usage and scenarios
@@ -153,6 +168,9 @@ docs/
├── eval.md # Evaluation harness internals
└── diagrams/ # UML architecture diagrams (PlantUML sources + PNGs)
└── png/ # Pre-rendered diagram images
deploy/
├── helm/turnstone/ # Helm chart for Kubernetes
└── terraform/ # Terraform modules (AWS ECS/Fargate)
```
### Architecture Diagrams
@@ -163,8 +181,8 @@ Detailed UML diagrams are available in [`docs/diagrams/`](docs/diagrams/):
|---------|-------------|
| [System Context](docs/diagrams/png/01-system-context.png) | Top-level components and external dependencies |
| [Package Structure](docs/diagrams/png/02-package-structure.png) | Python modules and dependency graph |
| [Core Engine Classes](docs/diagrams/png/03-core-engine-classes.png) | SessionUI protocol, ChatSession, WorkstreamManager |
| [Conversation Turn](docs/diagrams/png/04-conversation-turn.png) | Full message lifecycle through the engine |
| [Core Engine Classes](docs/diagrams/png/03-core-engine-classes.png) | SessionUI protocol, ChatSession, LLMProvider, WorkstreamManager |
| [Conversation Turn](docs/diagrams/png/04-conversation-turn.png) | Full message lifecycle through the engine (provider-agnostic) |
| [Tool Pipeline](docs/diagrams/png/05-tool-pipeline.png) | Three-phase prepare/approve/execute |
| [MQ Protocol](docs/diagrams/png/06-mq-protocol.png) | 9 inbound + 19 outbound message types |
| [Message Routing](docs/diagrams/png/07-message-routing.png) | Multi-node routing scenarios |
@@ -173,6 +191,8 @@ Detailed UML diagrams are available in [`docs/diagrams/`](docs/diagrams/):
| [Simulator](docs/diagrams/png/10-simulator-architecture.png) | SimCluster, dispatchers, scenarios |
| [Console Data Flow](docs/diagrams/png/11-console-data-flow.png) | Dashboard data collection threads |
| [Deployment](docs/diagrams/png/12-deployment.png) | Docker Compose service topology |
| [SDK Architecture](docs/diagrams/png/13-sdk-architecture.png) | Python + TypeScript client libraries |
| [Storage Architecture](docs/diagrams/png/14-storage-architecture.png) | Pluggable database backends (SQLite + PostgreSQL) |
## Multi-node routing
@@ -209,7 +229,7 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
| `math` | Sandboxed Python evaluation | |
| `man` | Read man pages | yes |
| `web_fetch` | Fetch URL content | |
| `web_search` | Search via Tavily API | |
| `web_search` | Web search (provider-native or Tavily) | |
| `remember` | Save persistent facts | yes |
| `recall` | Search memories and history | yes |
| `forget` | Remove a memory | yes |
@@ -241,28 +261,37 @@ turnstone-server --mcp-config ~/.config/turnstone/mcp.json
Use `/mcp` in the REPL to list connected tools. MCP tools require user approval by default (overridden by `--skip-permissions` or UI auto-approve).
### Multi-Model Support
### Multi-Model and Multi-Provider Support
Turnstone supports multiple model backends per server instance. Define named models in `config.toml` and select per-workstream or switch mid-session with `/model <alias>`.
Turnstone supports multiple model backends per server instance, including different LLM providers. `ChatSession` delegates all API communication to pluggable `LLMProvider` adapters — the internal message format stays OpenAI-like, and each provider translates at the API boundary. Define named models in `config.toml` and select per-workstream or switch mid-session with `/model <alias>`.
```toml
[models.local]
base_url = "http://localhost:8000/v1"
model = "qwen3-32b"
# provider defaults to "openai" (works with vLLM, llama.cpp, etc.)
[models.claude]
provider = "anthropic"
api_key = "sk-ant-..."
model = "claude-opus-4-6"
context_window = 200000
[models.openai]
base_url = "https://api.openai.com/v1"
api_key = "sk-..."
model = "gpt-4o"
context_window = 128000
model = "gpt-5"
context_window = 400000
[model]
default = "local" # which model to use by default
fallback = ["openai"] # try these if the primary is unreachable
agent_model = "local" # optional: cheaper model for plan/task sub-agents
fallback = ["claude", "openai"] # try these if the primary is unreachable
agent_model = "claude" # optional: separate model for plan/task sub-agents
```
Use `/model` to show available models, `/model openai` to switch. Workstreams created via the API accept an optional `model` parameter.
Supported providers: `"openai"` (default -- OpenAI, vLLM, llama.cpp, any OpenAI-compatible API) and `"anthropic"` (Anthropic Messages API, requires `pip install turnstone[anthropic]`).
Use `/model` to show available models, `/model claude` to switch. Workstreams created via the API accept an optional `model` parameter.
## Configuration
@@ -272,7 +301,7 @@ All entry points read `~/.config/turnstone/config.toml`. CLI flags override conf
[api]
base_url = "http://localhost:8000/v1"
api_key = ""
tavily_key = ""
tavily_key = "" # only needed for local/vLLM models without native search
[model]
name = "" # empty = auto-detect
@@ -317,6 +346,12 @@ enabled = true
requests_per_second = 10.0
burst = 20
[database]
backend = "sqlite" # "sqlite" (default) or "postgresql"
path = ".turnstone.db" # SQLite file path (relative to working directory)
# url = "postgresql+psycopg://user:pass@host:5432/turnstone" # PostgreSQL
# pool_size = 5 # PostgreSQL connection pool size
[mcp]
config_path = "" # path to MCP JSON config file (alternative to TOML sections)
@@ -373,8 +408,10 @@ Per-workstream metrics are labeled by `ws_id` (bounded to 10 max workstreams).
## Requirements
- Python 3.11+
- An OpenAI-compatible API endpoint ([vLLM](https://github.com/vllm-project/vllm), [NVIDIA NIM](https://build.nvidia.com/), [llama.cpp](https://github.com/ggml-org/llama.cpp), etc.)
- An OpenAI-compatible API endpoint ([vLLM](https://github.com/vllm-project/vllm), [NVIDIA NIM](https://build.nvidia.com/), [llama.cpp](https://github.com/ggml-org/llama.cpp), etc.) or an Anthropic API key
- Redis (for message queue bridge — `pip install turnstone[mq]`)
- Anthropic provider (optional — `pip install turnstone[anthropic]`)
- PostgreSQL (optional, for production — `pip install turnstone[postgres]`)
## License
+42 -5
View File
@@ -2,10 +2,11 @@
# Turnstone Docker Compose Stack
#
# Usage:
# Full stack: docker compose up
# With simulator: docker compose --profile sim up
# Sim only: docker compose --profile sim up redis console sim
# Scale bridges: docker compose up --scale bridge=3
# Default (SQLite): docker compose up
# Production (PG): DB_BACKEND=postgresql docker compose --profile production up
# (or set DB_BACKEND=postgresql in .env)
# With simulator: docker compose --profile sim up
# Scale bridges: docker compose up --scale bridge=3
# =============================================================================
name: turnstone
@@ -17,8 +18,37 @@ networks:
volumes:
redis-data:
turnstone-data:
postgres-data:
services:
# -------------------------------------------------------------------
# PostgreSQL — production database (profile: production)
# -------------------------------------------------------------------
postgres:
image: postgres:17-alpine
profiles:
- production
environment:
POSTGRES_DB: turnstone
POSTGRES_USER: ${POSTGRES_USER:-turnstone}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required for production profile}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- turnstone-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-turnstone}"]
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
restart: unless-stopped
# -------------------------------------------------------------------
# Redis — message broker, pub/sub, node registry
# -------------------------------------------------------------------
@@ -66,6 +96,7 @@ services:
--port 8080
--base-url "$${LLM_BASE_URL}"
--api-key "$${OPENAI_API_KEY}"
$${MODEL:+--model $$MODEL}
$${SKIP_PERMISSIONS:+--skip-permissions}
ports:
- "${SERVER_PORT:-8080}:8080"
@@ -78,6 +109,9 @@ services:
- SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-}
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
- MODEL=${MODEL:-}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${DATABASE_URL:-}
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
@@ -85,6 +119,9 @@ services:
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
required: false
healthcheck:
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
interval: 10s
@@ -107,7 +144,7 @@ services:
- --redis-host=redis
- --redis-port=6379
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
- --approval-timeout=${APPROVAL_TIMEOUT:-300}
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
+16
View File
@@ -0,0 +1,16 @@
apiVersion: v2
name: turnstone
description: Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation
type: application
version: 0.1.0
appVersion: "0.3.0"
dependencies:
- name: postgresql
version: ~16.0
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
- name: redis
version: ~20.0
repository: https://charts.bitnami.com/bitnami
condition: redis.enabled
+42
View File
@@ -0,0 +1,42 @@
Turnstone {{ .Chart.AppVersion }} has been deployed.
{{- if .Values.ingress.enabled }}
Access the application via your ingress:
{{- range .Values.ingress.hosts }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }}
{{- end }}
{{- else }}
To access the Turnstone server, run:
kubectl port-forward svc/{{ include "turnstone.fullname" . }}-server {{ .Values.server.service.port }}:{{ .Values.server.service.port }}
Then open: http://localhost:{{ .Values.server.service.port }}
To access the Turnstone console (cluster dashboard), run:
kubectl port-forward svc/{{ include "turnstone.fullname" . }}-console {{ .Values.console.service.port }}:{{ .Values.console.service.port }}
Then open: http://localhost:{{ .Values.console.service.port }}
{{- end }}
Components deployed:
- Server: {{ include "turnstone.fullname" . }}-server ({{ .Values.server.replicas }} replica(s))
- Bridge: {{ include "turnstone.fullname" . }}-bridge ({{ .Values.bridge.replicas }} replica(s))
- Console: {{ include "turnstone.fullname" . }}-console ({{ .Values.console.replicas }} replica(s))
{{- if .Values.postgresql.enabled }}
- PostgreSQL (bitnami subchart)
{{- end }}
{{- if .Values.redis.enabled }}
- Redis (bitnami subchart)
{{- end }}
{{- if not .Values.llm.apiKey }}
{{- if not .Values.llm.existingSecret }}
WARNING: No LLM API key configured. Set llm.apiKey or llm.existingSecret in your values.
{{- end }}
{{- end }}
@@ -0,0 +1,163 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "turnstone.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this
(by the DNS naming spec). If release name contains chart name it will be used
as a full name.
*/}}
{{- define "turnstone.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "turnstone.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels.
*/}}
{{- define "turnstone.labels" -}}
helm.sh/chart: {{ include "turnstone.chart" . }}
{{ include "turnstone.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels.
*/}}
{{- define "turnstone.selectorLabels" -}}
app.kubernetes.io/name: {{ include "turnstone.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use.
*/}}
{{- define "turnstone.serviceAccountName" -}}
{{- if .Values.serviceAccount }}
{{- if .Values.serviceAccount.name }}
{{- .Values.serviceAccount.name }}
{{- else }}
{{- include "turnstone.fullname" . }}
{{- end }}
{{- else }}
{{- include "turnstone.fullname" . }}
{{- end }}
{{- end }}
{{/*
Determine the PostgreSQL host.
*/}}
{{- define "turnstone.postgresql.host" -}}
{{- if .Values.postgresql.enabled }}
{{- printf "%s-postgresql" .Release.Name }}
{{- else }}
{{- .Values.database.external.host }}
{{- end }}
{{- end }}
{{/*
Determine the PostgreSQL port.
*/}}
{{- define "turnstone.postgresql.port" -}}
{{- if .Values.postgresql.enabled }}
{{- printf "5432" }}
{{- else }}
{{- .Values.database.external.port | toString }}
{{- end }}
{{- end }}
{{/*
Determine the PostgreSQL database name.
*/}}
{{- define "turnstone.postgresql.database" -}}
{{- if .Values.postgresql.enabled }}
{{- .Values.postgresql.auth.database }}
{{- else }}
{{- .Values.database.external.database }}
{{- end }}
{{- end }}
{{/*
Determine the PostgreSQL username.
*/}}
{{- define "turnstone.postgresql.username" -}}
{{- if .Values.postgresql.enabled }}
{{- .Values.postgresql.auth.username }}
{{- else }}
{{- .Values.database.external.username }}
{{- end }}
{{- end }}
{{/*
Determine the Redis host.
*/}}
{{- define "turnstone.redis.host" -}}
{{- if .Values.redis.enabled }}
{{- printf "%s-redis-master" .Release.Name }}
{{- else }}
{{- .Values.redis.external.host }}
{{- end }}
{{- end }}
{{/*
Determine the Redis port.
*/}}
{{- define "turnstone.redis.port" -}}
{{- if .Values.redis.enabled }}
{{- printf "6379" }}
{{- else }}
{{- .Values.redis.external.port | toString }}
{{- end }}
{{- end }}
{{/*
Determine the secret name for LLM API keys.
*/}}
{{- define "turnstone.llm.secretName" -}}
{{- if .Values.llm.existingSecret }}
{{- .Values.llm.existingSecret }}
{{- else }}
{{- printf "%s-secrets" (include "turnstone.fullname" .) }}
{{- end }}
{{- end }}
{{/*
Determine the secret name for auth tokens.
*/}}
{{- define "turnstone.auth.secretName" -}}
{{- if .Values.auth.existingSecret }}
{{- .Values.auth.existingSecret }}
{{- else }}
{{- printf "%s-secrets" (include "turnstone.fullname" .) }}
{{- end }}
{{- end }}
{{/*
Container image reference.
*/}}
{{- define "turnstone.image" -}}
{{- $tag := .Values.image.tag | default .Chart.AppVersion }}
{{- printf "%s:%s" .Values.image.repository $tag }}
{{- end }}
@@ -0,0 +1,23 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "turnstone.fullname" . }}-config
labels:
{{- include "turnstone.labels" . | nindent 4 }}
data:
TURNSTONE_DB_BACKEND: {{ .Values.database.backend | quote }}
TURNSTONE_DB_HOST: {{ include "turnstone.postgresql.host" . | quote }}
TURNSTONE_DB_PORT: {{ include "turnstone.postgresql.port" . | quote }}
TURNSTONE_DB_NAME: {{ include "turnstone.postgresql.database" . | quote }}
TURNSTONE_DB_USER: {{ include "turnstone.postgresql.username" . | quote }}
TURNSTONE_SERVER_HOST: "0.0.0.0"
TURNSTONE_SERVER_PORT: {{ .Values.server.service.port | quote }}
TURNSTONE_CONSOLE_HOST: "0.0.0.0"
TURNSTONE_CONSOLE_PORT: {{ .Values.console.service.port | quote }}
TURNSTONE_REDIS_HOST: {{ include "turnstone.redis.host" . | quote }}
TURNSTONE_REDIS_PORT: {{ include "turnstone.redis.port" . | quote }}
TURNSTONE_POLL_INTERVAL: "5"
{{- if .Values.llm.baseUrl }}
TURNSTONE_LLM_BASE_URL: {{ .Values.llm.baseUrl | quote }}
{{- end }}
TURNSTONE_LLM_PROVIDER: {{ .Values.llm.provider | quote }}
@@ -0,0 +1,45 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "turnstone.fullname" . }}-bridge
labels:
{{- include "turnstone.labels" . | nindent 4 }}
app.kubernetes.io/component: bridge
spec:
replicas: {{ .Values.bridge.replicas }}
selector:
matchLabels:
{{- include "turnstone.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: bridge
template:
metadata:
labels:
{{- include "turnstone.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: bridge
spec:
serviceAccountName: {{ include "turnstone.serviceAccountName" . }}
containers:
- name: bridge
image: {{ include "turnstone.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
- turnstone-bridge
- --server-url={{ printf "http://%s-server:%s" (include "turnstone.fullname" .) (.Values.server.service.port | toString) }}
- --redis-host={{ include "turnstone.redis.host" . }}
- --redis-port={{ include "turnstone.redis.port" . }}
envFrom:
- configMapRef:
name: {{ include "turnstone.fullname" . }}-config
- secretRef:
name: {{ include "turnstone.llm.secretName" . }}
optional: true
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
env:
- name: TURNSTONE_AUTH_TOKEN
valueFrom:
secretKeyRef:
name: {{ .Values.auth.existingSecret }}
key: TURNSTONE_AUTH_TOKEN
{{- end }}
resources:
{{- toYaml .Values.bridge.resources | nindent 12 }}
@@ -0,0 +1,62 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "turnstone.fullname" . }}-console
labels:
{{- include "turnstone.labels" . | nindent 4 }}
app.kubernetes.io/component: console
spec:
replicas: {{ .Values.console.replicas }}
selector:
matchLabels:
{{- include "turnstone.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: console
template:
metadata:
labels:
{{- include "turnstone.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: console
spec:
serviceAccountName: {{ include "turnstone.serviceAccountName" . }}
containers:
- name: console
image: {{ include "turnstone.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
- turnstone-console
- --host=0.0.0.0
- --port={{ .Values.console.service.port }}
- --redis-host={{ include "turnstone.redis.host" . }}
- --redis-port={{ include "turnstone.redis.port" . }}
ports:
- name: http
containerPort: {{ .Values.console.service.port }}
protocol: TCP
envFrom:
- configMapRef:
name: {{ include "turnstone.fullname" . }}-config
- secretRef:
name: {{ include "turnstone.llm.secretName" . }}
optional: true
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
env:
- name: TURNSTONE_AUTH_TOKEN
valueFrom:
secretKeyRef:
name: {{ .Values.auth.existingSecret }}
key: TURNSTONE_AUTH_TOKEN
{{- end }}
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 15
periodSeconds: 20
resources:
{{- toYaml .Values.console.resources | nindent 12 }}
@@ -0,0 +1,64 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "turnstone.fullname" . }}-server
labels:
{{- include "turnstone.labels" . | nindent 4 }}
app.kubernetes.io/component: server
spec:
replicas: {{ .Values.server.replicas }}
selector:
matchLabels:
{{- include "turnstone.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: server
template:
metadata:
labels:
{{- include "turnstone.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: server
spec:
serviceAccountName: {{ include "turnstone.serviceAccountName" . }}
containers:
- name: server
image: {{ include "turnstone.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
- turnstone-server
- --host
- "0.0.0.0"
- --port
- {{ .Values.server.service.port | quote }}
ports:
- name: http
containerPort: {{ .Values.server.service.port }}
protocol: TCP
envFrom:
- configMapRef:
name: {{ include "turnstone.fullname" . }}-config
- secretRef:
name: {{ include "turnstone.llm.secretName" . }}
optional: true
env:
- name: TURNSTONE_DB_URL
value: "postgresql+psycopg://$(TURNSTONE_DB_USER):$(POSTGRES_PASSWORD)@$(TURNSTONE_DB_HOST):$(TURNSTONE_DB_PORT)/$(TURNSTONE_DB_NAME)"
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
- name: TURNSTONE_AUTH_TOKEN
valueFrom:
secretKeyRef:
name: {{ .Values.auth.existingSecret }}
key: TURNSTONE_AUTH_TOKEN
{{- end }}
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 15
periodSeconds: 20
resources:
{{- toYaml .Values.server.resources | nindent 12 }}
@@ -0,0 +1,47 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "turnstone.fullname" . }}
labels:
{{- include "turnstone.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType | default "Prefix" }}
backend:
service:
{{- if eq (.service | default "server") "console" }}
name: {{ include "turnstone.fullname" $ }}-console
port:
number: {{ $.Values.console.service.port }}
{{- else }}
name: {{ include "turnstone.fullname" $ }}-server
port:
number: {{ $.Values.server.service.port }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,38 @@
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "turnstone.fullname" . }}-migrate
labels:
{{- include "turnstone.labels" . | nindent 4 }}
app.kubernetes.io/component: migrate
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-1"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: 3
template:
metadata:
labels:
{{- include "turnstone.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: migrate
spec:
serviceAccountName: {{ include "turnstone.serviceAccountName" . }}
restartPolicy: OnFailure
containers:
- name: migrate
image: {{ include "turnstone.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
- python
- -m
- turnstone.core.storage._migrate
envFrom:
- configMapRef:
name: {{ include "turnstone.fullname" . }}-config
- secretRef:
name: {{ include "turnstone.llm.secretName" . }}
optional: true
env:
- name: TURNSTONE_DB_URL
value: "postgresql+psycopg://$(TURNSTONE_DB_USER):$(POSTGRES_PASSWORD)@$(TURNSTONE_DB_HOST):$(TURNSTONE_DB_PORT)/$(TURNSTONE_DB_NAME)"
@@ -0,0 +1,28 @@
{{- if not .Values.llm.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "turnstone.fullname" . }}-secrets
labels:
{{- include "turnstone.labels" . | nindent 4 }}
type: Opaque
data:
{{- if .Values.llm.apiKey }}
OPENAI_API_KEY: {{ .Values.llm.apiKey | b64enc | quote }}
{{- end }}
{{- if and .Values.postgresql.enabled .Values.postgresql.auth.password }}
POSTGRES_PASSWORD: {{ .Values.postgresql.auth.password | b64enc | quote }}
{{- else if and (not .Values.postgresql.enabled) .Values.database.external.password }}
POSTGRES_PASSWORD: {{ .Values.database.external.password | b64enc | quote }}
{{- end }}
{{- if and .Values.auth.enabled .Values.auth.token (not .Values.auth.existingSecret) }}
TURNSTONE_AUTH_TOKEN: {{ .Values.auth.token | b64enc | quote }}
{{- end }}
{{- if and .Values.redis.enabled .Values.redis.auth }}
{{- if .Values.redis.auth.password }}
REDIS_PASSWORD: {{ .Values.redis.auth.password | b64enc | quote }}
{{- end }}
{{- else if and (not .Values.redis.enabled) .Values.redis.external.password }}
REDIS_PASSWORD: {{ .Values.redis.external.password | b64enc | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "turnstone.fullname" . }}-console
labels:
{{- include "turnstone.labels" . | nindent 4 }}
app.kubernetes.io/component: console
spec:
type: {{ .Values.console.service.type }}
ports:
- port: {{ .Values.console.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "turnstone.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: console
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "turnstone.fullname" . }}-server
labels:
{{- include "turnstone.labels" . | nindent 4 }}
app.kubernetes.io/component: server
spec:
type: {{ .Values.server.service.type }}
ports:
- port: {{ .Values.server.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "turnstone.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: server
@@ -0,0 +1,6 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "turnstone.serviceAccountName" . }}
labels:
{{- include "turnstone.labels" . | nindent 4 }}
+103
View File
@@ -0,0 +1,103 @@
# -- Container image settings
image:
repository: ghcr.io/turnstonelabs/turnstone
tag: ""
pullPolicy: IfNotPresent
# -- Database configuration
database:
# Backend type (postgresql)
backend: postgresql
# External database settings (used when postgresql.enabled is false)
external:
host: ""
port: 5432
database: turnstone
username: turnstone
existingSecret: ""
sslmode: prefer
# -- Bitnami PostgreSQL subchart
postgresql:
enabled: true
auth:
database: turnstone
username: turnstone
# -- Redis configuration
redis:
enabled: true
architecture: standalone
# External Redis settings (used when redis.enabled is false)
external:
host: ""
port: 6379
existingSecret: ""
# -- Turnstone server (main API + web UI)
server:
replicas: 1
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: "2"
memory: 1Gi
service:
type: ClusterIP
port: 8080
# -- Turnstone bridge (Redis MQ connector)
bridge:
replicas: 1
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
# -- Turnstone console (cluster dashboard)
console:
replicas: 1
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
service:
type: ClusterIP
port: 8090
# -- LLM provider configuration
llm:
baseUrl: ""
provider: openai
apiKey: ""
existingSecret: ""
# -- Authentication
auth:
enabled: false
token: ""
existingSecret: ""
# -- Ingress configuration
ingress:
enabled: false
className: ""
annotations: {}
hosts: []
# - host: turnstone.example.com
# paths:
# - path: /
# pathType: Prefix
# service: server
tls: []
# - secretName: turnstone-tls
# hosts:
# - turnstone.example.com
@@ -0,0 +1,36 @@
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
module "turnstone" {
source = "../../modules/aws-ecs"
vpc_id = var.vpc_id
private_subnet_ids = var.private_subnet_ids
public_subnet_ids = var.public_subnet_ids
image_repository = var.image_repository
image_tag = var.image_tag
llm_base_url = var.llm_base_url
openai_api_key = var.openai_api_key
environment = var.environment
name_prefix = var.name_prefix
auth_token = var.auth_token
tags = {
Example = "aws-ecs-basic"
}
}
@@ -0,0 +1,29 @@
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer."
value = module.turnstone.alb_dns_name
}
output "server_url" {
description = "HTTP URL for the Turnstone server."
value = module.turnstone.server_url
}
output "console_url" {
description = "HTTP URL for the Turnstone console."
value = module.turnstone.console_url
}
output "cluster_arn" {
description = "ARN of the ECS cluster."
value = module.turnstone.cluster_arn
}
output "rds_endpoint" {
description = "RDS PostgreSQL endpoint."
value = module.turnstone.rds_endpoint
}
output "redis_endpoint" {
description = "ElastiCache Redis endpoint."
value = module.turnstone.redis_endpoint
}
@@ -0,0 +1,22 @@
# --- Required ---
# VPC and subnet IDs from your existing AWS infrastructure.
# The VPC must have DNS support and DNS hostnames enabled.
vpc_id = "vpc-0123456789abcdef0"
private_subnet_ids = ["subnet-aaa111", "subnet-bbb222"]
public_subnet_ids = ["subnet-ccc333", "subnet-ddd444"]
# LLM provider configuration.
# For OpenAI: https://api.openai.com/v1
# For a self-hosted vLLM instance: http://your-vllm-host:8000/v1
llm_base_url = "https://api.openai.com/v1"
openai_api_key = "sk-..."
# --- Optional ---
# aws_region = "us-east-1"
# image_repository = "ghcr.io/turnstonelabs/turnstone"
# image_tag = "0.3.0"
# environment = "production"
# name_prefix = "turnstone"
# auth_token = "my-secret-token"
@@ -0,0 +1,62 @@
variable "aws_region" {
description = "AWS region to deploy into."
type = string
default = "us-east-1"
}
variable "vpc_id" {
description = "ID of the VPC where all resources will be created."
type = string
}
variable "private_subnet_ids" {
description = "List of private subnet IDs for ECS tasks, RDS, and ElastiCache."
type = list(string)
}
variable "public_subnet_ids" {
description = "List of public subnet IDs for the Application Load Balancer."
type = list(string)
}
variable "image_repository" {
description = "Container image repository."
type = string
default = "ghcr.io/turnstonelabs/turnstone"
}
variable "image_tag" {
description = "Container image tag."
type = string
default = "latest"
}
variable "llm_base_url" {
description = "Base URL for the LLM provider API."
type = string
}
variable "openai_api_key" {
description = "API key for the LLM provider."
type = string
sensitive = true
}
variable "environment" {
description = "Deployment environment name."
type = string
default = "production"
}
variable "name_prefix" {
description = "Prefix for all resource names."
type = string
default = "turnstone"
}
variable "auth_token" {
description = "Optional authentication token for the Turnstone API."
type = string
sensitive = true
default = ""
}
+150
View File
@@ -0,0 +1,150 @@
# ---------- Application Load Balancer ----------
#
# HTTP listeners are provided as a starter baseline. For production, set
# var.certificate_arn to an ACM certificate ARN to enable HTTPS listeners
# that redirect HTTP traffic to TLS.
resource "aws_lb" "this" {
name = "${var.name_prefix}-${var.environment}"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = var.public_subnet_ids
tags = local.common_tags
}
# ---------- Server Target Group + Listeners ----------
resource "aws_lb_target_group" "server" {
name = "${var.name_prefix}-server-${var.environment}"
port = 8080
protocol = "HTTP"
vpc_id = var.vpc_id
target_type = "ip"
tags = local.common_tags
health_check {
path = "/health"
port = "traffic-port"
protocol = "HTTP"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 30
matcher = "200"
}
}
# HTTP listener: forwards directly when no certificate, redirects to HTTPS otherwise.
resource "aws_lb_listener" "server" {
count = var.certificate_arn == "" ? 1 : 0
load_balancer_arn = aws_lb.this.arn
port = 80
protocol = "HTTP"
tags = local.common_tags
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.server.arn
}
}
resource "aws_lb_listener" "server_http_redirect" {
count = var.certificate_arn != "" ? 1 : 0
load_balancer_arn = aws_lb.this.arn
port = 80
protocol = "HTTP"
tags = local.common_tags
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
resource "aws_lb_listener" "server_https" {
count = var.certificate_arn != "" ? 1 : 0
load_balancer_arn = aws_lb.this.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.certificate_arn
tags = local.common_tags
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.server.arn
}
}
# ---------- Console Target Group + Listeners ----------
resource "aws_lb_target_group" "console" {
name = "${var.name_prefix}-console-${var.environment}"
port = 8090
protocol = "HTTP"
vpc_id = var.vpc_id
target_type = "ip"
tags = local.common_tags
health_check {
path = "/health"
port = "traffic-port"
protocol = "HTTP"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 30
matcher = "200"
}
}
# HTTP listener: forwards directly when no certificate, redirects to HTTPS otherwise.
resource "aws_lb_listener" "console" {
count = var.certificate_arn == "" ? 1 : 0
load_balancer_arn = aws_lb.this.arn
port = 8090
protocol = "HTTP"
tags = local.common_tags
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.console.arn
}
}
resource "aws_lb_listener" "console_http_redirect" {
count = var.certificate_arn != "" ? 1 : 0
load_balancer_arn = aws_lb.this.arn
port = 8090
protocol = "HTTP"
tags = local.common_tags
default_action {
type = "redirect"
redirect {
port = "8443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
resource "aws_lb_listener" "console_https" {
count = var.certificate_arn != "" ? 1 : 0
load_balancer_arn = aws_lb.this.arn
port = 8443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.certificate_arn
tags = local.common_tags
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.console.arn
}
}
@@ -0,0 +1,30 @@
# ---------- ElastiCache Subnet Group ----------
resource "aws_elasticache_subnet_group" "this" {
name = "${var.name_prefix}-${var.environment}"
subnet_ids = var.private_subnet_ids
tags = local.common_tags
}
# ---------- ElastiCache Redis Replication Group ----------
resource "aws_elasticache_replication_group" "this" {
replication_group_id = "${var.name_prefix}-${var.environment}"
description = "Turnstone Redis for MQ and session state"
engine = "redis"
engine_version = "7.1"
node_type = var.redis_node_type
num_cache_clusters = 1
port = 6379
subnet_group_name = aws_elasticache_subnet_group.this.name
security_group_ids = [aws_security_group.redis.id]
at_rest_encryption_enabled = true
transit_encryption_enabled = true
automatic_failover_enabled = false
tags = local.common_tags
}
+70
View File
@@ -0,0 +1,70 @@
# ---------- ECS Task Execution Role ----------
# Used by the ECS agent to pull images and retrieve secrets.
resource "aws_iam_role" "ecs_execution" {
name = "${var.name_prefix}-ecs-execution-${var.environment}"
tags = local.common_tags
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
Action = "sts:AssumeRole"
},
]
})
}
resource "aws_iam_role_policy_attachment" "ecs_execution_base" {
role = aws_iam_role.ecs_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role_policy" "ecs_execution_secrets" {
name = "${var.name_prefix}-secrets-read-${var.environment}"
role = aws_iam_role.ecs_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"secretsmanager:GetSecretValue",
]
Resource = concat(
[
aws_secretsmanager_secret.openai_api_key.arn,
aws_secretsmanager_secret.db_password.arn,
],
var.auth_token != "" ? [aws_secretsmanager_secret.auth_token[0].arn] : [],
)
},
]
})
}
# ---------- ECS Task Role ----------
# Assumed by the running container. Minimal permissions; extend as needed.
resource "aws_iam_role" "ecs_task" {
name = "${var.name_prefix}-ecs-task-${var.environment}"
tags = local.common_tags
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
Action = "sts:AssumeRole"
},
]
})
}
+313
View File
@@ -0,0 +1,313 @@
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.5"
}
}
}
locals {
full_image = "${var.image_repository}:${var.image_tag}"
common_tags = merge(var.tags, {
Project = "turnstone"
Environment = var.environment
ManagedBy = "terraform"
})
# Shared environment variables injected into every container.
common_env = [
{ name = "TURNSTONE_ENV", value = var.environment },
{ name = "TURNSTONE_DB_BACKEND", value = "postgresql" },
{ name = "TURNSTONE_LLM_BASE_URL", value = var.llm_base_url },
{ name = "TURNSTONE_REDIS_URL", value = "redis://${aws_elasticache_replication_group.this.primary_endpoint_address}:6379/0" },
]
# Secrets pulled from Secrets Manager at container start.
common_secrets = [
{
name = "OPENAI_API_KEY"
valueFrom = aws_secretsmanager_secret_version.openai_api_key.arn
},
{
name = "TURNSTONE_DB_URL"
valueFrom = aws_secretsmanager_secret_version.db_url.arn
},
]
auth_env = var.auth_token != "" ? [
{ name = "TURNSTONE_AUTH_ENABLED", value = "true" },
] : []
auth_secrets = var.auth_token != "" ? [
{
name = "TURNSTONE_AUTH_TOKEN"
valueFrom = aws_secretsmanager_secret_version.auth_token[0].arn
},
] : []
}
# ---------- Secrets Manager ----------
resource "aws_secretsmanager_secret" "openai_api_key" {
name = "${var.name_prefix}-${var.environment}-openai-api-key"
tags = local.common_tags
}
resource "aws_secretsmanager_secret_version" "openai_api_key" {
secret_id = aws_secretsmanager_secret.openai_api_key.id
secret_string = var.openai_api_key
}
resource "aws_secretsmanager_secret" "auth_token" {
count = var.auth_token != "" ? 1 : 0
name = "${var.name_prefix}-${var.environment}-auth-token"
tags = local.common_tags
}
resource "aws_secretsmanager_secret_version" "auth_token" {
count = var.auth_token != "" ? 1 : 0
secret_id = aws_secretsmanager_secret.auth_token[0].id
secret_string = var.auth_token
}
resource "aws_secretsmanager_secret" "db_password" {
name = "${var.name_prefix}-${var.environment}-db-password"
tags = local.common_tags
}
resource "aws_secretsmanager_secret_version" "db_password" {
secret_id = aws_secretsmanager_secret.db_password.id
secret_string = random_password.db.result
}
resource "aws_secretsmanager_secret" "db_url" {
name = "${var.name_prefix}-${var.environment}-db-url"
tags = local.common_tags
}
resource "aws_secretsmanager_secret_version" "db_url" {
secret_id = aws_secretsmanager_secret.db_url.id
secret_string = "postgresql+psycopg://${aws_db_instance.this.username}:${random_password.db.result}@${aws_db_instance.this.endpoint}/turnstone"
}
# ---------- ECS Cluster ----------
resource "aws_ecs_cluster" "this" {
name = "${var.name_prefix}-${var.environment}"
tags = local.common_tags
setting {
name = "containerInsights"
value = "enabled"
}
}
# ---------- CloudWatch Log Group ----------
resource "aws_cloudwatch_log_group" "this" {
name = "/ecs/${var.name_prefix}-${var.environment}"
retention_in_days = 30
tags = local.common_tags
}
# ---------- Server Task Definition + Service ----------
resource "aws_ecs_task_definition" "server" {
family = "${var.name_prefix}-server"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = var.server_cpu
memory = var.server_memory
execution_role_arn = aws_iam_role.ecs_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
tags = local.common_tags
container_definitions = jsonencode([
{
name = "server"
image = local.full_image
essential = true
command = ["turnstone-server", "--host", "0.0.0.0", "--port", "8080"]
portMappings = [
{ containerPort = 8080, protocol = "tcp" },
]
environment = concat(local.common_env, local.auth_env)
secrets = concat(local.common_secrets, local.auth_secrets)
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.this.name
"awslogs-region" = data.aws_region.current.name
"awslogs-stream-prefix" = "server"
}
}
healthCheck = {
command = ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval = 30
timeout = 5
retries = 3
startPeriod = 10
}
},
])
}
resource "aws_ecs_service" "server" {
name = "${var.name_prefix}-server"
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.server.arn
desired_count = 1
launch_type = "FARGATE"
tags = local.common_tags
network_configuration {
subnets = var.private_subnet_ids
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.server.arn
container_name = "server"
container_port = 8080
}
depends_on = [aws_lb_target_group.server]
}
# ---------- Bridge Task Definition + Service ----------
resource "aws_ecs_task_definition" "bridge" {
family = "${var.name_prefix}-bridge"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = var.bridge_cpu
memory = var.bridge_memory
execution_role_arn = aws_iam_role.ecs_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
tags = local.common_tags
container_definitions = jsonencode([
{
name = "bridge"
image = local.full_image
essential = true
command = ["turnstone-bridge"]
environment = concat(local.common_env, local.auth_env)
secrets = concat(local.common_secrets, local.auth_secrets)
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.this.name
"awslogs-region" = data.aws_region.current.name
"awslogs-stream-prefix" = "bridge"
}
}
},
])
}
resource "aws_ecs_service" "bridge" {
name = "${var.name_prefix}-bridge"
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.bridge.arn
desired_count = 1
launch_type = "FARGATE"
tags = local.common_tags
network_configuration {
subnets = var.private_subnet_ids
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false
}
depends_on = [aws_ecs_service.server]
}
# ---------- Console Task Definition + Service ----------
resource "aws_ecs_task_definition" "console" {
family = "${var.name_prefix}-console"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = var.console_cpu
memory = var.console_memory
execution_role_arn = aws_iam_role.ecs_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
tags = local.common_tags
container_definitions = jsonencode([
{
name = "console"
image = local.full_image
essential = true
command = ["turnstone-console", "--host", "0.0.0.0", "--port", "8090"]
portMappings = [
{ containerPort = 8090, protocol = "tcp" },
]
environment = concat(local.common_env, local.auth_env)
secrets = concat(local.common_secrets, local.auth_secrets)
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.this.name
"awslogs-region" = data.aws_region.current.name
"awslogs-stream-prefix" = "console"
}
}
healthCheck = {
command = ["CMD-SHELL", "curl -f http://localhost:8090/health || exit 1"]
interval = 30
timeout = 5
retries = 3
startPeriod = 10
}
},
])
}
resource "aws_ecs_service" "console" {
name = "${var.name_prefix}-console"
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.console.arn
desired_count = 1
launch_type = "FARGATE"
tags = local.common_tags
network_configuration {
subnets = var.private_subnet_ids
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.console.arn
container_name = "console"
container_port = 8090
}
depends_on = [aws_lb_target_group.console]
}
# ---------- Data Sources ----------
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}
@@ -0,0 +1,29 @@
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer."
value = aws_lb.this.dns_name
}
output "server_url" {
description = "HTTP URL for the Turnstone server API and web UI."
value = "http://${aws_lb.this.dns_name}"
}
output "console_url" {
description = "HTTP URL for the Turnstone console dashboard."
value = "http://${aws_lb.this.dns_name}:8090"
}
output "cluster_arn" {
description = "ARN of the ECS cluster."
value = aws_ecs_cluster.this.arn
}
output "rds_endpoint" {
description = "Endpoint of the RDS PostgreSQL instance (host:port)."
value = aws_db_instance.this.endpoint
}
output "redis_endpoint" {
description = "Primary endpoint of the ElastiCache Redis replication group."
value = aws_elasticache_replication_group.this.primary_endpoint_address
}
+42
View File
@@ -0,0 +1,42 @@
# ---------- Random Password ----------
resource "random_password" "db" {
length = 32
special = false
}
# ---------- DB Subnet Group ----------
resource "aws_db_subnet_group" "this" {
name = "${var.name_prefix}-${var.environment}"
subnet_ids = var.private_subnet_ids
tags = local.common_tags
}
# ---------- RDS PostgreSQL ----------
resource "aws_db_instance" "this" {
identifier = "${var.name_prefix}-${var.environment}"
engine = "postgres"
engine_version = "17"
instance_class = var.db_instance_class
allocated_storage = 20
storage_type = "gp3"
storage_encrypted = true
deletion_protection = true
skip_final_snapshot = false
final_snapshot_identifier = "${var.name_prefix}-${var.environment}-final"
db_name = "turnstone"
username = "turnstone"
password = random_password.db.result
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = [aws_security_group.rds.id]
backup_retention_period = 7
multi_az = false
tags = local.common_tags
}
@@ -0,0 +1,133 @@
# ---------- ALB Security Group ----------
resource "aws_security_group" "alb" {
name = "${var.name_prefix}-alb-${var.environment}"
description = "Allow inbound HTTP to ALB for server and console"
vpc_id = var.vpc_id
tags = local.common_tags
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
description = "HTTP traffic to server"
from_port = 80
to_port = 80
ip_protocol = "tcp"
cidr_ipv4 = "0.0.0.0/0"
tags = local.common_tags
}
resource "aws_vpc_security_group_ingress_rule" "alb_https" {
count = var.certificate_arn != "" ? 1 : 0
security_group_id = aws_security_group.alb.id
description = "HTTPS traffic to server"
from_port = 443
to_port = 443
ip_protocol = "tcp"
cidr_ipv4 = "0.0.0.0/0"
tags = local.common_tags
}
resource "aws_vpc_security_group_ingress_rule" "alb_console" {
security_group_id = aws_security_group.alb.id
description = "HTTP traffic to console"
from_port = 8090
to_port = 8090
ip_protocol = "tcp"
cidr_ipv4 = "0.0.0.0/0"
tags = local.common_tags
}
resource "aws_vpc_security_group_ingress_rule" "alb_console_https" {
count = var.certificate_arn != "" ? 1 : 0
security_group_id = aws_security_group.alb.id
description = "HTTPS traffic to console"
from_port = 8443
to_port = 8443
ip_protocol = "tcp"
cidr_ipv4 = "0.0.0.0/0"
tags = local.common_tags
}
resource "aws_vpc_security_group_egress_rule" "alb_all" {
security_group_id = aws_security_group.alb.id
description = "Allow all outbound"
ip_protocol = "-1"
cidr_ipv4 = "0.0.0.0/0"
tags = local.common_tags
}
# ---------- ECS Tasks Security Group ----------
resource "aws_security_group" "ecs_tasks" {
name = "${var.name_prefix}-ecs-tasks-${var.environment}"
description = "Allow traffic from ALB to ECS tasks and outbound internet"
vpc_id = var.vpc_id
tags = local.common_tags
}
resource "aws_vpc_security_group_ingress_rule" "ecs_from_alb_server" {
security_group_id = aws_security_group.ecs_tasks.id
description = "Server port from ALB"
from_port = 8080
to_port = 8080
ip_protocol = "tcp"
referenced_security_group_id = aws_security_group.alb.id
tags = local.common_tags
}
resource "aws_vpc_security_group_ingress_rule" "ecs_from_alb_console" {
security_group_id = aws_security_group.ecs_tasks.id
description = "Console port from ALB"
from_port = 8090
to_port = 8090
ip_protocol = "tcp"
referenced_security_group_id = aws_security_group.alb.id
tags = local.common_tags
}
resource "aws_vpc_security_group_egress_rule" "ecs_all" {
security_group_id = aws_security_group.ecs_tasks.id
description = "Allow all outbound (LLM APIs, ECR, Secrets Manager, etc.)"
ip_protocol = "-1"
cidr_ipv4 = "0.0.0.0/0"
tags = local.common_tags
}
# ---------- RDS Security Group ----------
resource "aws_security_group" "rds" {
name = "${var.name_prefix}-rds-${var.environment}"
description = "Allow PostgreSQL access from ECS tasks"
vpc_id = var.vpc_id
tags = local.common_tags
}
resource "aws_vpc_security_group_ingress_rule" "rds_from_ecs" {
security_group_id = aws_security_group.rds.id
description = "PostgreSQL from ECS tasks"
from_port = 5432
to_port = 5432
ip_protocol = "tcp"
referenced_security_group_id = aws_security_group.ecs_tasks.id
tags = local.common_tags
}
# ---------- Redis Security Group ----------
resource "aws_security_group" "redis" {
name = "${var.name_prefix}-redis-${var.environment}"
description = "Allow Redis access from ECS tasks"
vpc_id = var.vpc_id
tags = local.common_tags
}
resource "aws_vpc_security_group_ingress_rule" "redis_from_ecs" {
security_group_id = aws_security_group.redis.id
description = "Redis from ECS tasks"
from_port = 6379
to_port = 6379
ip_protocol = "tcp"
referenced_security_group_id = aws_security_group.ecs_tasks.id
tags = local.common_tags
}
@@ -0,0 +1,130 @@
# --- Networking ---
variable "vpc_id" {
description = "ID of the VPC where all resources will be created."
type = string
}
variable "private_subnet_ids" {
description = "List of private subnet IDs for ECS tasks, RDS, and ElastiCache."
type = list(string)
}
variable "public_subnet_ids" {
description = "List of public subnet IDs for the Application Load Balancer."
type = list(string)
}
# --- Container Image ---
variable "image_repository" {
description = "Container image repository."
type = string
default = "ghcr.io/turnstonelabs/turnstone"
}
variable "image_tag" {
description = "Container image tag."
type = string
default = "latest"
}
# --- LLM Provider ---
variable "llm_base_url" {
description = "Base URL for the LLM provider API (e.g. https://api.openai.com/v1)."
type = string
}
variable "openai_api_key" {
description = "API key for the LLM provider. Stored in AWS Secrets Manager."
type = string
sensitive = true
}
# --- RDS ---
variable "db_instance_class" {
description = "RDS instance class for PostgreSQL."
type = string
default = "db.t4g.micro"
}
# --- ElastiCache ---
variable "redis_node_type" {
description = "ElastiCache node type for Redis."
type = string
default = "cache.t4g.micro"
}
# --- ECS Task Sizing ---
variable "server_cpu" {
description = "CPU units for the server task (1 vCPU = 1024)."
type = number
default = 512
}
variable "server_memory" {
description = "Memory (MiB) for the server task."
type = number
default = 1024
}
variable "bridge_cpu" {
description = "CPU units for the bridge task."
type = number
default = 256
}
variable "bridge_memory" {
description = "Memory (MiB) for the bridge task."
type = number
default = 512
}
variable "console_cpu" {
description = "CPU units for the console task."
type = number
default = 256
}
variable "console_memory" {
description = "Memory (MiB) for the console task."
type = number
default = 512
}
# --- General ---
variable "environment" {
description = "Deployment environment name (e.g. production, staging)."
type = string
default = "production"
}
variable "name_prefix" {
description = "Prefix for all resource names."
type = string
default = "turnstone"
}
variable "auth_token" {
description = "Optional authentication token for the Turnstone API. Empty string disables auth."
type = string
sensitive = true
default = ""
}
variable "certificate_arn" {
description = "ACM certificate ARN for HTTPS listeners. Leave empty for HTTP-only (not recommended for production)."
type = string
default = ""
}
variable "tags" {
description = "Additional tags to apply to all resources."
type = map(string)
default = {}
}
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# Run database migrations before starting the service
python -m turnstone.core.storage._migrate || true
# Execute the actual command
exec "$@"
+61 -22
View File
@@ -4,10 +4,10 @@
> See also: [MQ Protocol diagram](diagrams/png/06-mq-protocol.png) | [Message Routing diagram](diagrams/png/07-message-routing.png) | [Redis Key Schema diagram](diagrams/png/08-redis-key-schema.png)
`turnstone-server` exposes a browser-based chat UI backed by a Python stdlib HTTP
server (`socketserver.ThreadingMixIn` + `http.server.HTTPServer`). The server
uses **Server-Sent Events (SSE)** for real-time streaming and **HTTP POST** for
user actions.
`turnstone-server` exposes a browser-based chat UI backed by a
**Starlette** ASGI application served by **uvicorn**. The server uses
**Server-Sent Events (SSE)** via `sse-starlette` for real-time streaming
and **HTTP POST** for user actions.
All API responses use `Content-Type: application/json` unless otherwise noted.
CORS headers (`Access-Control-Allow-Origin: *`) are included on every response.
@@ -17,6 +17,45 @@ an independent `ChatSession` and event queue.
---
## API Versioning
All API endpoints use the `/v1/` prefix. Non-API endpoints (`/`, `/health`, `/metrics`, `/openapi.json`, `/docs`, `/static/*`, `/shared/*`) are unversioned.
### Interactive Documentation
- **OpenAPI spec**: `GET /openapi.json` — machine-readable OpenAPI 3.1 schema
- **Swagger UI**: `GET /docs` — interactive API explorer (loads from CDN)
### Client SDKs
Typed client libraries for programmatic access to both the server and console APIs.
**Python** (included in the `turnstone` package):
```python
from turnstone.sdk import TurnstoneServer
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
ws = client.create_workstream(name="demo")
result = client.send_and_wait("Hello!", ws.ws_id)
print(result.content)
```
Async variant: `AsyncTurnstoneServer` / `AsyncTurnstoneConsole`.
**TypeScript** (`sdk/typescript/`):
```typescript
import { TurnstoneServer } from "@turnstone/sdk";
const client = new TurnstoneServer({ baseUrl: "http://localhost:8080", token: "tok_xxx" });
const ws = await client.createWorkstream({ name: "demo" });
const result = await client.sendAndWait("Hello!", ws.ws_id);
console.log(result.content);
```
---
## Endpoints
### `GET /`
@@ -29,7 +68,7 @@ below.
---
### `GET /api/events?ws_id=<id>`
### `GET /v1/api/events?ws_id=<id>`
Opens a Server-Sent Events stream scoped to a single workstream. The connection
remains open indefinitely; the server pushes events as they occur.
@@ -146,7 +185,7 @@ action required).
```
**`approve_request`** -- one or more tool calls that require user approval. The
client must respond via `POST /api/approve`.
client must respond via `POST /v1/api/approve`.
```json
{
@@ -213,7 +252,7 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
| `effort` | string | Reasoning effort level (`low`/`medium`/`high`) |
**`plan_review`** -- the model is proposing a plan and wants feedback. The
client must respond via `POST /api/plan`.
client must respond via `POST /v1/api/plan`.
```json
{"type": "plan_review", "content": "Step 1: ...\nStep 2: ..."}
@@ -267,7 +306,7 @@ connection begins streaming.
---
### `GET /api/events/global`
### `GET /v1/api/events/global`
Opens a Server-Sent Events stream that broadcasts state-change events across
all workstreams. This is used by the tab bar to display per-workstream activity
@@ -299,11 +338,11 @@ Possible `state` values:
and copies each event to every client queue. If a client queue is full, the
event is silently dropped for that client.
**Keepalive:** Same as `/api/events` -- an SSE comment every 5 seconds.
**Keepalive:** Same as `/v1/api/events` -- an SSE comment every 5 seconds.
---
### `GET /api/workstreams`
### `GET /v1/api/workstreams`
Returns a list of all active workstreams.
@@ -325,11 +364,11 @@ Each workstream object:
| `id` | string | Unique workstream routing identifier |
| `name` | string | Display name (alias if set, otherwise `ws-xxxx`) |
| `state` | string | Current state (see state values above) |
| `session_id` | string/null | Session ID of the workstream's `ChatSession`, used for deduplication against `/api/sessions` |
| `session_id` | string/null | Session ID of the workstream's `ChatSession`, used for deduplication against `/v1/api/sessions` |
---
### `GET /api/sessions`
### `GET /v1/api/sessions`
Returns a list of saved sessions from the database, ordered by most recently
updated.
@@ -364,7 +403,7 @@ Each session object:
---
### `POST /api/send`
### `POST /v1/api/send`
Sends a user message to a workstream. Spawns a daemon worker thread that calls
`session.send()` and streams results back via the SSE channel.
@@ -402,7 +441,7 @@ from a previous request. Also pushes a `busy_error` event to the SSE stream.
---
### `POST /api/approve`
### `POST /v1/api/approve`
Responds to a tool approval request. The SSE stream must have previously sent
an `approve_request` event for the given workstream.
@@ -434,7 +473,7 @@ automatically approved without prompting.
---
### `POST /api/plan`
### `POST /v1/api/plan`
Responds to a plan review dialog. The SSE stream must have previously sent a
`plan_review` event for the given workstream.
@@ -464,7 +503,7 @@ revision instructions).
---
### `POST /api/command`
### `POST /v1/api/command`
Executes a slash command in the given workstream.
@@ -499,7 +538,7 @@ containing the resumed session's messages.
---
### `POST /api/workstreams/new`
### `POST /v1/api/workstreams/new`
Creates a new workstream. The server supports up to 10 concurrent workstreams.
@@ -538,7 +577,7 @@ Status code: `400`
---
### `POST /api/workstreams/close`
### `POST /v1/api/workstreams/close`
Closes and removes a workstream. The last remaining workstream cannot be
closed.
@@ -592,8 +631,8 @@ Status code: `200` with an empty body.
| Malformed or unparseable JSON body | Treated as an empty dict `{}`; missing fields use defaults |
| Unknown `ws_id` | `404` with `{"error": "Unknown workstream"}` |
| Unknown path (GET or POST) | `404` with plain-text body `Not found` |
| Empty `message` on `/api/send` | `400` with `{"error": "Empty message"}` |
| Empty `command` on `/api/command` | `400` with `{"error": "Empty command"}` |
| Empty `message` on `/v1/api/send` | `400` with `{"error": "Empty message"}` |
| Empty `command` on `/v1/api/command` | `400` with `{"error": "Empty command"}` |
| Rate limit exceeded | `429` with `Retry-After` header (see below) |
### `429 Too Many Requests`
@@ -635,7 +674,7 @@ reconnection:
On reconnect, the server replays the full conversation history via the
`history` event, so the client can rebuild its UI state without data loss. The
same reconnection strategy applies to both the per-workstream SSE stream
(`/api/events`) and the global state stream (`/api/events/global`).
(`/v1/api/events`) and the global state stream (`/v1/api/events/global`).
---
@@ -743,7 +782,7 @@ turnstone_workstreams_active_total 1
# TYPE turnstone_http_requests_total counter
turnstone_http_requests_total{method="GET",endpoint="/health",status_code="200"} 42
turnstone_http_requests_total{method="GET",endpoint="/metrics",status_code="200"} 7
turnstone_http_requests_total{method="POST",endpoint="/api/send",status_code="200"} 18
turnstone_http_requests_total{method="POST",endpoint="/v1/api/send",status_code="200"} 18
# HELP turnstone_tokens_total Total tokens consumed
# TYPE turnstone_tokens_total counter
turnstone_tokens_total{type="prompt"} 84320
+319 -86
View File
@@ -1,9 +1,10 @@
# Turnstone Architecture
Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent
memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) and
gives the model 14 built-in tools plus external tools via MCP (Model Context
Protocol) for reading, writing, searching, planning, and executing code.
memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) or
Anthropic's native Messages API via pluggable provider adapters, and gives the
model 14 built-in tools plus external tools via MCP (Model Context Protocol) for
reading, writing, searching, planning, and executing code.
The core design principle is a **UI-agnostic engine with pluggable frontends**.
The engine (`ChatSession`) drives the conversation loop -- streaming, tool
@@ -32,11 +33,17 @@ turnstone/
eval.py Evaluation harness (HeadlessSession, scoring, prompt optimization)
core/
session.py ChatSession engine, SessionUI protocol, tool dispatch
providers/ LLM provider adapters (pluggable backend layer)
_protocol.py LLMProvider protocol, ModelCapabilities, StreamChunk, CompletionResult
_openai.py OpenAIProvider — OpenAI, vLLM, llama.cpp, any compatible API
_anthropic.py AnthropicProvider — Anthropic Messages API, native streaming, thinking
__init__.py create_provider() + create_client() factory functions
workstream.py Parallel workstream manager (WorkstreamState, Workstream, WorkstreamManager)
tools.py Tool schema loader (JSON -> OpenAI function-calling format)
mcp_client.py MCPClientManager — MCP server connections, tool discovery, async-sync bridge
model_registry.py ModelRegistry — named model configs, lazy client creation, fallback routing
memory.py SQLite persistence (conversations, memories, FTS5 search)
memory.py Persistence facade (delegates to storage backend)
storage/ Pluggable storage: StorageBackend protocol, SQLite + PostgreSQL
metrics.py Prometheus-compatible metrics collector (MetricsCollector)
healthcheck.py BackendHealthMonitor — periodic probe + circuit breaker
ratelimit.py Per-IP token-bucket rate limiter (RateLimiter, TokenBucket)
@@ -44,27 +51,45 @@ turnstone/
safety.py Command safety validation (blocked patterns, sanitization)
sandbox.py Math code sandboxing (AST validation, subprocess execution)
web.py Web utilities (HTML stripping, SSRF prevention)
api/
schemas.py Shared Pydantic v2 models (auth, errors, WorkstreamState)
server_schemas.py Server endpoint request/response models
console_schemas.py Console endpoint request/response models
openapi.py OpenAPI 3.1 spec builder
server_spec.py Server endpoint catalog → build_server_spec()
console_spec.py Console endpoint catalog → build_console_spec()
docs.py /openapi.json + /docs (Swagger UI) handler factories
sdk/
server.py AsyncTurnstoneServer + TurnstoneServer (HTTP client)
console.py AsyncTurnstoneConsole + TurnstoneConsole (HTTP client)
events.py 27 SSE event dataclasses with type registry
_base.py Shared httpx async client, auth, error handling
_sync.py Background event loop for sync wrappers
_types.py TurnResult + TurnstoneAPIError
mq/
protocol.py Inbound/outbound message dataclasses (JSON serialization)
broker.py Abstract MessageBroker protocol + RedisBroker
bridge.py Bridge service (queue ↔ turnstone-server HTTP API)
client.py TurnstoneClient library + TurnResult for external systems
client.py TurnstoneClient library + TurnResult for MQ-based access
console/
collector.py ClusterCollector — aggregates state from all nodes via Redis + HTTP
server.py Cluster dashboard HTTP server + SSE + CLI entry point
static/ Cluster dashboard web UI (HTML, CSS, JS)
static/ Cluster dashboard web UI (page-specific HTML, CSS, JS)
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
ui/
colors.py ANSI color constants with NO_COLOR support
markdown.py Streaming terminal markdown renderer (line-buffered)
spinner.py Braille character spinner (daemon thread)
static/
index.html Single-page app shell (links to CSS and JS)
style.css All UI styles (dark/light themes, dashboard, approval blocks)
app.js All client-side JavaScript (SSE, workstreams, dashboard, markdown)
style.css Page-specific UI styles (dashboard layout, approval blocks)
app.js Page-specific client-side JavaScript (SSE, workstreams, markdown)
tools/
*.json 14 tool schemas (OpenAI function-calling format + turnstone metadata)
```
Both UIs share a common design system extracted into `turnstone/shared_static/`: design tokens, login overlay, toast notifications, theme toggle, keyboard shortcuts, and utility functions. Each UI imports `base.css` and the shared JS modules at `/shared/`, then adds only page-specific code at `/static/`.
---
## Core Loop
@@ -86,7 +111,7 @@ A user message flows through the system as follows:
_emit_state("thinking")
|
v
_create_stream_with_retry() ----> client.chat.completions.create(stream=True)
_create_stream_with_retry() ----> provider.create_streaming(client, model, messages, ...)
| up to 3 retries (4 total attempts), exponential backoff
v
_stream_response(stream) --------> dispatch tokens to UI:
@@ -327,11 +352,11 @@ non-idle background workstreams above the input prompt.
- **Tab bar**: Each workstream renders as a tab with a colored state indicator
(CSS `@keyframes pulse` animation per state).
- **Per-tab SSE**: `connectContentSSE(wsId)` opens
`/api/events?ws_id=<id>` for the active tab's event stream.
- **Global SSE**: `connectGlobalSSE()` opens `/api/events/global` which
`/v1/api/events?ws_id=<id>` for the active tab's event stream.
- **Global SSE**: `connectGlobalSSE()` opens `/v1/api/events/global` which
receives `ws_state` broadcasts from all workstreams, used to update tab
indicators without switching.
- **New tab / close**: POST `/api/workstreams/new`, POST `/api/workstreams/close`.
- **New tab / close**: POST `/v1/api/workstreams/new`, POST `/v1/api/workstreams/close`.
### Thread Safety
@@ -405,7 +430,7 @@ from each schema and builds:
- `edit_file` -- string replacement in an existing file (requires prior `read_file`)
- `math` -- execute Python in sandboxed subprocess (via `turnstone.core.sandbox`)
- `web_fetch` -- fetch a URL (with SSRF protection via `turnstone.core.web`)
- `web_search` -- search the web via Tavily API
- `web_search` -- search the web (provider-native for Anthropic/OpenAI, Tavily fallback for local models)
**Agent (delegated sub-sessions)**:
- `task` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
@@ -476,6 +501,68 @@ at connection time (server names with `__` are rejected).
servers still connect. Tool execution errors return error strings to the LLM
rather than crashing the session.
### Provider Adapter Layer
> See also: [Core Engine Classes diagram](diagrams/png/03-core-engine-classes.png)
`ChatSession` is provider-agnostic — it delegates all LLM communication to an
`LLMProvider` protocol (`turnstone/core/providers/_protocol.py`). Internally,
messages use an OpenAI-like format; each provider translates at the API boundary.
```
ChatSession
|
v
LLMProvider (protocol)
|
+--- OpenAIProvider --- OpenAI, vLLM, llama.cpp, any /v1/chat/completions API
+--- AnthropicProvider --- Anthropic Messages API (native streaming, thinking)
```
**Protocol methods:**
| Method | Purpose |
|--------|---------|
| `create_streaming()` | Streaming request, yields normalized `StreamChunk` objects |
| `create_completion()` | Non-streaming request, returns `CompletionResult` |
| `get_capabilities()` | Per-model flags (`ModelCapabilities`) |
| `convert_tools()` | Translate OpenAI tool schemas to provider format |
| `retryable_error_names` | Exception class names that trigger retry |
**Normalized data types:**
| Type | Fields |
|------|--------|
| `StreamChunk` | `content_delta`, `reasoning_delta`, `tool_call_deltas`, `info_delta`, `usage`, `finish_reason` |
| `CompletionResult` | `content`, `tool_calls`, `finish_reason`, `usage` |
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search` |
| `UsageInfo` | `prompt_tokens`, `completion_tokens`, `total_tokens` |
**OpenAIProvider** (`_openai.py`): passes messages through unchanged (they are
already in OpenAI format). Model capability lookup table covers
GPT-5/5.1/5.2, O-series, and search models (`gpt-5-search-api`).
For search models, injects `web_search_options` and removes the `web_search`
function tool (the model always searches). Citations from `url_citation`
annotations are formatted as footnotes. Unknown models (local servers) get
permissive defaults and use Tavily for web search.
**AnthropicProvider** (`_anthropic.py`): converts OpenAI-format messages to
Anthropic content blocks, maps `system`/`developer` roles to the `system`
parameter, groups consecutive `tool` result messages into user-role content
blocks, and translates tool schemas from OpenAI function-calling format to
Anthropic's `input_schema` format. Supports both manual and adaptive thinking
modes, with effort parameter support for models like Claude Opus 4.6 and
Sonnet 4.6. Replaces the `web_search` function tool with Anthropic's native
`web_search_20250305` server-side tool — Claude decides when to search, the
API executes it, and results stream back as `server_tool_use` /
`web_search_tool_result` content blocks (emitted as `info_delta` for UI
display). The `anthropic` SDK is imported lazily so it remains an optional
dependency (`pip install turnstone[anthropic]`).
**Factory functions** (`__init__.py`): `create_provider(name)` returns a
singleton provider instance (thread-safe). `create_client(name, base_url,
api_key)` creates the appropriate SDK client.
### Multi-Model Registry
`ModelRegistry` (`turnstone/core/model_registry.py`) manages named model
@@ -486,34 +573,47 @@ configurations so workstreams can use different LLM backends.
[models.local]
base_url = "http://localhost:8000/v1"
model = "qwen3-32b"
# provider defaults to "openai"
[models.claude]
provider = "anthropic"
api_key = "sk-ant-..."
model = "claude-opus-4-6"
context_window = 200000
[models.openai]
base_url = "https://api.openai.com/v1"
api_key = "sk-..."
model = "gpt-4o"
context_window = 128000
model = "gpt-5"
context_window = 400000
[model]
default = "local"
fallback = ["openai"]
agent_model = "local"
fallback = ["claude", "openai"]
agent_model = "claude"
```
Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
(default: `"openai"`). Supported values: `"openai"` and `"anthropic"`.
**Lifecycle:**
1. `load_model_registry()` reads `[models.*]` sections from config.toml and
builds a `"default"` entry from CLI `--base-url`/`--model`/`--api-key` args
2. The registry is passed to the session factory closure in both `cli.py` and
`server.py`; each workstream resolves its model on creation
3. `ModelRegistry.get_client()` lazily creates `OpenAI` client instances
(thread-safe via `_client_lock`)
4. `/model` command shows available models; `/model <alias>` switches the
3. `ModelRegistry.get_client()` lazily creates SDK client instances via
`create_client()``OpenAI` for the openai provider, `Anthropic` for
the anthropic provider (thread-safe via `_client_lock`)
4. `ModelRegistry.get_provider()` lazily creates `LLMProvider` instances via
`create_provider()` (also cached and thread-safe)
5. `/model` command shows available models; `/model <alias>` switches the
active workstream's client, model, and context window
5. `_create_stream_with_retry()` tries the primary model, then each fallback
6. `_create_stream_with_retry()` tries the primary model, then each fallback
alias in order if the primary is unreachable
6. `_run_agent()` resolves `registry.agent_model` (if set) for plan/task
7. `_run_agent()` resolves `registry.agent_model` (if set) for plan/task
sub-agents, allowing a cheaper model for autonomous loops
**Per-workstream selection:** `POST /api/workstreams/new` accepts an optional
**Per-workstream selection:** `POST /v1/api/workstreams/new` accepts an optional
`"model"` field. The bridge `CreateWorkstreamMessage` carries the same field
through the MQ protocol.
@@ -538,11 +638,37 @@ This truncation message is visible to the model, so it knows output was cut.
## Persistence
### Database
### Storage Architecture
SQLite via `turnstone.core.memory`. Database file: `.turnstone.db` in the
current working directory (overridable via `memory.db_override` for eval
isolation).
Persistence is managed by the `turnstone.core.storage` package — a pluggable
backend behind a `StorageBackend` protocol. The `memory.py` facade provides
backward-compatible module-level functions that delegate to the active backend.
```
session.py / server.py / cli.py
memory.py (facade — silent-failure wrappers)
storage._registry (singleton factory)
┌─────────────┐ ┌──────────────────┐
│ SQLiteBackend │ │ PostgreSQLBackend │
│ (FTS5 search) │ │ (tsvector/ILIKE) │
└─────────────┘ └──────────────────┘
↓ ↓
storage._schema (SQLAlchemy Core tables — single source of truth)
storage._migrate (programmatic Alembic)
```
**SQLite** is the default (zero-config, single file at `.turnstone.db`).
**PostgreSQL** is the production backend (connection pooling, `tsvector`
full-text search). Select via `[database]` in `config.toml`, CLI flags, or
environment variables (`TURNSTONE_DB_BACKEND`, `TURNSTONE_DB_URL`).
Schema migrations are managed by Alembic and run automatically on startup.
Existing SQLite databases created before the migration system are auto-stamped
at the baseline revision.
### Tables
@@ -569,34 +695,53 @@ conversations
tool_name TEXT
tool_args TEXT
tool_call_id TEXT -- links tool_call ↔ tool_result for resume
provider_data TEXT -- raw provider content (e.g. Anthropic encrypted)
conversations_fts -- FTS5 virtual table
session_config
session_id TEXT NOT NULL -- composite PK with key
key TEXT NOT NULL
value TEXT
conversations_fts -- SQLite FTS5 virtual table (optional)
content (content=conversations, content_rowid=id)
```
The `tool_call_id` column was added via schema migration (`ALTER TABLE`) for
backwards compatibility with existing databases.
Table definitions live in `storage/_schema.py` (SQLAlchemy Core `Table` objects)
and are the single source of truth for both backends and Alembic migrations.
### Key Functions
### StorageBackend Protocol
| Function | Purpose |
|----------|---------|
| `open_db()` | Open/create database, run migrations, initialize tables |
| `load_memories()` | Return all `(key, value)` pairs sorted by key |
| `save_message(session_id, role, content, ...)` | Log a message to conversations (accepts `tool_call_id`) |
| `search_history(query, limit)` | Full-text search via FTS5 (falls back to LIKE) |
| `search_history_recent(limit)` | Return most recent messages |
| Method | Purpose |
|--------|---------|
| `register_session(session_id, title)` | Create a sessions row (no-op if exists) |
| `update_session_title(session_id, title)` | Set/update LLM-generated title |
| `save_message(session_id, role, content, ...)` | Log a message to conversations |
| `load_session_messages(session_id)` | Reconstruct OpenAI message format from DB rows |
| `list_sessions(limit)` | List sessions with >=1 message, ordered by updated DESC |
| `delete_session(session_id)` | Delete session and all its messages |
| `prune_sessions(retention_days)` | Remove empty sessions and old unnamed sessions |
| `resolve_session(alias_or_id)` | Resolve alias, exact id, or id prefix to full session_id |
| `save_session_config(session_id, config)` | Persist session configuration key/value pairs |
| `load_session_config(session_id)` | Retrieve session configuration |
| `set_session_alias(session_id, alias)` | Set user-friendly alias (returns False if taken) |
| `get_session_name(session_id)` | Return alias if set, else title, else None |
| `resolve_session(alias_or_id)` | Resolve alias, exact id, or id prefix to full session_id |
| `list_sessions(limit)` | List sessions with ≥1 message, ordered by updated DESC |
| `load_session_messages(session_id)` | Reconstruct OpenAI message format from DB rows |
| `delete_session(session_id)` | Delete session and all its messages |
| `prune_sessions(retention_days, log_fn)` | Remove empty sessions and old unnamed sessions; called at startup |
| `normalize_key(key)` | Normalize memory keys (`lower`, replace `-`/` ` with `_`) |
| `fts5_query(query)` | Convert plain text to safe FTS5 query (quoted terms) |
| `update_session_title(session_id, title)` | Set/update LLM-generated title |
| `kv_get(key)` / `kv_set(key, value)` / `kv_delete(key)` | Generic key-value store (backs memories table) |
| `kv_list()` / `kv_search(query)` | List or search key-value pairs |
| `search_history(query, limit)` | Full-text search (FTS5 on SQLite, tsvector on PostgreSQL) |
| `search_history_recent(limit)` | Return most recent messages |
| `close()` | Release resources (connection pool, engine) |
### Database Configuration
```toml
[database]
backend = "sqlite" # "sqlite" | "postgresql"
path = ".turnstone.db" # SQLite file path
url = "" # PostgreSQL connection URL
pool_size = 5 # PostgreSQL connection pool size
```
Environment variables: `TURNSTONE_DB_BACKEND`, `TURNSTONE_DB_URL`, `TURNSTONE_DB_PATH`.
### Session Persistence and Resume
@@ -738,7 +883,8 @@ HALF_OPEN ──(probe fails)──────────> OPEN
- `record_success()` / `record_failure()` update `_consecutive_failures` and
transition the `_state` (`CircuitState` enum: `CLOSED`, `OPEN`, `HALF_OPEN`).
- `should_allow_request()` returns `False` when the circuit is `OPEN`, causing
- `acquire_request_permit()` returns `False` when the circuit is `OPEN` or when
in `HALF_OPEN` and the single probe permit has already been consumed. Causes
`ChatSession._create_stream_with_retry` to skip the backend and surface an
error immediately.
- The `/health` endpoint reads the monitor's state: `"status": "ok"` when the
@@ -751,8 +897,12 @@ limits using a token-bucket algorithm. Each IP gets a `TokenBucket` with
`requests_per_second` (refill rate) and `burst` (bucket capacity) from
`[ratelimit]` config.
- Applied in `do_GET` / `do_POST` after authentication but before route dispatch.
- Applied via `RateLimitMiddleware` after authentication but before route dispatch.
- `/health` and `/metrics` are exempt (monitoring must always be reachable).
- **X-Forwarded-For support**: when `trusted_proxies` is configured (comma-separated
CIDRs), the middleware parses the `X-Forwarded-For` header using the
rightmost-untrusted approach. IPv4-mapped IPv6 addresses are normalized.
The direct client IP must be in the trusted set before XFF is considered.
- On limit exceeded: HTTP 429 with `Retry-After` header and JSON body
`{"error": "Rate limit exceeded", "retry_after": N}`.
- The `turnstone_ratelimit_rejected_total` counter is incremented on each
@@ -785,34 +935,52 @@ stderr so it does not interfere with readline. Tool execution may use a
### Server
```
ThreadedHTTPServer (ThreadingMixIn + HTTPServer, daemon_threads=True)
Starlette ASGI app (served by uvicorn)
|
+-- Thread per HTTP request
| POST /api/send -> worker thread per workstream
| POST /api/approve -> unblocks WebUI._approval_event
| POST /api/plan -> unblocks WebUI._plan_event
| POST /api/workstreams/new -> creates workstream + worker
| GET /api/events -> SSE long-poll (per workstream)
| GET /api/events/global -> SSE long-poll (fan-out)
+-- Async request handlers (all under /v1/ prefix)
| POST /v1/api/send -> starts worker thread per workstream
| POST /v1/api/approve -> unblocks WebUI._approval_event
| POST /v1/api/plan -> unblocks WebUI._plan_event
| POST /v1/api/workstreams/new -> creates workstream + worker
| GET /v1/api/events -> SSE via EventSourceResponse (per workstream)
| GET /v1/api/events/global -> SSE via EventSourceResponse (fan-out)
|
+-- Worker thread per workstream
| Runs session.send() in a loop
| Blocks on WebUI._approval_event / _plan_event
+-- ASGI middleware stack
| MetricsMiddleware -> CORSMiddleware -> AuthMiddleware -> RateLimitMiddleware
|
+-- Global SSE fan-out
WebUI._global_queue shared across all WebUI instances
Global SSE endpoint drains this queue
+-- Worker thread per workstream (daemon)
| Runs session.send() synchronously -- ChatSession is fully blocking
| Blocks on WebUI._approval_event / _plan_event (threading.Event)
|
+-- Background daemon threads
Global SSE fan-out: reads global_queue, copies to per-client queues
Idle cleanup: closes stale workstreams, cleans rate limiter buckets
```
`ThreadingMixIn` ensures each HTTP request (including long-lived SSE
connections) gets its own thread. This is necessary because SSE connections
block indefinitely, and POST requests must be handled concurrently.
Starlette handles all HTTP routing, CORS, and middleware. uvicorn runs
the ASGI application with async request handling. All API endpoints live
under the `/v1/` prefix via a Starlette `Mount`. An OpenAPI 3.1 spec is
generated from Pydantic v2 models and served at `/openapi.json`; Swagger
UI is available at `/docs`. SSE endpoints use `EventSourceResponse` from
`sse-starlette` with async generators that bridge sync `queue.Queue` via
`asyncio.get_running_loop().run_in_executor()`.
`ChatSession.send()` remains synchronous, running in daemon worker threads.
WebUI keeps `threading.Event` and `queue.Queue` primitives (unchanged from
the sync era). The `_global_fanout_thread` and `_idle_cleanup_thread` remain
as daemon threads since they interact with sync primitives. A lifespan
context manager handles startup/shutdown (health monitor, MCP client,
registry).
Each workstream's `WebUI` has:
- `_event_queue` (per-workstream SSE events)
- `_event_queue` (per-workstream SSE events, `queue.Queue`)
- `_approval_event` / `_plan_event` (`threading.Event` for blocking)
- `_global_queue` (class variable, shared, for state broadcasts)
The SSE handlers bridge these sync queues to async via
`run_in_executor()`, polling `queue.Queue.get(timeout=1)` while
`sse-starlette` handles keepalive pings automatically.
### Workstream Threading (CLI)
```
@@ -843,7 +1011,8 @@ bell + status line to stderr to alert the user.
Main thread Global SSE thread Per-WS SSE threads (×N)
+------------------+ +------------------+ +-------------------+
| Inbound loop | | GET /events/glob | | GET /events?ws_id |
| BLPOP on Redis | | Parse SSE data | | Parse SSE data |
| BLPOP on Redis | | Parse SSE via | | Parse SSE via |
| | | httpx-sse | | httpx-sse |
| Dispatch to | | Forward state | | Forward content, |
| handler | | changes | | tool results |
| POST to server | | Detect turn | | Handle approval |
@@ -859,10 +1028,10 @@ Main thread Global SSE thread Per-WS SSE threads (×N)
**Approval flow:** When a per-WS SSE thread receives an `approve_request`, it checks
the workstream's `auto_approve_tools` set. If all requested tools are in the set, the
bridge auto-approves via `POST /api/approve`. Otherwise, it publishes an
bridge auto-approves via `POST /v1/api/approve`. Otherwise, it publishes an
`ApprovalRequestEvent` to the outbound channel with a `request_id`, then blocks on
`BLPOP` of a Redis response queue (`turnstone:resp:{request_id}`) until the client pushes
a response or the approval timeout (default 300s) expires.
a response or the approval timeout (default 3600s / 1 hour) expires.
**Completion detection:** The bridge tracks which `correlation_id` maps to which
`ws_id` for active sends. When the global SSE reports `ws_state → idle` for a tracked
@@ -879,25 +1048,51 @@ re-routes to that node's queue (1 extra hop). Bridges publish heartbeats to
### Cluster Console
```
Event subscriber Node discovery Poll loop
+------------------+ +------------------+ +-------------------+
| SUBSCRIBE on | | SCAN node:* keys | | For each node: |
| events:cluster | | every 15 seconds | | GET /api/dash |
| Apply state | | Add/remove nodes | | GET /health |
| changes to | | Emit join/lost | | ThreadPoolExecutor|
| in-memory model | | events | | (50 workers) |
+------------------+ +------------------+ +-------------------+
| | |
+-- Redis pub/sub +-- Redis SCAN +-- HTTP to each
(SUBSCRIBE) (every 15s) server (every 10s)
Monitoring (3 daemon threads) Control + Proxy (async Starlette)
+------------------+ +----------------------------+
| Event subscriber | | POST /v1/api/cluster/ |
| SUBSCRIBE on | | workstreams/new |
| events:cluster | | → LPUSH to Redis |
+------------------+ | inbound:{node_id} |
| Node discovery | +----------------------------+
| SCAN node:* keys | | GET /node/{node_id}/ |
| every 15 seconds | | → httpx.AsyncClient |
+------------------+ | proxy to server_url |
| Poll loop | | GET /node/{id}/v1/api/events |
| GET /v1/api/dash | | → SSE stream proxy |
| GET /health | | POST /node/{id}/v1/api/send |
| ThreadPoolExec | | → forwarded to server |
+------------------+ +----------------------------+
```
The console is read-only — it never writes to Redis queues or sends commands to servers.
Real-time events provide instant state transitions; periodic polling provides full data
consistency (tokens, context ratios, activity strings). Clicking a workstream row in the
console opens the node's server UI with `?ws_id=<id>` for direct deep linking — the
server parses this on load and auto-selects the workstream. See [docs/console.md](console.md)
for the full API reference.
The console HTTP layer is a Starlette/ASGI app served by uvicorn. The SSE
endpoint uses `EventSourceResponse` with the same listener queue pattern as
the main server. `ClusterCollector`'s background threads (event subscriber,
node discovery, poll loop) use sync Redis clients and `ThreadPoolExecutor`
for parallel HTTP polling.
The console has two write-path capabilities:
1. **Workstream creation** — pushes `CreateWorkstreamMessage` to Redis inbound
queues targeting specific nodes. The bridge on each node picks up the message
and creates the workstream on the local server. Auto-selects the node with
the most available capacity if no target is specified.
2. **Reverse proxy** — serves each node's server UI through the console port at
`/node/{node_id}/`. Uses `httpx.AsyncClient` to proxy HTTP and SSE traffic.
A JS shim is injected into the server's `app.js` to override `fetch()` and
`EventSource()`, routing root-relative URLs through the proxy prefix. This
eliminates the need for direct network access to individual server nodes.
The console also performs **version drift detection** — flagging when nodes
report different versions via the `/health` endpoint. The overview API includes
`version_drift` and `versions` fields; the dashboard shows a yellow warning
indicator when versions diverge.
Clicking a workstream row in the console opens the proxied server UI at
`/node/{node_id}/?ws_id=<id>` — the server's JS parses this on load and
auto-selects the workstream. See [docs/console.md](console.md) for the full
API reference.
---
@@ -919,3 +1114,41 @@ preserves:
After compaction, `_read_files` is cleared to force re-reads before edits,
since file contents are no longer in the message history.
---
## Client SDK
> See also: [SDK Architecture diagram](diagrams/png/13-sdk-architecture.png) | [SDK Documentation](sdk.md)
The `turnstone/sdk/` package provides typed HTTP clients for programmatic access
to both the server and console APIs. It wraps REST endpoints with methods that
return Pydantic models, and SSE endpoints with async/sync iterators that yield
typed event dataclasses.
**Two client pairs** (sync + async):
- `TurnstoneServer` / `AsyncTurnstoneServer` — server API (workstreams, chat, streaming, sessions)
- `TurnstoneConsole` / `AsyncTurnstoneConsole` — console API (cluster overview, nodes, workstreams)
**Design**: async-first with thin sync wrappers. `_BaseClient` provides httpx
setup, auth headers, `_request()` (REST) and `_stream_sse()` (SSE). Sync
clients delegate through `_SyncRunner` which maintains a persistent background
event loop on a daemon thread.
**Event types**: 27 standalone dataclasses in `events.py` with a type-registry
pattern matching `OutboundEvent.from_json()` from `mq/protocol.py`. Events are
decoupled from the MQ package so SDK consumers don't need the `redis` dependency.
**TypeScript SDK**: `sdk/typescript/` — separate npm package with the same API
surface. Zero browser dependencies, SSE via `fetch` + `ReadableStream` parsing.
```python
# Python quick start
from turnstone.sdk import TurnstoneServer
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
ws = client.create_workstream(name="demo")
result = client.send_and_wait("Hello!", ws.ws_id)
print(result.content)
```
+133 -27
View File
@@ -1,28 +1,40 @@
# Cluster Dashboard (turnstone-console)
`turnstone-console` is a standalone monitoring service that provides cluster-wide visibility across all turnstone nodes. It connects to the shared Redis broker, discovers nodes via heartbeat keys, polls each node's HTTP API for workstream data, and subscribes to a cluster event channel for real-time state changes.
`turnstone-console` is a cluster management service that provides cluster-wide visibility and control across all turnstone nodes. It connects to the shared Redis broker, discovers nodes via heartbeat keys, polls each node's HTTP API for workstream data, and subscribes to a cluster event channel for real-time state changes.
The console is read-only — it observes but does not own workstreams or drive LLM sessions.
The console also supports **workstream creation** (dispatched via MQ to target nodes) and a **reverse proxy** that serves each node's server UI through the console port — so users only need network access to the console, not to individual server nodes.
## Architecture
> See also: [Console Data Flow diagram](diagrams/png/11-console-data-flow.png)
```
turnstone-server ── turnstone-bridge ──→ Redis ──→ turnstone-console ──→ Browser
(per node) (per node) (shared) (one instance)
┌── Redis ←── turnstone-bridge ── turnstone-server
│ (MQ) (per node) (per node)
turnstone-console ──────┤
(one instance) │
└── turnstone-server (direct HTTP proxy)
Browser
```
Each bridge publishes state changes to `{prefix}:events:cluster` on Redis pub/sub. The console subscribes once to that channel for real-time updates and periodically polls each node's `GET /api/dashboard` for full workstream snapshots.
Data flows in two directions:
- **Inbound (monitoring):** Bridges publish state changes to `{prefix}:events:cluster` on Redis pub/sub. The console subscribes for real-time updates and periodically polls each node's `GET /v1/api/dashboard` for full workstream snapshots.
- **Outbound (control):** The console pushes `CreateWorkstreamMessage` to Redis inbound queues targeting specific nodes. Bridges pick up these messages and create workstreams on their local servers.
- **Proxy (pass-through):** The console reverse-proxies each node's server UI at `/node/{node_id}/`, forwarding HTTP and SSE traffic so the browser never contacts server nodes directly.
### Data Sources
| Source | Method | Frequency | Data |
| Source | Method | Direction | Data |
|--------|--------|-----------|------|
| Redis heartbeats | `SCAN turnstone:node:*` | Every 15s | Node discovery (node_id, server_url, started) |
| Redis pub/sub | `SUBSCRIBE turnstone:events:cluster` | Real-time | State changes, creates, closes, renames |
| Node HTTP API | `GET {server_url}/api/dashboard` | Every 10s | Full workstream list with tokens, context, activity |
| Node HTTP API | `GET {server_url}/health` | Every 10s | Node health status |
| Redis heartbeats | `SCAN turnstone:node:*` | Read | Node discovery (node_id, server_url, started) |
| Redis pub/sub | `SUBSCRIBE turnstone:events:cluster` | Read | State changes, creates, closes, renames |
| Node HTTP API | `GET {server_url}/v1/api/dashboard` | Read | Full workstream list with tokens, context, activity |
| Node HTTP API | `GET {server_url}/health` | Read | Node health status |
| Redis inbound queue | `RPUSH turnstone:inbound:{node_id}` | Write | Workstream creation commands |
| Node HTTP API | `GET/POST {server_url}/*` | Proxy | Server UI, API requests, SSE streams |
### Redis Key: Cluster Event Channel
@@ -47,7 +59,7 @@ The collector (`turnstone/console/collector.py`) maintains an in-memory snapshot
2. **Node discovery** — scans heartbeat keys every 15 seconds via `broker.list_nodes()`. Adds newly discovered nodes, removes expired ones, emits `node_joined` / `node_lost` events to SSE listeners.
3. **Poll loop** — fetches `GET /api/dashboard` and `GET /health` from each known node every 10 seconds. Uses `ThreadPoolExecutor(max_workers=50)` for parallelism. Each poll replaces the node's workstream list with the authoritative server data.
3. **Poll loop** — fetches `GET /v1/api/dashboard` and `GET /health` from each known node every 10 seconds. Uses `ThreadPoolExecutor(max_workers=50)` for parallelism. Each poll replaces the node's workstream list with the authoritative server data.
### Thread Safety
@@ -64,7 +76,7 @@ All reads and writes to the node/workstream map are protected by a single `threa
## HTTP API
### `GET /api/cluster/overview`
### `GET /v1/api/cluster/overview`
Cluster-wide state counts and aggregate metrics.
@@ -73,11 +85,15 @@ Cluster-wide state counts and aggregate metrics.
"nodes": 847,
"workstreams": 4219,
"states": {"running": 1847, "thinking": 312, "attention": 89, "idle": 1940, "error": 31},
"aggregate": {"total_tokens": 12400000, "total_tool_calls": 34200}
"aggregate": {"total_tokens": 12400000, "total_tool_calls": 34200},
"version_drift": true,
"versions": ["0.3.0", "0.3.1"]
}
```
### `GET /api/cluster/nodes?sort=activity&limit=100&offset=0`
`version_drift` is `true` when nodes report different versions. `versions` lists all unique version strings sorted alphabetically.
### `GET /v1/api/cluster/nodes?sort=activity&limit=100&offset=0`
Paginated node list. Sort options: `activity` (default, by running+attention count), `tokens`, `name`.
@@ -91,14 +107,15 @@ Paginated node list. Sort options: `activity` (default, by running+attention cou
"total_tokens": 48200,
"started": 1709294400.0,
"reachable": true,
"health": {}
"health": {"status": "ok", "version": "0.3.0"},
"version": "0.3.0"
}
],
"total": 847
}
```
### `GET /api/cluster/workstreams?state=running&node=db-west-04&search=perf&page=1&per_page=50`
### `GET /v1/api/cluster/workstreams?state=running&node=db-west-04&search=perf&page=1&per_page=50`
Filtered, paginated workstream list. All query parameters are optional. `per_page` is capped at 200.
@@ -115,7 +132,7 @@ Filtered, paginated workstream list. All query parameters are optional. `per_pag
}
```
### `GET /api/cluster/node/{node_id}`
### `GET /v1/api/cluster/node/{node_id}`
Single node detail with all its workstreams.
@@ -129,7 +146,41 @@ Single node detail with all its workstreams.
}
```
### `GET /api/cluster/events`
### `POST /v1/api/cluster/workstreams/new`
Create a new workstream on a target node. Dispatches a `CreateWorkstreamMessage` through the Redis MQ pipeline — the bridge on the target node picks it up and creates the workstream on the server. Requires `"full"` auth role.
Request:
```json
{
"node_id": "db-west-04",
"name": "perf-analysis",
"model": "gpt-5"
}
```
All fields are optional:
- `node_id` — targeting mode:
- **omitted or `"auto"`** — console picks the reachable node with the most available capacity (max_ws - ws_total) and pushes to its directed queue.
- **`"pool"`** — pushes to the shared inbound queue; the next available bridge picks it up (true general-pool dispatch).
- **specific node ID** — pushes to that node's directed queue.
- `name` — workstream display name. Auto-generated if omitted.
- `model` — model alias from the target node's registry. Uses the node's default model if omitted.
Response:
```json
{
"status": "ok",
"correlation_id": "a1b2c3d4e5f6",
"target_node": "db-west-04"
}
```
Creation is asynchronous — the response confirms the MQ message was dispatched. A `ws_created` event on the cluster SSE stream confirms the workstream was actually created.
### `GET /v1/api/cluster/events`
Server-Sent Events stream for real-time cluster updates.
@@ -146,32 +197,86 @@ Keepalive comments (`: keepalive\n\n`) are sent every 5 seconds. Clients should
### `GET /health`
```json
{"status": "ok", "service": "turnstone-console", "nodes": 847, "workstreams": 4219}
{
"status": "ok",
"service": "turnstone-console",
"nodes": 847,
"workstreams": 4219,
"version_drift": false,
"versions": ["0.3.0"]
}
```
---
## Reverse Proxy
The console reverse-proxies each node's server UI at `/node/{node_id}/`. This allows users to interact with any node's workstreams through the console port alone — individual server ports do not need to be exposed to the office network.
### Proxy Routes
| Route | Behavior |
|-------|----------|
| `GET /node/{node_id}/` | Fetches the server's `index.html`, rewrites static and shared asset paths, injects a console-return banner and an inline JS proxy shim |
| `GET /node/{node_id}/static/{path}` | Proxies page-specific static files |
| `GET /node/{node_id}/shared/{path}` | Proxies shared static files (`base.css`, `auth.js`, etc.) |
| `GET /node/{node_id}/v1/api/{path}` | Proxies GET API requests; detects SSE endpoints and streams them |
| `POST /node/{node_id}/v1/api/{path}` | Proxies POST API requests with body forwarding |
| `GET /node/{node_id}/{path}` | Proxies non-API endpoints (health, metrics) |
### URL Rewriting
The server UI uses root-relative URLs (`/v1/api/send`, `/static/app.js`, `/shared/base.css`, etc.). Since `<base>` tags cannot rewrite root-relative URLs, the console uses a JS shim approach:
1. **HTML rewriting** — when serving `index.html`, replaces `href=` and `src=` references to both `/static/` and `/shared/` with the proxy prefix (`/node/{node_id}/static/` and `/node/{node_id}/shared/` respectively).
2. **Inline JS shim** — injects an inline `<script>` block into the proxied HTML (after the console-return banner, before any external scripts) that overrides `window.fetch()` and `window.EventSource()` to prepend the proxy prefix to any root-relative URL. Running the shim inline ensures it executes before any external scripts load, so all API calls and SSE connections are intercepted transparently.
3. **Console-return banner** — injects a thin inline-styled `<div>` after `<body>` with a "← Console" link and the node ID, providing navigation back to the dashboard.
### SSE Proxy
SSE streams (`/v1/api/events`, `/v1/api/events/global`) are proxied by creating a per-connection `httpx.AsyncClient(timeout=None)`, streaming the upstream response via `aiter_text()`, parsing SSE framing (`\n\n` delimiters), and re-emitting events through `EventSourceResponse`. Each proxied SSE stream requires its own httpx client since the shared client's 30-second timeout would kill long-lived connections.
### Authentication
The proxy forwards requests to server nodes using the console's `--auth-token`. The console's own auth middleware also checks proxy routes — `POST` requests to proxy write endpoints (`/v1/api/send`, `/v1/api/approve`, etc.) require the `"full"` auth role, preventing read-only tokens from escalating to write operations.
---
## Browser Dashboard
The web UI has three views, toggled client-side:
The web UI has four views, toggled client-side:
### 1. Cluster Overview (landing)
- **State cards** — 5 clickable cards (running, thinking, attention, idle, error) with count and colored top border. Clicking filters to that state.
- **Aggregate bar** — total tokens and tool calls across the cluster.
- **Node table** — columns: NODE, WS, RUN, ATTN, TOKENS, HEALTH. Sorted by activity. Clickable rows drill down to node detail.
- **Node table** — columns: NODE, WS, RUN, ATTN, TOKENS, VER, LOAD. Sorted by activity. Clickable rows drill down to node detail. Version column shows per-node version; hidden on mobile.
- **Version drift indicator** — when nodes report different versions, the status bar shows a yellow "DRIFT" warning with a tooltip listing all versions. Node groups show "mixed" with a yellow badge when their members disagree.
- **"+ new" button** — opens the workstream creation modal (see below).
### 2. Node Drill-down
Breadcrumb: `Cluster > db-west-04`. Shows the node's workstreams in a table matching the per-node dashboard layout (STATE, NAME, NODE, TASK, TOKENS, CTX) with activity sub-lines. Includes a link to the node's own dashboard (`http://{server_url}/`).
Breadcrumb: `Cluster > db-west-04`. Shows the node's workstreams in a table matching the per-node dashboard layout (STATE, NAME, MODEL, NODE, TASK, TOKENS, CTX) with activity sub-lines. Includes a link to the node's proxied server UI.
**Deep linking:** Clicking a workstream row opens the node's server UI in a new tab with `?ws_id=<id>`, which auto-selects that workstream. A `↗` indicator appears on hover to signal the external navigation. Rows without a `server_url` are non-interactive.
**Proxy deep-linking:** Clicking a workstream row opens the node's server UI in a new tab via the proxy at `/node/{node_id}/?ws_id=<id>`, which auto-selects that workstream. Users do not need direct network access to the server node.
### 3. Filtered Workstreams
Breadcrumb: `Cluster > Running` or `Cluster > db-west-04`. Server-side paginated workstream table. NODE column values are clickable to filter further. Pagination controls at bottom. Workstream rows are deep-linkable when `server_url` is available (injected by the collector from the parent node).
Breadcrumb: `Cluster > Running` or `Cluster > db-west-04`. Server-side paginated workstream table. NODE column values are clickable to filter further. Pagination controls at bottom. Workstream rows use proxy deep-links.
All three views receive live updates via SSE — state cards update counts, node rows update metrics, workstream rows update state indicators.
### 4. Workstream Creation Modal
Triggered by the "+ new" header button. A modal dialog with:
- **Node selector** — dropdown with three targeting modes: "Auto (best available)" picks the node with the most headroom, "General pool (any node)" pushes to the shared queue for any bridge to pick up, or a specific node from the list (showing capacity).
- **Name** — optional text input. Auto-generated if left empty.
- **Model** — optional text input for a model alias from the target node's registry.
On submit, `POST /v1/api/cluster/workstreams/new` dispatches the creation request. A toast confirms success; the SSE stream delivers the `ws_created` event to update the dashboard.
All four views receive live updates via SSE — state cards update counts, node rows update metrics, workstream rows update state indicators.
---
@@ -201,6 +306,7 @@ CLI flags for `turnstone-console`:
| `--redis-password` | `$REDIS_PASSWORD` | Redis password |
| `--redis-db` | `0` | Redis DB |
| `--poll-interval` | `10` | Node polling interval (seconds) |
| `--auth-token` | `$TURNSTONE_AUTH_TOKEN` | Bearer token for server node communication and proxy |
| `--log-level` | `INFO` | Log level |
Config file (`~/.config/turnstone/config.toml`):
@@ -233,7 +339,7 @@ turnstone-server --port 8080
turnstone-bridge --server-url http://localhost:8080 --node-id node-a
# Start cluster console (one instance)
turnstone-console --redis-host localhost --port 8090
turnstone-console --redis-host localhost --port 8090 --auth-token "$TURNSTONE_AUTH_TOKEN"
```
Open `http://localhost:8090` for the cluster dashboard.
Open `http://localhost:8090` for the cluster dashboard. Create workstreams via the "+ new" button. Click any workstream to open the proxied server UI — no direct access to server ports required.
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d5a2bd1c55ac8cf3b777a8decb6f3bb3d063c10c8f3a9e63457079830e48f456
size 162310
+11 -8
View File
@@ -9,7 +9,10 @@ actor "External Client\n(Python / CI)" as ext_client
actor "Eval Harness" as eval_user
' External Systems
cloud "LLM Provider\n(OpenAI-compatible API)" as llm
cloud "LLM Providers" as llm {
component [OpenAI-compatible API\n(OpenAI, vLLM, llama.cpp)] as llm_openai
component [Anthropic Messages API] as llm_anthropic
}
database "Redis" as redis
database "SQLite\n(.turnstone.db)" as sqlite
@@ -31,21 +34,21 @@ ext_client --> redis : Redis LIST\n(push commands)
eval_user --> eval : Python API
' Internal connections
cli --> llm : OpenAI Streaming API\n(HTTPS)
cli --> llm : LLM Provider API\n(via provider adapters)
cli --> sqlite : SQLite
server --> llm : OpenAI Streaming API\n(HTTPS)
server --> llm : LLM Provider API\n(via provider adapters)
server --> sqlite : SQLite
eval --> llm : OpenAI API\n(non-streaming)
eval --> llm : LLM Provider API\n(non-streaming)
eval --> sqlite : SQLite
bridge --> server : HTTP REST\n(POST /api/send, etc.)
bridge <-- server : SSE\n(GET /api/events)
bridge --> server : HTTP REST\n(POST /v1/api/send, etc.)
bridge <-- server : SSE\n(GET /v1/api/events)
bridge --> redis : Redis LIST + PUBSUB\n+ STRING (routing, heartbeats)
console --> redis : Redis PUBSUB + STRING\n(cluster channel, heartbeats)
console --> server : HTTP polling\n(GET /api/dashboard)
console --> redis : Redis PUBSUB + STRING + LIST\n(cluster events, heartbeats,\nworkstream creation commands)
console --> server : HTTP polling + reverse proxy\n(GET /v1/api/dashboard,\nproxy /node/{id}/* traffic)
sim --> redis : Redis LIST + PUBSUB\n+ STRING (heartbeats)
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:09cee5819bb7820641a466ed29a53f1b479bc2ded83079fc798c7410bf741a62
size 329703
+43 -1
View File
@@ -11,6 +11,8 @@ skinparam component {
BackgroundColor<<console>> #B2EBF2
BackgroundColor<<ui>> #F0F4C3
BackgroundColor<<artifact>> #ECEFF1
BackgroundColor<<sdk>> #FFCDD2
BackgroundColor<<api>> #D1C4E9
}
' Entry points
@@ -24,9 +26,11 @@ package "Entry Points" <<Rectangle>> {
' Core engine
package "turnstone/core/" <<Rectangle>> {
component [session.py\nChatSession, SessionUI] as session <<core>>
component [providers/\nLLMProvider, OpenAI, Anthropic] as providers <<core>>
component [workstream.py\nWorkstreamManager] as workstream <<core>>
component [tools.py\nTool loader] as tools <<core>>
component [memory.py\nSQLite + FTS5] as memory <<core>>
component [memory.py\nPersistence facade] as memory <<core>>
component [storage/\nStorageBackend protocol\nSQLite + PostgreSQL] as storage <<core>>
component [metrics.py\nPrometheus metrics] as metrics <<core>>
component [config.py\nTOML config] as config <<core>>
component [safety.py\nPath validation] as safety <<core>>
@@ -72,6 +76,23 @@ package "turnstone/ui/" <<Rectangle>> {
component [spinner.py\nTerminal spinner] as spinner <<ui>>
}
' API schemas
package "turnstone/api/" <<Rectangle>> {
component [schemas.py\nShared Pydantic models] as apischemas <<api>>
component [server_spec.py\nServer OpenAPI spec] as serverspec <<api>>
component [console_spec.py\nConsole OpenAPI spec] as consolespec <<api>>
component [openapi.py\nSpec builder] as openapi <<api>>
component [docs.py\nSwagger UI handler] as apidocs <<api>>
}
' SDK
package "turnstone/sdk/" <<Rectangle>> {
component [server.py\nTurnstoneServer (sync+async)] as sdkserver <<sdk>>
component [console.py\nTurnstoneConsole (sync+async)] as sdkconsole <<sdk>>
component [events.py\n27 SSE event types] as sdkevents <<sdk>>
component [_base.py\nhttpx client base] as sdkbase <<sdk>>
}
' Tool schemas
package "turnstone/tools/" <<Rectangle>> {
component [*.json\n14 tool schemas] as schemas <<artifact>>
@@ -105,8 +126,10 @@ eval --> tools
chat --> session
' Core internal deps
session --> providers
session --> tools
session --> memory
memory --> storage
session --> safety
session --> sandbox
session --> edit
@@ -114,6 +137,7 @@ session --> web
session --> healthcheck
session --> mcp : optional
session --> registry : optional
registry --> providers
healthcheck --> metrics
mcp --> config
registry --> config
@@ -149,4 +173,22 @@ consoleserver --> config
consoleserver --> auth
collector --> broker
' API dependencies
serverspec --> openapi
consolespec --> openapi
serverspec --> apischemas
consolespec --> apischemas
server --> apidocs
server --> serverspec
consoleserver --> apidocs
consoleserver --> consolespec
' SDK dependencies
sdkserver --> sdkbase
sdkconsole --> sdkbase
sdkserver --> sdkevents
sdkconsole --> sdkevents
sdkserver --> apischemas : returns models
sdkconsole --> apischemas : returns models
@enduml
+64 -5
View File
@@ -52,6 +52,8 @@ class "WebUI" as WebUI {
Enqueues JSON events for SSE.
Blocks on threading.Event for
approval/plan review.
SSE handlers bridge Queue to
async via run_in_executor().
--
server.py
}
@@ -63,9 +65,55 @@ class "NullUI" as NullUI {
eval.py
}
' LLMProvider Protocol
interface "LLMProvider" as LLMProvider <<Protocol>> {
+ provider_name: str {property}
+ get_capabilities(model) → ModelCapabilities
+ create_streaming(client, model, messages, ...) → Iterator[StreamChunk]
+ create_completion(client, model, messages, ...) → CompletionResult
+ convert_tools(tools) → list[dict]
+ retryable_error_names: frozenset[str] {property}
--
core/providers/_protocol.py
}
class "OpenAIProvider" as OpenAIProv {
Model capability lookup table
(GPT-5.x, O-series, search)
Passthrough: messages already
in OpenAI format.
Search models: web_search_options
+ url_citation annotations.
--
core/providers/_openai.py
}
class "AnthropicProvider" as AnthropicProv {
Converts OpenAI messages to
Anthropic content blocks.
Adaptive + manual thinking.
Native web search via
web_search_20250305 server tool.
Lazy anthropic SDK import.
--
core/providers/_anthropic.py
}
' ModelCapabilities
class "ModelCapabilities" as ModelCaps <<frozen>> {
+ context_window: int
+ max_output_tokens: int
+ supports_temperature: bool
+ token_param: str
+ thinking_mode: str
+ supports_effort: bool
+ supports_web_search: bool
}
' ChatSession
class "ChatSession" as ChatSession {
- client: OpenAI
- client: Any
- provider: LLMProvider
- model: str
- ui: SessionUI
- messages: list[dict]
@@ -172,20 +220,22 @@ class "MCPClientManager" as MCPMgr {
' ModelRegistry
class "ModelRegistry" as ModelReg {
- _models: dict[str, ModelConfig]
- _clients: dict[str, OpenAI]
- _clients: dict[str, Any]
- _providers: dict[str, LLMProvider]
- _client_lock: Lock
+ default: str
+ fallback: list[str]
+ agent_model: str | None
--
+ resolve(alias) → (client, model, config)
+ get_client(alias) → OpenAI
+ get_client(alias) → Any
+ get_provider(alias) → LLMProvider
+ has_alias(alias) → bool
+ list_aliases() → list[str]
+ shutdown()
--
Thread-safe lazy client creation.
Loaded by load_model_registry()
Thread-safe lazy client + provider
creation. Loaded by load_model_registry()
from CLI args + [models.*] config.
--
core/model_registry.py
@@ -193,6 +243,7 @@ class "ModelRegistry" as ModelReg {
class "ModelConfig" as ModelCfg <<frozen>> {
+ alias: str
+ provider: str
+ base_url: str
+ model: str
+ context_window: int
@@ -260,7 +311,11 @@ TerminalUI <|-- WsTermUI
SessionUI <|.. WebUI
SessionUI <|.. NullUI
LLMProvider <|.. OpenAIProv
LLMProvider <|.. AnthropicProv
ChatSession --> SessionUI : uses
ChatSession --> LLMProvider : delegates LLM calls
ChatSession --> MCPMgr : optional
ChatSession --> ModelReg : optional
ChatSession <|-- HeadlessSession
@@ -273,6 +328,8 @@ Ws --> "1" WsState : has
WsMgr ..> ChatSession : creates via\nsession_factory(ui, model_alias)
ModelReg --> "*" ModelCfg : holds
ModelReg --> "*" LLMProvider : caches
LLMProvider --> ModelCaps : returns
ChatSession --> HealthMon : checks circuit
HealthMon --> "1" CircuitState : has
@@ -282,6 +339,8 @@ note bottom of ChatSession
Central engine: multi-turn LLM loop
with tool dispatch, agent sub-sessions,
context compaction, and memory persistence.
Provider-agnostic — delegates all LLM
communication to LLMProvider adapters.
core/session.py (~2700 lines)
end note
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fa1c94a0a7489cb9e6e6cad17f7e3577c458ec89d2a47fb2669fe935768837cf
size 276863
oid sha256:fca0957b54101ce5c2b2e06d639b04dc5fff733f2641af0d732c49ea86883882
size 279397
+5 -3
View File
@@ -8,7 +8,7 @@ skinparam sequenceLifeLineBackgroundColor #F5F5F5
participant "User /\nHTTP Client" as User
participant "ChatSession" as CS
participant "SessionUI" as UI
participant "OpenAI API\n(LLM)" as LLM
participant "LLMProvider\n(OpenAI / Anthropic)" as LLM
participant "Tool Executor\n(ThreadPool)" as TP
database "SQLite" as DB
@@ -27,7 +27,7 @@ group loop [while tool_calls present]
CS -> UI : on_state_change("thinking")
CS -> UI : on_thinking_start()
CS -> LLM : client.chat.completions.create(\n model, messages, tools,\n stream=True, stream_options={include_usage})
CS -> LLM : provider.create_streaming(\n client, model, messages, tools, ...)\n (normalized StreamChunk iterator)
activate LLM
note right of CS
@@ -52,6 +52,8 @@ group loop [while tool_calls present]
CS -> UI : on_content_token(text)
else tool_call delta
CS -> CS : accumulate in tool_calls_acc
else info_delta present
CS -> UI : on_info(text)\n(e.g. server-side web search status)
end
end
@@ -116,7 +118,7 @@ group loop [while tool_calls present]
task/plan → _run_agent() sub-loop
math → sandboxed subprocess
web_fetch → httpx + LLM summarize
web_search → Tavily API
web_search → provider-native or Tavily fallback
remember/recall/forget → SQLite
end note
+1 -1
View File
@@ -105,7 +105,7 @@ partition "Phase 3: Execute" #E3F2FD {
├─ _exec_math: sandboxed subprocess
├─ _exec_man: man/info subprocess
├─ _exec_web_fetch: httpx.get + LLM summary
├─ _exec_web_search: Tavily API POST
├─ _exec_web_search: Tavily API POST (fallback for local models)
├─ _exec_task: _run_agent(TASK_AGENT_TOOLS)
├─ _exec_plan: _run_agent(AGENT_TOOLS, read-only)
├─ _exec_remember: SQLite INSERT OR REPLACE
+1
View File
@@ -58,6 +58,7 @@ package "Inbound Messages (Client → Bridge)" #FFF3E0 {
+ auto_approve: bool = False
+ auto_approve_tools: list[str] = []
+ target_node: str = ""
+ initial_message: str = ""
}
class CloseWorkstreamMessage {
+4 -4
View File
@@ -18,20 +18,20 @@ note right of Redis : Shared queue — any bridge can pick up
BridgeA -> Redis : BLPOP [turnstone:inbound:nodeA,\n turnstone:inbound]
Redis --> BridgeA : SendMessage (from shared queue)
BridgeA -> ServerA : POST /api/workstreams/new\n{name:"", auto_approve:false}
BridgeA -> ServerA : POST /v1/api/workstreams/new\n{name:"", auto_approve:false}
ServerA --> BridgeA : {ws_id:"abc12345", name:"ws-abc1"}
BridgeA -> Redis : SET turnstone:ws:abc12345 "nodeA"
note right : Register workstream ownership
BridgeA -> ServerA : GET /api/events?ws_id=abc12345
BridgeA -> ServerA : GET /v1/api/events?ws_id=abc12345
note right : Start per-WS SSE thread
BridgeA -> Redis : PUBLISH turnstone:events:global\nWorkstreamCreatedEvent
BridgeA -> Redis : PUBLISH turnstone:events:cluster\nClusterStateEvent(ws_id, state:"idle", node_id:"nodeA")
BridgeA -> ServerA : POST /api/send\n{message:"...", ws_id:"abc12345"}
BridgeA -> ServerA : POST /v1/api/send\n{message:"...", ws_id:"abc12345"}
ServerA --> BridgeA : {status:"ok"}
BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nAckEvent(status:"ok")
@@ -93,7 +93,7 @@ note right : Response queue — bypasses inbound queue
BridgeA -> Redis : BLPOP turnstone:resp:req_xyz\n(spawned approval thread, timeout 300s)
Redis --> BridgeA : ApproveMessage
BridgeA -> ServerA : POST /api/approve\n{approved:true, ws_id:"abc12345"}
BridgeA -> ServerA : POST /v1/api/approve\n{approved:true, ws_id:"abc12345"}
== Heartbeat (continuous) ==
+109 -12
View File
@@ -1,13 +1,14 @@
@startuml
!theme plain
title Turnstone — Console Dashboard Data Collection
title Turnstone — Console Dashboard Data Flow
skinparam sequenceArrowThickness 1.5
participant "Browser" as Browser
participant "Console\nHTTP Server" as Server
participant "Console\nStarlette App" as Server
participant "ClusterCollector" as CC
collections "Redis" as Redis
participant "Node-A Bridge" as BridgeA
participant "Node-A\n(real server)" as NodeA
participant "Node-B\n(sim node)" as NodeB
@@ -67,14 +68,14 @@ note right of CC
from the cluster event channel.
end note
CC -> NodeA : GET /api/dashboard
CC -> NodeA : GET /v1/api/dashboard
activate NodeA
NodeA --> CC : {workstreams: [...],\naggregate: {total_tokens, ...}}
deactivate NodeA
CC -> NodeA : GET /health
activate NodeA
NodeA --> CC : {status:"ok", model:"...",\nworkstreams:{total, idle, ...}}
NodeA --> CC : {status:"ok", version:"0.3.0",\nmodel:"...", workstreams:{...}}
deactivate NodeA
CC -> CC : Replace NodeSnapshot["nodeA"]\n.workstreams, .health, .aggregate
@@ -85,19 +86,19 @@ deactivate CC
== Browser SSE Stream ==
Browser -> Server : GET /api/cluster/events
Browser -> Server : GET /v1/api/cluster/events
activate Server
Server -> CC : register_listener(queue)
note right : Per-client queue.Queue(maxsize=500)
note right : Per-client queue.Queue(maxsize=500)\nSSE via EventSourceResponse + run_in_executor()
loop continuous
CC -> Server : event via listener queue\n(from any of the 3 threads)
Server -> Browser : data: {"type":"cluster_state",...}\n\n
end
alt timeout (5s no events)
Server -> Browser : : keepalive\n\n
alt keepalive (sse-starlette ping=5)
Server -> Browser : : ping\n\n
end
Browser -> Server : connection closed
@@ -106,19 +107,115 @@ deactivate Server
== Browser REST Requests ==
Browser -> Server : GET /api/cluster/overview
Browser -> Server : GET /v1/api/cluster/overview
Server -> CC : get_overview()
CC --> Server : {nodes: 10, workstreams: 47,\nstates: {running:5, thinking:3, ...},\naggregate: {total_tokens: 50000}}
CC --> Server : {nodes: 10, workstreams: 47,\nstates: {running:5, ...},\naggregate: {total_tokens: 50000},\nversion_drift: false, versions: ["0.3.0"]}
Server --> Browser : JSON response
Browser -> Server : GET /api/cluster/nodes?sort=activity
Browser -> Server : GET /v1/api/cluster/nodes?sort=activity
Server -> CC : get_nodes(sort_by="activity")
CC --> Server : {nodes: [...], total: 10}
Server --> Browser : JSON response
Browser -> Server : GET /api/cluster/workstreams\n?state=running&node=sim-0003
Browser -> Server : GET /v1/api/cluster/workstreams\n?state=running&node=sim-0003
Server -> CC : get_workstreams(state="running",\nnode="sim-0003")
CC --> Server : {workstreams: [...], total: 5,\npage: 1, per_page: 50, pages: 1}
Server --> Browser : JSON response
== Workstream Creation (via MQ) ==
Browser -> Server : POST /v1/api/cluster/workstreams/new\n{node_id:"nodeA", name:"new-task"}
activate Server #FFECB3
Server -> CC : _pick_best_node() or\nget_node_detail(node_id)
CC --> Server : node validated
Server -> Server : Build CreateWorkstreamMessage\n{target_node:"nodeA", name:"new-task"}
Server -> Redis : RPUSH turnstone:inbound:nodeA\n(directed queue)
Server --> Browser : {status:"ok", correlation_id:"abc",\ntarget_node:"nodeA"}
deactivate Server
note right of Redis
Bridge on Node-A picks up the
message from its directed queue,
POSTs to /v1/api/workstreams/new,
registers ownership, publishes
ws_created to cluster channel.
end note
Redis --> BridgeA : BLPOP turnstone:inbound:nodeA
activate BridgeA
BridgeA -> NodeA : POST /v1/api/workstreams/new\n{name:"new-task"}
NodeA --> BridgeA : {ws_id:"ws789", name:"new-task"}
BridgeA -> Redis : SET turnstone:ws:ws789 = nodeA
BridgeA -> Redis : PUBLISH turnstone:events:cluster\n{type:"ws_created", ws_id:"ws789",\nnode_id:"nodeA", name:"new-task"}
deactivate BridgeA
Redis --> CC : ws_created event
CC -> CC : Add workstream to\nNodeSnapshot["nodeA"]
CC -> CC : _fanout(event)
Server -> Browser : SSE: data: {"type":"ws_created",...}
== Reverse Proxy (server UI through console port) ==
Browser -> Server : GET /node/nodeA/
activate Server #FFF9C4
Server -> CC : get_node_detail("nodeA")\n→ server_url = "http://10.0.1.1:8080"
Server -> NodeA : GET http://10.0.1.1:8080/\n(via httpx.AsyncClient)
activate NodeA
NodeA --> Server : index.html
deactivate NodeA
Server -> Server : Rewrite static paths:\nhref="/static/" → "/node/nodeA/static/"\nInject console-return banner\nafter <body>
Server --> Browser : Rewritten HTML
deactivate Server
Browser -> Server : GET /node/nodeA/static/app.js
activate Server #FFF9C4
Server -> NodeA : GET http://10.0.1.1:8080/static/app.js
activate NodeA
NodeA --> Server : app.js
deactivate NodeA
Server -> Server : Prepend JS proxy shim:\nOverride fetch() and EventSource()\nto prepend "/node/nodeA" prefix
Server --> Browser : Shimmed app.js
deactivate Server
note right of Browser
All fetch("/v1/api/send") calls in the
server UI now become fetch("/node/nodeA/v1/api/send"),
routed through the console proxy.
end note
Browser -> Server : GET /node/nodeA/v1/api/events?ws_id=ws789
activate Server #FFF9C4
Server -> NodeA : GET http://10.0.1.1:8080/v1/api/events?ws_id=ws789\n(SSE stream via httpx.AsyncClient timeout=None)
activate NodeA
loop SSE streaming
NodeA --> Server : data: {"type":"content","text":"..."}\n\n
Server --> Browser : data: {"type":"content","text":"..."}\n\n
end
deactivate NodeA
deactivate Server
Browser -> Server : POST /node/nodeA/v1/api/send\n{message:"hello", ws_id:"ws789"}
activate Server #FFF9C4
Server -> NodeA : POST http://10.0.1.1:8080/v1/api/send\n(body forwarded)
activate NodeA
NodeA --> Server : {status:"ok"}
deactivate NodeA
Server --> Browser : {status:"ok"}
deactivate Server
@enduml
+4 -4
View File
@@ -83,12 +83,12 @@ mqclient --> redis : Redis protocol\nport 6379
server --> redis : Redis protocol\n(6379)
server --> llm_api : OpenAI API\n(HTTPS/HTTP)
bridge --> server : HTTP REST\n(POST /api/send, etc.)
bridge <-- server : SSE\n(GET /api/events)
bridge --> server : HTTP REST\n(POST /v1/api/send, etc.)
bridge <-- server : SSE\n(GET /v1/api/events)
bridge --> redis : Redis protocol\n(queues + pubsub)
console --> redis : Redis PUBSUB\n(cluster channel)
console --> server : HTTP polling\n(GET /api/dashboard)
console --> redis : Redis PUBSUB + LIST\n(cluster events,\nws creation commands)
console --> server : HTTP polling + proxy\n(GET /v1/api/dashboard,\nproxy /node/{id}/*)
sim --> redis : Redis protocol\n(queues + pubsub + keys)
+155
View File
@@ -0,0 +1,155 @@
@startuml
!theme plain
title Turnstone — Client SDK Architecture
skinparam class {
BackgroundColor<<async>> #C8E6C9
BackgroundColor<<sync>> #B8D4E3
BackgroundColor<<event>> #FFE0B2
BackgroundColor<<type>> #F0F4C3
BackgroundColor<<ts>> #E1BEE7
}
skinparam packageBorderColor #888888
skinparam ArrowColor #555555
' Python SDK
package "turnstone/sdk/ (Python)" {
abstract class _BaseClient <<async>> {
- _client: httpx.AsyncClient
- _owns_client: bool
+ _request(method, path, ...) → T
+ _stream_sse(path, ...) → AsyncIterator
+ aclose()
}
class AsyncTurnstoneServer <<async>> {
+ list_workstreams()
+ dashboard()
+ create_workstream()
+ close_workstream()
+ send(message, ws_id)
+ approve()
+ plan_feedback()
+ command()
+ stream_events(ws_id)
+ stream_global_events()
+ send_and_wait()
+ list_sessions()
+ login() / logout()
+ health()
}
class AsyncTurnstoneConsole <<async>> {
+ overview()
+ nodes()
+ workstreams()
+ node_detail()
+ create_workstream()
+ stream_cluster_events()
+ login() / logout()
+ health()
}
class TurnstoneServer <<sync>> {
- _async: AsyncTurnstoneServer
- _runner: _SyncRunner
.. delegates all methods ..
+ __enter__ / __exit__
}
class TurnstoneConsole <<sync>> {
- _async: AsyncTurnstoneConsole
- _runner: _SyncRunner
.. delegates all methods ..
+ __enter__ / __exit__
}
class _SyncRunner <<sync>> {
- _loop: EventLoop
- _thread: Thread
+ run(coro) → T
+ run_iter(async_gen) → Iterator
+ close()
}
class TurnResult <<type>> {
+ ws_id: str
+ content_parts: list[str]
+ reasoning_parts: list[str]
+ tool_results: list
+ errors: list[str]
+ timed_out: bool
--
+ content: str
+ reasoning: str
+ ok: bool
}
class ServerEvent <<event>> {
+ type: str
+ ws_id: str
+ from_dict() → ServerEvent
}
class ClusterEvent <<event>> {
+ type: str
+ from_dict() → ClusterEvent
}
_BaseClient <|-- AsyncTurnstoneServer
_BaseClient <|-- AsyncTurnstoneConsole
TurnstoneServer --> AsyncTurnstoneServer : wraps
TurnstoneServer --> _SyncRunner : uses
TurnstoneConsole --> AsyncTurnstoneConsole : wraps
TurnstoneConsole --> _SyncRunner : uses
AsyncTurnstoneServer ..> TurnResult : returns
AsyncTurnstoneServer ..> ServerEvent : yields
AsyncTurnstoneConsole ..> ClusterEvent : yields
}
' TypeScript SDK
package "sdk/typescript/ (TypeScript)" {
class "BaseClient" as TSBase <<ts>> {
# baseUrl: string
# token: string
# fetchFn: fetch
# request<T>()
# streamSSE<T>()
}
class "TurnstoneServer" as TSServer <<ts>> {
+ listWorkstreams()
+ send()
+ streamEvents()
+ sendAndWait()
...
}
class "TurnstoneConsole" as TSConsole <<ts>> {
+ overview()
+ nodes()
+ clusterEvents()
...
}
TSBase <|-- TSServer
TSBase <|-- TSConsole
}
' External connections
class "turnstone-server :8080" as Server <<artifact>>
class "turnstone-console :8081" as Console <<artifact>>
AsyncTurnstoneServer --> Server : httpx REST + SSE
AsyncTurnstoneConsole --> Console : httpx REST + SSE
TSServer --> Server : fetch REST + SSE
TSConsole --> Console : fetch REST + SSE
note right of AsyncTurnstoneServer
Returns Pydantic models from
turnstone.api.server_schemas
(no type duplication)
end note
@enduml
+156
View File
@@ -0,0 +1,156 @@
@startuml
!theme plain
title Turnstone — Storage Architecture
skinparam class {
BackgroundColor<<protocol>> #E8EAF6
BackgroundColor<<sqlite>> #C8E6C9
BackgroundColor<<postgres>> #B3E5FC
BackgroundColor<<facade>> #FFF9C4
BackgroundColor<<migration>> #FFE0B2
BackgroundColor<<schema>> #F3E5F5
}
' -- Protocol --
interface "StorageBackend" as SB <<protocol>> {
+register_session(session_id, title)
+save_message(session_id, role, content, ...)
+load_session_messages(session_id) → list[dict]
+list_sessions(limit) → list
+delete_session(session_id) → bool
+prune_sessions(retention_days) → (int, int)
+resolve_session(alias_or_id) → str | None
+save_session_config(session_id, config)
+load_session_config(session_id) → dict
+set_session_alias(session_id, alias) → bool
+get_session_name(session_id) → str | None
+update_session_title(session_id, title)
+kv_get(key) → str | None
+kv_set(key, value) → str | None
+kv_delete(key) → bool
+kv_list() → list[(str, str)]
+kv_search(query) → list[(str, str)]
+search_history(query, limit) → list
+search_history_recent(limit) → list
+close()
}
' -- Backends --
class "SQLiteBackend" as SQLite <<sqlite>> {
-_engine: sa.Engine
-_fts5_available: bool
+__init__(path: str)
--
FTS5 full-text search
Default pool, check_same_thread=False
}
class "PostgreSQLBackend" as PG <<postgres>> {
-_engine: sa.Engine
+__init__(url: str, pool_size: int)
--
tsvector + ILIKE search
Connection pooling
}
' -- Schema --
class "_schema.py" as Schema <<schema>> {
+metadata: MetaData
+memories: Table
+conversations: Table
+sessions: Table
+session_config: Table
--
SQLAlchemy Core
Single source of truth
}
' -- Migration --
class "_migrate.py" as Migrate <<migration>> {
+run_migrations(storage, backend)
-_bootstrap_existing_sqlite()
--
Programmatic Alembic
Auto-bootstrap existing DBs
}
class "migrations/" as Versions <<migration>> {
001_initial_schema.py
}
' -- Registry --
class "_registry.py" as Registry {
-_storage: StorageBackend | None
+init_storage(backend, path, url) → StorageBackend
+get_storage() → StorageBackend
+reset_storage()
--
Auto-initializes SQLite
if not configured
}
' -- Facade --
class "memory.py" as Facade <<facade>> {
+register_session()
+save_message()
+load_session_messages()
+save_memory() / delete_memory()
+search_memories()
+... (all 18 functions)
--
Thin delegation to
get_storage()
Silent failure behavior
}
' -- Consumers --
class "session.py\nChatSession" as Session {
}
class "server.py\nWeb UI" as Server {
}
class "cli.py\nTerminal" as CLI {
}
' -- Relationships --
SQLite ..|> SB
PG ..|> SB
SQLite --> Schema : uses
PG --> Schema : uses
Registry --> SB : creates
Registry --> Migrate : calls
Migrate --> Versions : applies
Migrate --> Schema : references
Facade --> Registry : get_storage()
Session --> Facade : imports
Server --> Facade : imports
CLI --> Facade : imports
' -- Config --
note right of Registry
[database]
backend = "sqlite" | "postgresql"
url = "postgresql+psycopg://..."
path = ".turnstone.db"
pool_size = 5
end note
note bottom of SQLite
Default backend.
Zero-config for
single-node / dev.
end note
note bottom of PG
Production backend.
Multi-node / Docker
default.
end note
@enduml
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:341a8ab1483b1e0146878bd384a11d56bc78d29262de8262d06ef924317e2762
size 139969
oid sha256:9a1b0361c466327d0011a488847ea3c0365983713537d4a7c27cd7f5538ba33c
size 164829
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:29534422fc31eee613f70a479aa14de5278b98c49bb75fce7a63b72e248f1149
size 323269
oid sha256:7d75da92a657525bcbb7a425dc6c8d3cafe074c3ac3bff3cf4b1d44aea607b50
size 330156
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f85f24081d32318e079d26a4855ebb6e66df349ca7c7c703db50788af528426b
size 376282
oid sha256:637458e0d78df82752746e519cd7300a830c8ce211f21625694ad0c162ca316d
size 481637
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a90dec1546dd0f8343e3e27cf6f235d95f3ffc375928d34d0df5d8f25d63e5ad
size 269901
oid sha256:dc3b64c9e48153641af62ed43fbc1d89a31d1a8a61e7e71cfc550c805000310d
size 288290
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cb36b4924394cf54d6aaef454cced317b72e336e580cc6ac25cd4b9d0917bec5
size 243422
oid sha256:b842683d238664a3e35d04358fecfc56cefd013f7dca5f13357b0376f881e1b3
size 245043
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e660f453a3708d1a7d827f07cd967f122500eb1c4845f5a75cc1309091bce6af
size 185796
oid sha256:b22d5980fe5cc4b8466ba0797113dc8fa83fab8df24b5dacceaf97e62e2e25b0
size 187649
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8cc7c94d5ac4862c3c09450346f0af923818e02ea0550cc8023f039fdc701179
size 221528
oid sha256:90e4f74be795b530e711faa87bc6eb2b3bf6abb68d8fac8ebff7aaf30c6fbe53
size 222032
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:793d7c2b28a751c6f467f2de788fcd462d3b8fd9cd5cb7adb5b32d78fb185394
size 236004
oid sha256:97e7210cd8f1ad195f4d5e25e778d82df3c08c5c6e0f09722e84a7a453714867
size 411664
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0e6c1dfaef840d5228645aaad3637c973b2f71c372595814f3b743a991f5c6fc
size 239128
oid sha256:4c3214ef416c1dfe4fa17834c2b6f4071a8093cfdb2b862848ca79938f726a13
size 252599
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c9823a41e09611c5c0530d9fc12ad4139cfcc3ae238dc665b2888ec94d7d6781
size 195708
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0c615984373b4893b6cc5755604f137541e9d391862122746a4fcbae63543563
size 201041
+1 -1
View File
@@ -57,7 +57,7 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
|----------|---------|-------------|
| `LLM_BASE_URL` | `http://host.docker.internal:8000/v1` | OpenAI-compatible API URL |
| `OPENAI_API_KEY` | `dummy` | API key (`dummy` for local servers) |
| `TAVILY_API_KEY` | — | Web search API key (optional) |
| `TAVILY_API_KEY` | — | Web search API key (only needed for local/vLLM models; Anthropic and OpenAI search models use native search) |
### Redis
+258
View File
@@ -0,0 +1,258 @@
# Turnstone Client SDK
> See also: [API Reference](api-reference.md) | [Architecture](architecture.md) | [SDK Class Diagram](diagrams/png/13-sdk-architecture.png)
Typed HTTP client libraries for programmatic access to the turnstone server and console APIs. Available in Python (sync + async) and TypeScript.
---
## Python SDK
The Python SDK is included in the `turnstone` package — no extra install required. It wraps the REST and SSE endpoints with typed methods that return Pydantic models directly.
### Quick Start
```python
from turnstone.sdk import TurnstoneServer
# Synchronous client
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
# Create a workstream
ws = client.create_workstream(name="Analysis")
# Send a message and wait for the full response
result = client.send_and_wait("Summarize this codebase.", ws.ws_id)
print(result.content)
# Stream events in real time
for event in client.stream_events(ws.ws_id):
if event.type == "content":
print(event.text, end="", flush=True)
# Close when done
client.close_workstream(ws.ws_id)
```
### Async Client
```python
import asyncio
from turnstone.sdk import AsyncTurnstoneServer
async def main():
async with AsyncTurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
ws = await client.create_workstream(name="demo")
async for event in client.stream_events(ws.ws_id):
if event.type == "content":
print(event.text, end="", flush=True)
asyncio.run(main())
```
### Server Client API
Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
| Category | Method | Returns |
|----------|--------|---------|
| **Workstreams** | `list_workstreams()` | `ListWorkstreamsResponse` |
| | `dashboard()` | `DashboardResponse` |
| | `create_workstream(*, name, model, auto_approve)` | `CreateWorkstreamResponse` |
| | `close_workstream(ws_id)` | `StatusResponse` |
| **Chat** | `send(message, ws_id)` | `SendResponse` |
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
| | `plan_feedback(*, ws_id, feedback)` | `StatusResponse` |
| | `command(*, ws_id, command)` | `StatusResponse` |
| **Streaming** | `stream_events(ws_id)` | `Iterator[ServerEvent]` |
| | `stream_global_events()` | `Iterator[ServerEvent]` |
| **High-level** | `send_and_wait(message, ws_id, *, timeout, on_event)` | `TurnResult` |
| **Sessions** | `list_sessions()` | `ListSessionsResponse` |
| **Auth** | `login(token)` | `AuthLoginResponse` |
| | `logout()` | `StatusResponse` |
| **Health** | `health()` | `HealthResponse` |
### Console Client API
Both `TurnstoneConsole` (sync) and `AsyncTurnstoneConsole` (async) expose:
| Category | Method | Returns |
|----------|--------|---------|
| **Cluster** | `overview()` | `ClusterOverviewResponse` |
| | `nodes(*, sort, limit, offset)` | `ClusterNodesResponse` |
| | `workstreams(*, state, node, search, sort, page, per_page)` | `ClusterWorkstreamsResponse` |
| | `node_detail(node_id)` | `NodeDetailResponse` |
| | `create_workstream(*, node_id, name, model, initial_message)` | `ConsoleCreateWsResponse` |
| **Streaming** | `stream_cluster_events()` | `Iterator[ClusterEvent]` |
| **Auth** | `login(token)` / `logout()` | `AuthLoginResponse` / `StatusResponse` |
| **Health** | `health()` | `ConsoleHealthResponse` |
### Event Types
SSE events are deserialized into typed dataclasses. Use `event.type` to discriminate.
**Per-workstream events** (from `stream_events(ws_id)`):
| Type | Class | Key Fields |
|------|-------|------------|
| `connected` | `ConnectedEvent` | `model`, `model_alias`, `skip_permissions` |
| `history` | `HistoryEvent` | `messages` |
| `content` | `ContentEvent` | `text` |
| `reasoning` | `ReasoningEvent` | `text` |
| `tool_info` | `ToolInfoEvent` | `items` |
| `approve_request` | `ApproveRequestEvent` | `items` |
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output` |
| `tool_output_chunk` | `ToolOutputChunkEvent` | `call_id`, `chunk` |
| `status` | `StatusEvent` | `prompt_tokens`, `total_tokens`, `pct`, `effort` |
| `plan_review` | `PlanReviewEvent` | `content` |
| `error` | `ErrorEvent` | `message` |
| `info` | `InfoEvent` | `message` |
| `stream_end` | `StreamEndEvent` | — |
**Global events** (from `stream_global_events()`):
| Type | Class | Key Fields |
|------|-------|------------|
| `ws_state` | `WsStateEvent` | `ws_id`, `state`, `tokens`, `activity` |
| `ws_activity` | `WsActivityEvent` | `ws_id`, `activity`, `activity_state` |
| `ws_rename` | `WsRenameEvent` | `ws_id`, `name` |
| `ws_closed` | `WsClosedEvent` | `ws_id` |
**Cluster events** (from `stream_cluster_events()`):
| Type | Class | Key Fields |
|------|-------|------------|
| `node_joined` | `NodeJoinedEvent` | `node_id` |
| `node_lost` | `NodeLostEvent` | `node_id` |
| `cluster_state` | `ClusterStateEvent` | `ws_id`, `node_id`, `state`, `tokens` |
| `ws_created` | `ClusterWsCreatedEvent` | `ws_id`, `node_id`, `name` |
### TurnResult
The `send_and_wait()` method returns a `TurnResult` that aggregates the full response:
```python
result = client.send_and_wait("Hello", ws_id, timeout=60)
result.content # Full text response
result.reasoning # Chain-of-thought (if shown)
result.tool_results # List of (tool_name, output) tuples
result.errors # Any error messages
result.ok # True if no errors and not timed out
result.timed_out # True if timeout expired
```
### Error Handling
Non-2xx responses raise `TurnstoneAPIError`:
```python
from turnstone.sdk import TurnstoneServer, TurnstoneAPIError
try:
client.send("hi", "bad_ws_id")
except TurnstoneAPIError as e:
print(e.status_code) # 404
print(e.message) # "Unknown workstream"
```
---
## TypeScript SDK
Located at `sdk/typescript/`. Zero runtime dependencies for browsers; uses native `fetch` and `ReadableStream` for SSE parsing.
### Quick Start
```typescript
import { TurnstoneServer } from "@turnstone/sdk";
const client = new TurnstoneServer({
baseUrl: "http://localhost:8080",
token: "tok_xxx",
});
// Create workstream and send message
const ws = await client.createWorkstream({ name: "demo" });
const result = await client.sendAndWait("Hello!", ws.ws_id);
console.log(result.content);
// Stream events
for await (const event of client.streamEvents(ws.ws_id)) {
if (event.type === "content") {
process.stdout.write(event.text);
}
}
```
### Console Client
```typescript
import { TurnstoneConsole } from "@turnstone/sdk";
const console = new TurnstoneConsole({
baseUrl: "http://localhost:8081",
token: "tok_xxx",
});
const overview = await console.overview();
console.log(`Nodes: ${overview.nodes}, Workstreams: ${overview.workstreams}`);
// Stream cluster events
for await (const event of console.clusterEvents()) {
console.log(event.type, event);
}
```
### Type Safety
All event types are modeled as a discriminated union:
```typescript
import { isContentEvent, isErrorEvent } from "@turnstone/sdk";
import type { ServerEvent } from "@turnstone/sdk";
function handleEvent(event: ServerEvent) {
if (isContentEvent(event)) {
// event is narrowed to ContentEvent
console.log(event.text);
} else if (isErrorEvent(event)) {
console.error(event.message);
}
}
```
### Custom Fetch
The client accepts a custom `fetch` implementation for testing or Node.js environments:
```typescript
const client = new TurnstoneServer({
baseUrl: "http://localhost:8080",
fetch: myCustomFetch,
});
```
---
## Architecture
```
turnstone/sdk/ Python SDK (sub-package)
_base.py Shared httpx async client, auth, error handling
_sync.py Background event loop for sync wrappers
_types.py TurnResult + TurnstoneAPIError
events.py 27 SSE event dataclasses with type registry
server.py AsyncTurnstoneServer + TurnstoneServer
console.py AsyncTurnstoneConsole + TurnstoneConsole
sdk/typescript/ TypeScript SDK (npm package)
src/base.ts fetch wrapper, auth, SSE streaming
src/server.ts TurnstoneServer class
src/console.ts TurnstoneConsole class
src/events.ts Discriminated union events + type guards
src/sse.ts ReadableStream SSE parser
src/types.ts Request/response interfaces
```
The Python SDK reuses Pydantic models from `turnstone/api/` directly — no schema duplication. The TypeScript SDK has hand-written interfaces matching those models.
Both SDKs follow the same design: typed methods for REST endpoints, async iterators for SSE streams, and a high-level `send_and_wait` method for simple request-response patterns.
+5 -2
View File
@@ -302,8 +302,11 @@ Search the web using a text query.
| `max_results` | integer | no | Max results to return (default 5, max 20). |
| `topic` | string | no | Search topic: `general`, `news`, or `finance` (default `general`). |
- **What it does**: Searches the web via the Tavily API and returns ranked results with titles, URLs, and content snippets.
- **Auto-approve**: No -- requires user confirmation (makes network requests).
- **What it does**: Searches the web and returns ranked results with titles, URLs, and content snippets. Uses provider-native search when available:
- **Anthropic**: Replaced at the API boundary with Anthropic's `web_search_20250305` server-side tool. Claude decides when to search; the API executes it and returns results with citations inline. No Tavily key needed.
- **OpenAI search models** (`gpt-5-search-api`): Replaced with `web_search_options` parameter. The model always searches and returns `url_citation` annotations.
- **Local/vLLM models**: Falls back to the Tavily API. Requires `tavily_key` in `config.toml` or `$TAVILY_API_KEY`.
- **Auto-approve**: Yes (auto-approved for all tool dispatch paths).
- **Agent availability**: `agent` and `task_agent`.
---
+30 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "0.3.0"
version = "0.3.5"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
@@ -21,7 +21,18 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
dependencies = ["openai>=2.24", "httpx>=0.28", "mcp>=1.6"]
dependencies = [
"openai>=2.24",
"httpx>=0.28",
"mcp>=1.6",
"starlette>=0.45",
"uvicorn>=0.34",
"sse-starlette>=2.0",
"httpx-sse>=0.4",
"pydantic>=2.0",
"sqlalchemy>=2.0",
"alembic>=1.14",
]
[project.urls]
Homepage = "https://github.com/turnstonelabs/turnstone"
@@ -34,6 +45,8 @@ dev = ["ruff>=0.9", "mypy>=1.14", "types-redis>=4.6"]
mq = ["redis>=7.2"]
console = ["redis>=7.2"]
sim = ["redis>=7.2"]
anthropic = ["anthropic>=0.39"]
postgres = ["psycopg[binary]>=3.2"]
[project.scripts]
@@ -54,6 +67,9 @@ include = [
"turnstone/console/static/*.html",
"turnstone/console/static/*.css",
"turnstone/console/static/*.js",
"turnstone/shared_static/*.css",
"turnstone/shared_static/*.js",
"turnstone/sdk/py.typed",
]
[tool.pytest.ini_options]
@@ -104,6 +120,18 @@ ignore_missing_imports = true
module = ["mcp", "mcp.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["sse_starlette", "sse_starlette.*", "uvicorn", "uvicorn.*", "httpx_sse", "httpx_sse.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["sqlalchemy", "sqlalchemy.*", "alembic", "alembic.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["anthropic", "anthropic.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+875
View File
@@ -0,0 +1,875 @@
{
"openapi": "3.1.0",
"info": {
"title": "turnstone Console API",
"version": "0.3.0",
"description": "Cluster-wide visibility and control across all turnstone nodes."
},
"paths": {
"/v1/api/cluster/overview": {
"get": {
"summary": "Cluster state summary",
"operationId": "v1_api_cluster_overview_get",
"tags": [
"Cluster"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ClusterOverviewResponse"
}
}
}
}
}
}
},
"/v1/api/cluster/nodes": {
"get": {
"summary": "Paginated node list",
"operationId": "v1_api_cluster_nodes_get",
"tags": [
"Cluster"
],
"parameters": [
{
"name": "sort",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "activity",
"enum": [
"activity",
"tokens",
"name"
]
},
"description": "Sort field"
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 100
},
"description": "Page size"
},
{
"name": "offset",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 0
},
"description": "Pagination offset"
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ClusterNodesResponse"
}
}
}
}
}
}
},
"/v1/api/cluster/workstreams": {
"get": {
"summary": "Filtered workstream list",
"operationId": "v1_api_cluster_workstreams_get",
"tags": [
"Cluster"
],
"parameters": [
{
"name": "state",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"running",
"thinking",
"attention",
"idle",
"error"
]
},
"description": "Filter by state"
},
{
"name": "node",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Filter by node_id"
},
{
"name": "search",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Search in name/title/node"
},
{
"name": "sort",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "state",
"enum": [
"state",
"tokens",
"name"
]
},
"description": "Sort field"
},
{
"name": "page",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 1
},
"description": "Page number"
},
{
"name": "per_page",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 50
},
"description": "Items per page (max 200)"
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ClusterWorkstreamsResponse"
}
}
}
}
}
}
},
"/v1/api/cluster/node/{node_id}": {
"get": {
"summary": "Single node detail",
"operationId": "v1_api_cluster_node_{node_id}_get",
"tags": [
"Cluster"
],
"parameters": [
{
"name": "node_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NodeDetailResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/cluster/workstreams/new": {
"post": {
"summary": "Create workstream via MQ dispatch",
"operationId": "v1_api_cluster_workstreams_new_post",
"tags": [
"Cluster"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConsoleCreateWsRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConsoleCreateWsResponse"
}
}
}
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"503": {
"description": "Error 503",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/cluster/events": {
"get": {
"summary": "Cluster SSE event stream",
"operationId": "v1_api_cluster_events_get",
"tags": [
"Streaming"
],
"description": "Server-Sent Events stream for real-time cluster updates. Returns text/event-stream with node_joined, node_lost, cluster_state, ws_created, ws_closed, ws_rename events.",
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/v1/api/auth/login": {
"post": {
"summary": "Authenticate with a token",
"operationId": "v1_api_auth_login_post",
"tags": [
"Auth"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthLoginRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthLoginResponse"
}
}
}
},
"401": {
"description": "Error 401",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/auth/logout": {
"post": {
"summary": "Clear auth cookie",
"operationId": "v1_api_auth_logout_post",
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatusResponse"
}
}
}
}
}
}
},
"/health": {
"get": {
"summary": "Console health check",
"operationId": "health_get",
"tags": [
"Observability"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConsoleHealthResponse"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"ErrorResponse": {
"description": "Standard error response body.",
"properties": {
"error": {
"description": "Error message",
"title": "Error",
"type": "string"
}
},
"required": [
"error"
],
"title": "ErrorResponse",
"type": "object"
},
"StatusResponse": {
"description": "Generic success response.",
"properties": {
"status": {
"default": "ok",
"examples": [
"ok"
],
"title": "Status",
"type": "string"
}
},
"title": "StatusResponse",
"type": "object"
},
"AuthLoginRequest": {
"description": "POST /v1/api/auth/login request body.",
"properties": {
"token": {
"description": "Bearer token to authenticate",
"title": "Token",
"type": "string"
}
},
"required": [
"token"
],
"title": "AuthLoginRequest",
"type": "object"
},
"AuthLoginResponse": {
"description": "POST /v1/api/auth/login success response.",
"properties": {
"status": {
"default": "ok",
"title": "Status",
"type": "string"
},
"role": {
"description": "Assigned role",
"examples": [
"full",
"read"
],
"title": "Role",
"type": "string"
}
},
"required": [
"role"
],
"title": "AuthLoginResponse",
"type": "object"
},
"ClusterOverviewResponse": {
"properties": {
"nodes": {
"default": 0,
"title": "Nodes",
"type": "integer"
},
"workstreams": {
"default": 0,
"title": "Workstreams",
"type": "integer"
},
"states": {
"$ref": "#/components/schemas/StateCounts",
"default": {
"running": 0,
"thinking": 0,
"attention": 0,
"idle": 0,
"error": 0
}
},
"aggregate": {
"$ref": "#/components/schemas/ClusterAggregate",
"default": {
"total_tokens": 0,
"total_tool_calls": 0
}
},
"version_drift": {
"default": false,
"title": "Version Drift",
"type": "boolean"
},
"versions": {
"default": [],
"items": {
"type": "string"
},
"title": "Versions",
"type": "array"
}
},
"title": "ClusterOverviewResponse",
"type": "object"
},
"ClusterAggregate": {
"properties": {
"total_tokens": {
"default": 0,
"title": "Total Tokens",
"type": "integer"
},
"total_tool_calls": {
"default": 0,
"title": "Total Tool Calls",
"type": "integer"
}
},
"title": "ClusterAggregate",
"type": "object"
},
"StateCounts": {
"properties": {
"running": {
"default": 0,
"title": "Running",
"type": "integer"
},
"thinking": {
"default": 0,
"title": "Thinking",
"type": "integer"
},
"attention": {
"default": 0,
"title": "Attention",
"type": "integer"
},
"idle": {
"default": 0,
"title": "Idle",
"type": "integer"
},
"error": {
"default": 0,
"title": "Error",
"type": "integer"
}
},
"title": "StateCounts",
"type": "object"
},
"ClusterNodesResponse": {
"properties": {
"nodes": {
"items": {
"$ref": "#/components/schemas/ClusterNodeInfo"
},
"title": "Nodes",
"type": "array"
},
"total": {
"default": 0,
"title": "Total",
"type": "integer"
}
},
"required": [
"nodes"
],
"title": "ClusterNodesResponse",
"type": "object"
},
"ClusterNodeInfo": {
"properties": {
"node_id": {
"title": "Node Id",
"type": "string"
},
"server_url": {
"default": "",
"title": "Server Url",
"type": "string"
},
"ws_total": {
"default": 0,
"title": "Ws Total",
"type": "integer"
},
"ws_running": {
"default": 0,
"title": "Ws Running",
"type": "integer"
},
"ws_thinking": {
"default": 0,
"title": "Ws Thinking",
"type": "integer"
},
"ws_attention": {
"default": 0,
"title": "Ws Attention",
"type": "integer"
},
"ws_idle": {
"default": 0,
"title": "Ws Idle",
"type": "integer"
},
"ws_error": {
"default": 0,
"title": "Ws Error",
"type": "integer"
},
"total_tokens": {
"default": 0,
"title": "Total Tokens",
"type": "integer"
},
"started": {
"default": 0.0,
"title": "Started",
"type": "number"
},
"reachable": {
"default": true,
"title": "Reachable",
"type": "boolean"
},
"health": {
"additionalProperties": {
"type": "string"
},
"title": "Health",
"type": "object"
},
"version": {
"default": "",
"title": "Version",
"type": "string"
}
},
"required": [
"node_id"
],
"title": "ClusterNodeInfo",
"type": "object"
},
"ClusterWorkstreamsResponse": {
"properties": {
"workstreams": {
"items": {
"$ref": "#/components/schemas/ClusterWorkstreamInfo"
},
"title": "Workstreams",
"type": "array"
},
"total": {
"default": 0,
"title": "Total",
"type": "integer"
},
"page": {
"default": 1,
"title": "Page",
"type": "integer"
},
"per_page": {
"default": 50,
"title": "Per Page",
"type": "integer"
},
"pages": {
"default": 1,
"title": "Pages",
"type": "integer"
}
},
"required": [
"workstreams"
],
"title": "ClusterWorkstreamsResponse",
"type": "object"
},
"ClusterWorkstreamInfo": {
"properties": {
"id": {
"title": "Id",
"type": "string"
},
"name": {
"default": "",
"title": "Name",
"type": "string"
},
"state": {
"default": "",
"title": "State",
"type": "string"
},
"node": {
"default": "",
"title": "Node",
"type": "string"
},
"title": {
"default": "",
"title": "Title",
"type": "string"
},
"tokens": {
"default": 0,
"title": "Tokens",
"type": "integer"
},
"context_ratio": {
"default": 0.0,
"title": "Context Ratio",
"type": "number"
},
"activity": {
"default": "",
"title": "Activity",
"type": "string"
},
"activity_state": {
"default": "",
"title": "Activity State",
"type": "string"
},
"tool_calls": {
"default": 0,
"title": "Tool Calls",
"type": "integer"
}
},
"required": [
"id"
],
"title": "ClusterWorkstreamInfo",
"type": "object"
},
"NodeDetailResponse": {
"properties": {
"node_id": {
"title": "Node Id",
"type": "string"
},
"server_url": {
"default": "",
"title": "Server Url",
"type": "string"
},
"health": {
"additionalProperties": {
"type": "string"
},
"title": "Health",
"type": "object"
},
"workstreams": {
"default": [],
"items": {
"$ref": "#/components/schemas/ClusterWorkstreamInfo"
},
"title": "Workstreams",
"type": "array"
},
"aggregate": {
"additionalProperties": {
"type": "integer"
},
"title": "Aggregate",
"type": "object"
},
"reachable": {
"default": true,
"title": "Reachable",
"type": "boolean"
}
},
"required": [
"node_id"
],
"title": "NodeDetailResponse",
"type": "object"
},
"ConsoleCreateWsRequest": {
"properties": {
"node_id": {
"default": "",
"description": "Target node: specific ID, 'auto', 'pool', or empty for auto",
"title": "Node Id",
"type": "string"
},
"name": {
"default": "",
"description": "Workstream name (auto-generated if empty)",
"title": "Name",
"type": "string"
},
"model": {
"default": "",
"description": "Model alias from node registry",
"title": "Model",
"type": "string"
},
"initial_message": {
"default": "",
"description": "Optional first message sent after creation",
"title": "Initial Message",
"type": "string"
}
},
"title": "ConsoleCreateWsRequest",
"type": "object"
},
"ConsoleCreateWsResponse": {
"properties": {
"status": {
"default": "ok",
"title": "Status",
"type": "string"
},
"correlation_id": {
"default": "",
"title": "Correlation Id",
"type": "string"
},
"target_node": {
"default": "",
"title": "Target Node",
"type": "string"
}
},
"title": "ConsoleCreateWsResponse",
"type": "object"
},
"ConsoleHealthResponse": {
"properties": {
"status": {
"default": "ok",
"examples": [
"ok"
],
"title": "Status",
"type": "string"
},
"service": {
"default": "turnstone-console",
"title": "Service",
"type": "string"
},
"nodes": {
"default": 0,
"title": "Nodes",
"type": "integer"
},
"workstreams": {
"default": 0,
"title": "Workstreams",
"type": "integer"
},
"version_drift": {
"default": false,
"title": "Version Drift",
"type": "boolean"
},
"versions": {
"default": [],
"items": {
"type": "string"
},
"title": "Versions",
"type": "array"
}
},
"title": "ConsoleHealthResponse",
"type": "object"
}
}
}
}
File diff suppressed because it is too large Load Diff
+1346
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@turnstone/sdk",
"version": "0.3.0",
"description": "TypeScript client SDK for the turnstone AI orchestration platform",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"generate-types": "python scripts/generate-types.py"
},
"files": [
"dist",
"src"
],
"keywords": [
"turnstone",
"ai",
"llm",
"agent",
"sdk",
"client"
],
"license": "BUSL-1.1",
"devDependencies": {
"typescript": "^5.4",
"vitest": "^2.0"
}
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Export OpenAPI specs to JSON files for TypeScript type reference.
Usage:
python scripts/generate-types.py
Writes:
openapi-server.json Server API OpenAPI 3.1 spec
openapi-console.json Console API OpenAPI 3.1 spec
"""
import json
import sys
from pathlib import Path
# Ensure the turnstone package is importable (repo root is 3 levels up)
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from turnstone.api.console_spec import build_console_spec
from turnstone.api.server_spec import build_server_spec
output_dir = Path(__file__).resolve().parent.parent
def main() -> None:
server_spec = build_server_spec()
console_spec = build_console_spec()
server_path = output_dir / "openapi-server.json"
console_path = output_dir / "openapi-console.json"
server_path.write_text(json.dumps(server_spec, indent=2) + "\n")
console_path.write_text(json.dumps(console_spec, indent=2) + "\n")
print(f"Wrote {server_path} ({len(server_spec['paths'])} paths)")
print(f"Wrote {console_path} ({len(console_spec['paths'])} paths)")
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
import { TurnstoneAPIError } from "./errors.js";
import { parseSSEStream } from "./sse.js";
export interface ClientOptions {
/** Server base URL (e.g. "http://localhost:8080"). */
baseUrl: string;
/** Bearer token for authentication. */
token?: string;
/** Custom fetch implementation (defaults to globalThis.fetch). */
fetch?: typeof globalThis.fetch;
}
export interface RequestOptions {
json?: object;
params?: Record<string, string | number>;
}
export class BaseClient {
protected readonly baseUrl: string;
protected readonly token: string;
protected readonly fetchFn: typeof globalThis.fetch;
constructor(options: ClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.token = options.token ?? "";
this.fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
}
protected async request<T>(
method: string,
path: string,
options?: RequestOptions,
): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
}
let url = `${this.baseUrl}${path}`;
if (options?.params) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(options.params)) {
if (value !== undefined && value !== "") {
searchParams.set(key, String(value));
}
}
const qs = searchParams.toString();
if (qs) url += `?${qs}`;
}
const resp = await this.fetchFn(url, {
method,
headers,
body: options?.json ? JSON.stringify(options.json) : undefined,
});
if (!resp.ok) {
let msg = "";
try {
const body = (await resp.json()) as Record<string, unknown>;
msg = (body.error as string) ?? (body.detail as string) ?? "";
} catch {
msg = await resp.text().catch(() => "");
}
throw new TurnstoneAPIError(resp.status, msg || `HTTP ${resp.status}`);
}
return (await resp.json()) as T;
}
protected async *streamSSE<T = Record<string, unknown>>(
path: string,
params?: Record<string, string | number>,
signal?: AbortSignal,
): AsyncIterableIterator<T> {
const headers: Record<string, string> = {
Accept: "text/event-stream",
};
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
}
let url = `${this.baseUrl}${path}`;
if (params) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") {
searchParams.set(key, String(value));
}
}
const qs = searchParams.toString();
if (qs) url += `?${qs}`;
}
const resp = await this.fetchFn(url, { method: "GET", headers, signal });
if (!resp.ok) {
throw new TurnstoneAPIError(
resp.status,
`SSE connection failed: HTTP ${resp.status}`,
);
}
yield* parseSSEStream<T>(resp);
}
}
+88
View File
@@ -0,0 +1,88 @@
import { BaseClient, type ClientOptions } from "./base.js";
import type { ClusterEvent } from "./events.js";
import type {
AuthLoginResponse,
ClusterNodesResponse,
ClusterOverviewResponse,
ClusterWorkstreamsResponse,
ConsoleCreateWsRequest,
ConsoleCreateWsResponse,
ConsoleHealthResponse,
NodeDetailResponse,
NodesOptions,
StatusResponse,
WorkstreamsOptions,
} from "./types.js";
/** Async client for the turnstone console API. */
export class TurnstoneConsole extends BaseClient {
constructor(options: ClientOptions) {
super(options);
}
// -- Cluster overview -----------------------------------------------------
async overview(): Promise<ClusterOverviewResponse> {
return this.request("GET", "/v1/api/cluster/overview");
}
async nodes(opts?: NodesOptions): Promise<ClusterNodesResponse> {
return this.request("GET", "/v1/api/cluster/nodes", {
params: {
sort: opts?.sort ?? "activity",
limit: opts?.limit ?? 100,
offset: opts?.offset ?? 0,
},
});
}
async workstreams(
opts?: WorkstreamsOptions,
): Promise<ClusterWorkstreamsResponse> {
const params: Record<string, string | number> = {
sort: opts?.sort ?? "state",
page: opts?.page ?? 1,
per_page: opts?.per_page ?? 50,
};
if (opts?.state) params.state = opts.state;
if (opts?.node) params.node = opts.node;
if (opts?.search) params.search = opts.search;
return this.request("GET", "/v1/api/cluster/workstreams", { params });
}
async nodeDetail(nodeId: string): Promise<NodeDetailResponse> {
return this.request("GET", `/v1/api/cluster/node/${nodeId}`);
}
async createWorkstream(
opts?: ConsoleCreateWsRequest,
): Promise<ConsoleCreateWsResponse> {
return this.request("POST", "/v1/api/cluster/workstreams/new", {
json: opts,
});
}
// -- Streaming ------------------------------------------------------------
async *clusterEvents(): AsyncIterableIterator<ClusterEvent> {
yield* this.streamSSE<ClusterEvent>("/v1/api/cluster/events");
}
// -- Auth -----------------------------------------------------------------
async login(token: string): Promise<AuthLoginResponse> {
return this.request("POST", "/v1/api/auth/login", {
json: { token },
});
}
async logout(): Promise<StatusResponse> {
return this.request("POST", "/v1/api/auth/logout");
}
// -- Health ---------------------------------------------------------------
async health(): Promise<ConsoleHealthResponse> {
return this.request("GET", "/health");
}
}
+10
View File
@@ -0,0 +1,10 @@
/** Raised when a turnstone server returns a non-2xx response. */
export class TurnstoneAPIError extends Error {
constructor(
public readonly statusCode: number,
public readonly errorMessage: string,
) {
super(`HTTP ${statusCode}: ${errorMessage}`);
this.name = "TurnstoneAPIError";
}
}
+239
View File
@@ -0,0 +1,239 @@
// ---------------------------------------------------------------------------
// Server SSE events
// ---------------------------------------------------------------------------
export interface ConnectedEvent {
type: "connected";
model: string;
model_alias: string;
skip_permissions: boolean;
}
export interface HistoryEvent {
type: "history";
messages: Array<Record<string, unknown>>;
}
export interface ThinkingStartEvent {
type: "thinking_start";
}
export interface ThinkingStopEvent {
type: "thinking_stop";
}
export interface ContentEvent {
type: "content";
text: string;
}
export interface ReasoningEvent {
type: "reasoning";
text: string;
}
export interface StreamEndEvent {
type: "stream_end";
}
export interface ToolInfoEvent {
type: "tool_info";
items: Array<Record<string, unknown>>;
}
export interface ApproveRequestEvent {
type: "approve_request";
items: Array<Record<string, unknown>>;
}
export interface ToolResultEvent {
type: "tool_result";
call_id: string;
name: string;
output: string;
}
export interface ToolOutputChunkEvent {
type: "tool_output_chunk";
call_id: string;
chunk: string;
}
export interface StatusEvent {
type: "status";
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
context_window: number;
pct: number;
effort: string;
}
export interface PlanReviewEvent {
type: "plan_review";
content: string;
}
export interface InfoEvent {
type: "info";
message: string;
}
export interface ErrorEvent {
type: "error";
message: string;
}
export interface BusyErrorEvent {
type: "busy_error";
message: string;
}
export interface ClearUiEvent {
type: "clear_ui";
}
// Global events
export interface WsStateEvent {
type: "ws_state";
ws_id: string;
state: string;
tokens: number;
context_ratio: number;
activity: string;
activity_state: string;
}
export interface WsActivityEvent {
type: "ws_activity";
ws_id: string;
activity: string;
activity_state: string;
}
export interface WsRenameEvent {
type: "ws_rename";
ws_id: string;
name: string;
}
export interface WsClosedEvent {
type: "ws_closed";
ws_id: string;
name?: string;
}
/** Discriminated union of all server SSE event types. */
export type ServerEvent =
| ConnectedEvent
| HistoryEvent
| ThinkingStartEvent
| ThinkingStopEvent
| ContentEvent
| ReasoningEvent
| StreamEndEvent
| ToolInfoEvent
| ApproveRequestEvent
| ToolResultEvent
| ToolOutputChunkEvent
| StatusEvent
| PlanReviewEvent
| InfoEvent
| ErrorEvent
| BusyErrorEvent
| ClearUiEvent
| WsStateEvent
| WsActivityEvent
| WsRenameEvent
| WsClosedEvent;
// ---------------------------------------------------------------------------
// Console cluster SSE events
// ---------------------------------------------------------------------------
export interface NodeJoinedEvent {
type: "node_joined";
node_id: string;
}
export interface NodeLostEvent {
type: "node_lost";
node_id: string;
}
export interface ClusterStateEvent {
type: "cluster_state";
ws_id: string;
node_id: string;
state: string;
tokens: number;
context_ratio: number;
activity: string;
activity_state: string;
}
export interface ClusterWsCreatedEvent {
type: "ws_created";
ws_id: string;
node_id: string;
name: string;
}
export interface ClusterWsClosedEvent {
type: "ws_closed";
ws_id: string;
}
export interface ClusterWsRenameEvent {
type: "ws_rename";
ws_id: string;
name: string;
}
/** Discriminated union of all console cluster SSE event types. */
export type ClusterEvent =
| NodeJoinedEvent
| NodeLostEvent
| ClusterStateEvent
| ClusterWsCreatedEvent
| ClusterWsClosedEvent
| ClusterWsRenameEvent;
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
export function isContentEvent(e: ServerEvent): e is ContentEvent {
return e.type === "content";
}
export function isReasoningEvent(e: ServerEvent): e is ReasoningEvent {
return e.type === "reasoning";
}
export function isErrorEvent(e: ServerEvent): e is ErrorEvent {
return e.type === "error";
}
export function isStreamEndEvent(e: ServerEvent): e is StreamEndEvent {
return e.type === "stream_end";
}
export function isToolResultEvent(e: ServerEvent): e is ToolResultEvent {
return e.type === "tool_result";
}
export function isWsStateEvent(e: ServerEvent): e is WsStateEvent {
return e.type === "ws_state";
}
export function isApproveRequestEvent(
e: ServerEvent,
): e is ApproveRequestEvent {
return e.type === "approve_request";
}
export function isPlanReviewEvent(e: ServerEvent): e is PlanReviewEvent {
return e.type === "plan_review";
}
+111
View File
@@ -0,0 +1,111 @@
/**
* @turnstone/sdk TypeScript client SDK for the turnstone AI orchestration platform.
*
* @example
* ```ts
* import { TurnstoneServer } from "@turnstone/sdk";
*
* const client = new TurnstoneServer({
* baseUrl: "http://localhost:8080",
* token: "tok_xxx",
* });
*
* const ws = await client.createWorkstream({ name: "demo" });
* const result = await client.sendAndWait("Hello!", ws.ws_id);
* console.log(result.content);
* ```
*/
// Clients
export { TurnstoneServer } from "./server.js";
export { TurnstoneConsole } from "./console.js";
export type { ClientOptions } from "./base.js";
// Errors
export { TurnstoneAPIError } from "./errors.js";
// Event types and guards
export type {
ServerEvent,
ClusterEvent,
ConnectedEvent,
HistoryEvent,
ThinkingStartEvent,
ThinkingStopEvent,
ContentEvent,
ReasoningEvent,
StreamEndEvent,
ToolInfoEvent,
ApproveRequestEvent,
ToolResultEvent,
ToolOutputChunkEvent,
StatusEvent,
PlanReviewEvent,
InfoEvent,
ErrorEvent,
BusyErrorEvent,
ClearUiEvent,
WsStateEvent,
WsActivityEvent,
WsRenameEvent,
WsClosedEvent,
NodeJoinedEvent,
NodeLostEvent,
ClusterStateEvent,
ClusterWsCreatedEvent,
ClusterWsClosedEvent,
ClusterWsRenameEvent,
} from "./events.js";
export {
isContentEvent,
isReasoningEvent,
isErrorEvent,
isStreamEndEvent,
isToolResultEvent,
isWsStateEvent,
isApproveRequestEvent,
isPlanReviewEvent,
} from "./events.js";
// Request/response types
export type {
SendRequest,
SendResponse,
ApproveRequest,
PlanFeedbackRequest,
CommandRequest,
CreateWorkstreamRequest,
CreateWorkstreamResponse,
CloseWorkstreamRequest,
WorkstreamInfo,
ListWorkstreamsResponse,
DashboardWorkstream,
DashboardAggregate,
DashboardResponse,
SessionInfo,
ListSessionsResponse,
BackendStatus,
WorkstreamCounts,
HealthResponse,
AuthLoginRequest,
AuthLoginResponse,
StatusResponse,
ErrorResponse,
ClusterOverviewResponse,
ClusterNodeInfo,
ClusterNodesResponse,
ClusterWorkstreamInfo,
ClusterWorkstreamsResponse,
NodeDetailResponse,
ConsoleCreateWsRequest,
ConsoleCreateWsResponse,
ConsoleHealthResponse,
TurnResult,
SendAndWaitOptions,
NodesOptions,
WorkstreamsOptions,
} from "./types.js";
// SSE parser (for advanced usage)
export { parseSSEStream } from "./sse.js";
+202
View File
@@ -0,0 +1,202 @@
import { BaseClient, type ClientOptions } from "./base.js";
import type { ServerEvent } from "./events.js";
import type {
AuthLoginResponse,
CreateWorkstreamRequest,
CreateWorkstreamResponse,
DashboardResponse,
HealthResponse,
ListSessionsResponse,
ListWorkstreamsResponse,
SendAndWaitOptions,
SendResponse,
StatusResponse,
TurnResult,
} from "./types.js";
/** Async client for the turnstone server API. */
export class TurnstoneServer extends BaseClient {
constructor(options: ClientOptions) {
super(options);
}
// -- Workstream management ------------------------------------------------
async listWorkstreams(): Promise<ListWorkstreamsResponse> {
return this.request("GET", "/v1/api/workstreams");
}
async dashboard(): Promise<DashboardResponse> {
return this.request("GET", "/v1/api/dashboard");
}
async createWorkstream(
opts?: CreateWorkstreamRequest,
): Promise<CreateWorkstreamResponse> {
return this.request("POST", "/v1/api/workstreams/new", { json: opts });
}
async closeWorkstream(wsId: string): Promise<StatusResponse> {
return this.request("POST", "/v1/api/workstreams/close", {
json: { ws_id: wsId },
});
}
// -- Chat interaction -----------------------------------------------------
async send(message: string, wsId: string): Promise<SendResponse> {
return this.request("POST", "/v1/api/send", {
json: { message, ws_id: wsId },
});
}
async approve(opts: {
wsId: string;
approved?: boolean;
feedback?: string | null;
always?: boolean;
}): Promise<StatusResponse> {
return this.request("POST", "/v1/api/approve", {
json: {
ws_id: opts.wsId,
approved: opts.approved ?? true,
feedback: opts.feedback,
always: opts.always,
},
});
}
async planFeedback(opts: {
wsId: string;
feedback?: string;
}): Promise<StatusResponse> {
return this.request("POST", "/v1/api/plan", {
json: { ws_id: opts.wsId, feedback: opts.feedback ?? "" },
});
}
async command(opts: {
wsId: string;
command: string;
}): Promise<StatusResponse> {
return this.request("POST", "/v1/api/command", {
json: { ws_id: opts.wsId, command: opts.command },
});
}
// -- Streaming ------------------------------------------------------------
async *streamEvents(wsId: string): AsyncIterableIterator<ServerEvent> {
yield* this.streamSSE<ServerEvent>("/v1/api/events", { ws_id: wsId });
}
async *streamGlobalEvents(): AsyncIterableIterator<ServerEvent> {
yield* this.streamSSE<ServerEvent>("/v1/api/events/global");
}
// -- High-level convenience -----------------------------------------------
async sendAndWait(
message: string,
wsId: string,
opts?: SendAndWaitOptions,
): Promise<TurnResult> {
const result: TurnResult = {
wsId,
contentParts: [],
reasoningParts: [],
toolResults: [],
errors: [],
timedOut: false,
get content() {
return this.contentParts.join("");
},
get reasoning() {
return this.reasoningParts.join("");
},
get ok() {
return !this.timedOut && this.errors.length === 0;
},
};
// Open SSE stream BEFORE sending to avoid missing early events
const timeoutMs = opts?.timeout ?? 600_000;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
// Start consuming the per-workstream SSE stream first
const events = this.streamSSE<ServerEvent>(
"/v1/api/events",
{ ws_id: wsId },
controller.signal,
);
const sendResp = await this.send(message, wsId);
if (sendResp.status === "busy") {
result.errors.push("Workstream is busy");
return result;
}
for await (const event of events) {
opts?.onEvent?.(event);
switch (event.type) {
case "content":
result.contentParts.push(event.text);
break;
case "reasoning":
result.reasoningParts.push(event.text);
break;
case "tool_result":
result.toolResults.push({
name: event.name,
output: event.output,
});
break;
case "error":
result.errors.push(event.message);
break;
case "ws_state":
if (event.state === "idle") return result;
break;
}
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
result.timedOut = true;
} else {
throw err;
}
} finally {
clearTimeout(timer);
controller.abort();
}
return result;
}
// -- Sessions -------------------------------------------------------------
async listSessions(): Promise<ListSessionsResponse> {
return this.request("GET", "/v1/api/sessions");
}
// -- Auth -----------------------------------------------------------------
async login(token: string): Promise<AuthLoginResponse> {
return this.request("POST", "/v1/api/auth/login", {
json: { token },
});
}
async logout(): Promise<StatusResponse> {
return this.request("POST", "/v1/api/auth/logout");
}
// -- Health ---------------------------------------------------------------
async health(): Promise<HealthResponse> {
return this.request("GET", "/health");
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* SSE stream parser for fetch ReadableStream.
*
* Parses standard Server-Sent Events from a `Response.body` stream.
* Works in browsers and Node.js 18+ natively (no dependencies).
*/
/**
* Parse an SSE stream and yield JSON-parsed data payloads.
*
* Handles the standard SSE format including multi-line `data:` fields
* (joined with `\n` per the SSE spec) and CRLF line endings.
*/
export async function* parseSSEStream<T = Record<string, unknown>>(
response: Response,
): AsyncIterableIterator<T> {
const body = response.body;
if (!body) return;
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Normalize CRLF to LF
buffer = buffer.replace(/\r\n/g, "\n");
// Process complete SSE frames (separated by double newlines)
const frames = buffer.split("\n\n");
// Keep the last (possibly incomplete) frame in the buffer
buffer = frames.pop() ?? "";
for (const frame of frames) {
if (!frame.trim()) continue;
// Extract data lines from the frame, joining with \n per SSE spec
const dataLines: string[] = [];
for (const line of frame.split("\n")) {
if (line.startsWith("data: ")) {
dataLines.push(line.slice(6));
} else if (line.startsWith("data:")) {
dataLines.push(line.slice(5));
}
}
if (dataLines.length === 0) continue;
const data = dataLines.join("\n");
if (!data.trim()) continue;
try {
yield JSON.parse(data) as T;
} catch {
// Skip malformed JSON
}
}
}
} finally {
reader.releaseLock();
}
}
+288
View File
@@ -0,0 +1,288 @@
// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------
export interface ErrorResponse {
error: string;
}
export interface StatusResponse {
status: string;
}
export interface AuthLoginRequest {
token: string;
}
export interface AuthLoginResponse {
status: string;
role: string;
}
// ---------------------------------------------------------------------------
// Server API — Workstream management
// ---------------------------------------------------------------------------
export interface SendRequest {
message: string;
ws_id: string;
}
export interface SendResponse {
status: string;
}
export interface ApproveRequest {
approved: boolean;
feedback?: string | null;
always?: boolean;
ws_id: string;
}
export interface PlanFeedbackRequest {
feedback: string;
ws_id: string;
}
export interface CommandRequest {
command: string;
ws_id: string;
}
export interface CreateWorkstreamRequest {
name?: string;
model?: string;
auto_approve?: boolean;
}
export interface CreateWorkstreamResponse {
ws_id: string;
name: string;
}
export interface CloseWorkstreamRequest {
ws_id: string;
}
export interface WorkstreamInfo {
id: string;
name: string;
state: string;
session_id?: string | null;
}
export interface ListWorkstreamsResponse {
workstreams: WorkstreamInfo[];
}
export interface DashboardWorkstream {
id: string;
name: string;
state: string;
session_id?: string | null;
title?: string;
tokens?: number;
context_ratio?: number;
activity?: string;
activity_state?: string;
tool_calls?: number;
node?: string;
model?: string;
model_alias?: string;
}
export interface DashboardAggregate {
total_tokens: number;
total_tool_calls: number;
active_count: number;
total_count: number;
uptime_seconds?: number;
node?: string;
}
export interface DashboardResponse {
workstreams: DashboardWorkstream[];
aggregate: DashboardAggregate;
}
// ---------------------------------------------------------------------------
// Server API — Sessions
// ---------------------------------------------------------------------------
export interface SessionInfo {
session_id: string;
alias?: string | null;
title?: string | null;
created: string;
updated: string;
message_count: number;
}
export interface ListSessionsResponse {
sessions: SessionInfo[];
}
// ---------------------------------------------------------------------------
// Server API — Health
// ---------------------------------------------------------------------------
export interface BackendStatus {
status: string;
circuit_state: string;
}
export interface WorkstreamCounts {
total: number;
idle?: number;
thinking?: number;
running?: number;
attention?: number;
error?: number;
}
export interface HealthResponse {
status: string;
version?: string;
uptime_seconds?: number;
model?: string;
workstreams?: WorkstreamCounts;
backend?: BackendStatus | null;
}
// ---------------------------------------------------------------------------
// Console API
// ---------------------------------------------------------------------------
export interface StateCounts {
running?: number;
thinking?: number;
attention?: number;
idle?: number;
error?: number;
}
export interface ClusterAggregate {
total_tokens: number;
total_tool_calls: number;
}
export interface ClusterOverviewResponse {
nodes: number;
workstreams: number;
states: StateCounts;
aggregate: ClusterAggregate;
version_drift: boolean;
versions: string[];
}
export interface ClusterNodeInfo {
node_id: string;
server_url: string;
ws_total: number;
ws_running: number;
ws_thinking: number;
ws_attention: number;
ws_idle: number;
ws_error: number;
total_tokens: number;
started: number;
reachable: boolean;
health: Record<string, string>;
version: string;
}
export interface ClusterNodesResponse {
nodes: ClusterNodeInfo[];
total: number;
}
export interface ClusterWorkstreamInfo {
id: string;
name: string;
state: string;
node: string;
title?: string;
tokens?: number;
context_ratio?: number;
activity?: string;
activity_state?: string;
tool_calls?: number;
}
export interface ClusterWorkstreamsResponse {
workstreams: ClusterWorkstreamInfo[];
total: number;
page: number;
per_page: number;
pages: number;
}
export interface NodeDetailResponse {
node_id: string;
server_url: string;
health: Record<string, string>;
workstreams: ClusterWorkstreamInfo[];
aggregate: ClusterAggregate;
}
export interface ConsoleCreateWsRequest {
node_id?: string;
name?: string;
model?: string;
initial_message?: string;
}
export interface ConsoleCreateWsResponse {
status: string;
correlation_id: string;
target_node: string;
}
export interface ConsoleHealthResponse {
status: string;
service: string;
nodes: number;
workstreams: number;
version_drift: boolean;
versions: string[];
}
// ---------------------------------------------------------------------------
// SDK-specific types
// ---------------------------------------------------------------------------
export interface TurnResult {
wsId: string;
contentParts: string[];
reasoningParts: string[];
toolResults: Array<{ name: string; output: string }>;
errors: string[];
timedOut: boolean;
content: string;
reasoning: string;
ok: boolean;
}
export interface SendAndWaitOptions {
/** Timeout in milliseconds (default: 600000 = 10 minutes). */
timeout?: number;
onEvent?: (event: import("./events.js").ServerEvent) => void;
}
export interface NodesOptions {
sort?: string;
limit?: number;
offset?: number;
}
export interface WorkstreamsOptions {
state?: string;
node?: string;
search?: string;
sort?: string;
page?: number;
per_page?: number;
}
// Re-export event types for convenience
export type { ServerEvent, ClusterEvent } from "./events.js";
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import { TurnstoneConsole } from "../src/console.js";
function mockFetch(response: object): typeof globalThis.fetch {
return vi.fn().mockResolvedValue(
new Response(JSON.stringify(response), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
}
describe("TurnstoneConsole", () => {
it("overview returns parsed response", async () => {
const fetchFn = mockFetch({
nodes: 2,
workstreams: 5,
states: { idle: 5 },
aggregate: { total_tokens: 1000, total_tool_calls: 0 },
version_drift: false,
versions: ["0.3.0"],
});
const client = new TurnstoneConsole({
baseUrl: "http://test",
fetch: fetchFn,
});
const resp = await client.overview();
expect(resp.nodes).toBe(2);
expect(resp.workstreams).toBe(5);
});
it("nodes passes query parameters", async () => {
const fetchFn = mockFetch({ nodes: [], total: 0 });
const client = new TurnstoneConsole({
baseUrl: "http://test",
fetch: fetchFn,
});
await client.nodes({ sort: "tokens", limit: 50, offset: 10 });
const [url] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("sort=tokens");
expect(url).toContain("limit=50");
expect(url).toContain("offset=10");
});
it("workstreams passes filter parameters", async () => {
const fetchFn = mockFetch({
workstreams: [],
total: 0,
page: 1,
per_page: 50,
pages: 0,
});
const client = new TurnstoneConsole({
baseUrl: "http://test",
fetch: fetchFn,
});
await client.workstreams({ state: "running", page: 2 });
const [url] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("state=running");
expect(url).toContain("page=2");
});
it("health returns parsed response", async () => {
const fetchFn = mockFetch({
status: "ok",
service: "turnstone-console",
nodes: 2,
workstreams: 5,
version_drift: false,
versions: ["0.3.0"],
});
const client = new TurnstoneConsole({
baseUrl: "http://test",
fetch: fetchFn,
});
const resp = await client.health();
expect(resp.status).toBe("ok");
expect(resp.nodes).toBe(2);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
isContentEvent,
isErrorEvent,
isStreamEndEvent,
isToolResultEvent,
isWsStateEvent,
isApproveRequestEvent,
isPlanReviewEvent,
isReasoningEvent,
} from "../src/events.js";
import type { ServerEvent } from "../src/events.js";
describe("event type guards", () => {
it("isContentEvent", () => {
const e: ServerEvent = { type: "content", text: "hello" };
expect(isContentEvent(e)).toBe(true);
expect(isErrorEvent(e)).toBe(false);
});
it("isReasoningEvent", () => {
const e: ServerEvent = { type: "reasoning", text: "step 1" };
expect(isReasoningEvent(e)).toBe(true);
expect(isContentEvent(e)).toBe(false);
});
it("isErrorEvent", () => {
const e: ServerEvent = { type: "error", message: "bad" };
expect(isErrorEvent(e)).toBe(true);
});
it("isStreamEndEvent", () => {
const e: ServerEvent = { type: "stream_end" };
expect(isStreamEndEvent(e)).toBe(true);
});
it("isToolResultEvent", () => {
const e: ServerEvent = {
type: "tool_result",
call_id: "c1",
name: "search",
output: "found",
};
expect(isToolResultEvent(e)).toBe(true);
});
it("isWsStateEvent", () => {
const e: ServerEvent = {
type: "ws_state",
ws_id: "ws1",
state: "idle",
tokens: 0,
context_ratio: 0,
activity: "",
activity_state: "",
};
expect(isWsStateEvent(e)).toBe(true);
});
it("isApproveRequestEvent", () => {
const e: ServerEvent = { type: "approve_request", items: [] };
expect(isApproveRequestEvent(e)).toBe(true);
});
it("isPlanReviewEvent", () => {
const e: ServerEvent = { type: "plan_review", content: "## Plan" };
expect(isPlanReviewEvent(e)).toBe(true);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
import { TurnstoneServer } from "../src/server.js";
import { TurnstoneAPIError } from "../src/errors.js";
function mockFetch(response: object, status = 200): typeof globalThis.fetch {
return vi.fn().mockResolvedValue(
new Response(JSON.stringify(response), {
status,
headers: { "content-type": "application/json" },
}),
);
}
function mockFetchError(
error: object,
status: number,
): typeof globalThis.fetch {
return vi.fn().mockResolvedValue(
new Response(JSON.stringify(error), {
status,
headers: { "content-type": "application/json" },
}),
);
}
describe("TurnstoneServer", () => {
it("listWorkstreams returns parsed response", async () => {
const fetchFn = mockFetch({
workstreams: [{ id: "ws1", name: "test", state: "idle" }],
});
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
const resp = await client.listWorkstreams();
expect(resp.workstreams).toHaveLength(1);
expect(resp.workstreams[0].id).toBe("ws1");
expect(fetchFn).toHaveBeenCalledWith(
"http://test/v1/api/workstreams",
expect.objectContaining({ method: "GET" }),
);
});
it("createWorkstream sends correct body", async () => {
const fetchFn = mockFetch({ ws_id: "ws_new", name: "Analysis" });
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
const resp = await client.createWorkstream({ name: "Analysis" });
expect(resp.ws_id).toBe("ws_new");
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(JSON.parse(init.body)).toEqual({ name: "Analysis" });
});
it("send posts correct payload", async () => {
const fetchFn = mockFetch({ status: "ok" });
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
await client.send("Hello", "ws1");
const [url, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("http://test/v1/api/send");
expect(JSON.parse(init.body)).toEqual({ message: "Hello", ws_id: "ws1" });
});
it("injects auth header when token provided", async () => {
const fetchFn = mockFetch({ workstreams: [] });
const client = new TurnstoneServer({
baseUrl: "http://test",
token: "tok_abc",
fetch: fetchFn,
});
await client.listWorkstreams();
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(init.headers.Authorization).toBe("Bearer tok_abc");
});
it("throws TurnstoneAPIError on 404", async () => {
const fetchFn = mockFetchError({ error: "Not found" }, 404);
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
await expect(client.send("hi", "bad_ws")).rejects.toThrow(
TurnstoneAPIError,
);
try {
await client.send("hi", "bad_ws");
} catch (e) {
expect(e).toBeInstanceOf(TurnstoneAPIError);
expect((e as TurnstoneAPIError).statusCode).toBe(404);
}
});
it("health returns parsed response", async () => {
const fetchFn = mockFetch({
status: "ok",
version: "0.3.0",
uptime_seconds: 120,
});
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
const resp = await client.health();
expect(resp.status).toBe("ok");
expect(resp.version).toBe("0.3.0");
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { parseSSEStream } from "../src/sse.js";
function makeSSEResponse(...events: string[]): Response {
const body = events.map((e) => `data: ${e}\n\n`).join("");
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(body));
controller.close();
},
});
return new Response(stream, {
headers: { "content-type": "text/event-stream" },
});
}
describe("parseSSEStream", () => {
it("yields parsed JSON from SSE data lines", async () => {
const resp = makeSSEResponse(
'{"type": "content", "text": "hello"}',
'{"type": "stream_end"}',
);
const events: unknown[] = [];
for await (const event of parseSSEStream(resp)) {
events.push(event);
}
expect(events).toHaveLength(2);
expect(events[0]).toEqual({ type: "content", text: "hello" });
expect(events[1]).toEqual({ type: "stream_end" });
});
it("skips malformed JSON", async () => {
const resp = makeSSEResponse(
"not-json",
'{"type": "info", "message": "ok"}',
);
const events: unknown[] = [];
for await (const event of parseSSEStream(resp)) {
events.push(event);
}
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ type: "info", message: "ok" });
});
it("handles multiple events in sequence", async () => {
const resp = makeSSEResponse(
'{"type": "connected", "model": "gpt-5"}',
'{"type": "content", "text": "a"}',
'{"type": "content", "text": "b"}',
'{"type": "status", "total_tokens": 10}',
'{"type": "stream_end"}',
);
const events: unknown[] = [];
for await (const event of parseSSEStream(resp)) {
events.push(event);
}
expect(events).toHaveLength(5);
const types = events.map((e) => (e as Record<string, unknown>).type);
expect(types).toEqual([
"connected",
"content",
"content",
"status",
"stream_end",
]);
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
},
});
+6 -6
View File
@@ -4,15 +4,15 @@ import pytest
@pytest.fixture
def tmp_db(tmp_path, monkeypatch):
"""Provide a temporary SQLite database."""
import turnstone.core.memory as memory
def tmp_db(tmp_path):
"""Provide a temporary SQLite storage backend."""
from turnstone.core.storage import init_storage, reset_storage
db_path = str(tmp_path / "test.db")
monkeypatch.setattr(memory, "db_override", db_path)
memory.db_initialized.discard(db_path)
reset_storage()
init_storage("sqlite", path=db_path, run_migrations=False)
yield db_path
memory.db_initialized.discard(db_path)
reset_storage()
@pytest.fixture
+120
View File
@@ -0,0 +1,120 @@
"""Integration tests for API versioning and OpenAPI/docs endpoints."""
import queue
import threading
from unittest.mock import MagicMock
import pytest
class TestServerVersioning:
"""Test /v1/ routes and OpenAPI endpoints on the server."""
@pytest.fixture()
def client(self):
from starlette.testclient import TestClient
from turnstone.core.auth import AuthConfig
from turnstone.server import create_app
mock_mgr = MagicMock()
mock_mgr.list_all.return_value = []
app = create_app(
workstreams=mock_mgr,
global_queue=queue.Queue(),
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
auth_config=AuthConfig(),
)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def test_v1_workstreams(self, client):
resp = client.get("/v1/api/workstreams")
assert resp.status_code == 200
assert "workstreams" in resp.json()
def test_unversioned_api_404(self, client):
resp = client.get("/api/workstreams")
assert resp.status_code == 404
def test_openapi_json(self, client):
resp = client.get("/openapi.json")
assert resp.status_code == 200
spec = resp.json()
assert spec["openapi"] == "3.1.0"
assert "/v1/api/send" in spec["paths"]
def test_docs_page(self, client):
resp = client.get("/docs")
assert resp.status_code == 200
assert "swagger-ui" in resp.text.lower()
def test_health_unversioned(self, client):
resp = client.get("/health")
assert resp.status_code == 200
assert "status" in resp.json()
def test_shared_static_unversioned(self, client):
resp = client.get("/shared/base.css")
assert resp.status_code == 200
class TestConsoleVersioning:
"""Test /v1/ routes and OpenAPI endpoints on the console."""
@pytest.fixture()
def client(self):
from starlette.testclient import TestClient
from turnstone.console.collector import ClusterCollector
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
collector = MagicMock(spec=ClusterCollector)
collector.get_overview.return_value = {
"nodes": 0,
"workstreams": 0,
"states": {},
"aggregate": {},
}
app = create_app(
collector=collector,
broker=MagicMock(),
auth_config=AuthConfig(),
)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def test_v1_cluster_overview(self, client):
resp = client.get("/v1/api/cluster/overview")
assert resp.status_code == 200
def test_unversioned_api_404(self, client):
resp = client.get("/api/cluster/overview")
assert resp.status_code == 404
def test_openapi_json(self, client):
resp = client.get("/openapi.json")
assert resp.status_code == 200
spec = resp.json()
assert spec["openapi"] == "3.1.0"
assert "/v1/api/cluster/overview" in spec["paths"]
def test_docs_page(self, client):
resp = client.get("/docs")
assert resp.status_code == 200
assert "swagger-ui" in resp.text.lower()
def test_health_unversioned(self, client):
resp = client.get("/health")
assert resp.status_code == 200
def test_console_app_js_uses_v1_paths(self, client):
resp = client.get("/static/app.js")
body = resp.text
assert "/v1/api/cluster" in body
+264 -213
View File
@@ -42,6 +42,12 @@ class TestIsPublicPath:
def test_static_subdir(self):
assert is_public_path("/static/fonts/mono.woff2") is True
def test_shared_css_public(self):
assert is_public_path("/shared/base.css") is True
def test_shared_js_public(self):
assert is_public_path("/shared/utils.js") is True
def test_api_workstreams_not_public(self):
assert is_public_path("/api/workstreams") is False
@@ -54,6 +60,27 @@ class TestIsPublicPath:
def test_api_events_not_public(self):
assert is_public_path("/api/events") is False
def test_v1_api_login_public(self):
assert is_public_path("/v1/api/auth/login") is True
def test_v1_api_logout_public(self):
assert is_public_path("/v1/api/auth/logout") is True
def test_v1_api_workstreams_not_public(self):
assert is_public_path("/v1/api/workstreams") is False
def test_v1_api_send_not_public(self):
assert is_public_path("/v1/api/send") is False
def test_openapi_json_public(self):
assert is_public_path("/openapi.json") is True
def test_docs_public(self):
assert is_public_path("/docs") is True
def test_shared_static_public(self):
assert is_public_path("/shared/base.css") is True
# ---------------------------------------------------------------------------
# TestRequiredRole
@@ -95,6 +122,35 @@ class TestRequiredRole:
def test_post_unknown_path_needs_read(self):
assert required_role("POST", "/api/unknown") == "read"
def test_v1_post_send_needs_full(self):
assert required_role("POST", "/v1/api/send") == "full"
def test_v1_post_approve_needs_full(self):
assert required_role("POST", "/v1/api/approve") == "full"
def test_v1_get_workstreams_needs_read(self):
assert required_role("GET", "/v1/api/workstreams") == "read"
def test_v1_post_cluster_ws_new_needs_full(self):
assert required_role("POST", "/v1/api/cluster/workstreams/new") == "full"
def test_v1_all_write_paths_need_full(self):
for path in WRITE_PATHS:
v1_path = "/v1" + path
assert required_role("POST", v1_path) == "full", f"{v1_path} should need full"
def test_proxy_v1_send_needs_full(self):
assert required_role("POST", "/node/node-a/v1/api/send") == "full"
def test_proxy_v1_approve_needs_full(self):
assert required_role("POST", "/node/node-a/v1/api/approve") == "full"
def test_proxy_v1_cluster_ws_new_needs_full(self):
assert required_role("POST", "/node/node-a/v1/api/cluster/workstreams/new") == "full"
def test_proxy_v1_read_endpoint_needs_read(self):
assert required_role("GET", "/node/node-a/v1/api/workstreams") == "read"
# ---------------------------------------------------------------------------
# TestAuthConfig
@@ -309,6 +365,76 @@ class TestCheckRequest:
assert allowed is False
assert status == 403
def test_proxy_write_read_token_403(self, enabled):
"""Read tokens cannot escalate to write ops via proxy routes."""
allowed, status, msg = check_request(
enabled, "POST", "/node/node-a/api/send", "Bearer tok_read"
)
assert allowed is False
assert status == 403
def test_proxy_write_trailing_slash_read_token_403(self, enabled):
"""Trailing slash must not bypass write-role check on proxy routes."""
allowed, status, msg = check_request(
enabled, "POST", "/node/node-a/api/send/", "Bearer tok_read"
)
assert allowed is False
assert status == 403
def test_direct_write_trailing_slash_read_token_403(self, enabled):
"""Trailing slash must not bypass write-role check on direct routes."""
allowed, status, msg = check_request(enabled, "POST", "/api/send/", "Bearer tok_read")
assert allowed is False
assert status == 403
def test_proxy_write_full_token_ok(self, enabled):
"""Full tokens pass through proxy write routes."""
allowed, status, msg = check_request(
enabled, "POST", "/node/node-a/api/send", "Bearer tok_full"
)
assert allowed is True
def test_proxy_v1_write_read_token_403(self, enabled):
"""Read tokens cannot escalate to write ops via v1 proxy routes."""
allowed, status, msg = check_request(
enabled, "POST", "/node/node-a/v1/api/send", "Bearer tok_read"
)
assert allowed is False
assert status == 403
def test_proxy_v1_write_full_token_ok(self, enabled):
"""Full tokens pass through v1 proxy write routes."""
allowed, status, msg = check_request(
enabled, "POST", "/node/node-a/v1/api/send", "Bearer tok_full"
)
assert allowed is True
def test_proxy_v1_cluster_ws_new_read_403(self, enabled):
"""Read tokens cannot create workstreams via v1 proxy."""
allowed, status, msg = check_request(
enabled,
"POST",
"/node/node-a/v1/api/cluster/workstreams/new",
"Bearer tok_read",
)
assert allowed is False
assert status == 403
def test_proxy_read_endpoint_read_token_ok(self, enabled):
"""Read tokens can access proxy read endpoints."""
allowed, status, msg = check_request(
enabled, "GET", "/node/node-a/api/workstreams", "Bearer tok_read"
)
assert allowed is True
def test_console_create_ws_read_token_403(self, enabled):
"""Read tokens cannot create workstreams."""
allowed, status, msg = check_request(
enabled, "POST", "/api/cluster/workstreams/new", "Bearer tok_read"
)
assert allowed is False
assert status == 403
def test_approve_full_token_ok(self, enabled):
allowed, status, msg = check_request(enabled, "POST", "/api/approve", "Bearer tok_full")
assert allowed is True
@@ -541,7 +667,7 @@ class TestLoadAuthConfig:
class TestServerAuth:
"""Spin up a real turnstone-server with auth enabled and test endpoints."""
"""Test turnstone-server with auth enabled using Starlette TestClient."""
@classmethod
def setup_class(cls):
@@ -549,6 +675,8 @@ class TestServerAuth:
import threading
from unittest.mock import MagicMock
from starlette.testclient import TestClient
import turnstone.server as srv_mod
from turnstone.core.metrics import MetricsCollector
from turnstone.core.workstream import WorkstreamState
@@ -556,164 +684,141 @@ class TestServerAuth:
srv_mod._metrics = MetricsCollector()
srv_mod._metrics.model = "test-model"
mock_session = MagicMock()
mock_session.session_id = "test-session-id"
mock_ws = MagicMock()
mock_ws.id = "test-ws"
mock_ws.name = "test"
mock_ws.state = WorkstreamState.IDLE
mock_ws.session = mock_session
mock_mgr = MagicMock()
mock_mgr.list_all.return_value = [mock_ws]
cls.server = srv_mod.ThreadedHTTPServer(("127.0.0.1", 0), srv_mod.TurnstoneHTTPHandler)
cls.server.workstreams = mock_mgr
cls.server.skip_permissions = False
cls.server.global_listeners = []
cls.server.global_queue = queue.Queue()
cls.server.global_listeners_lock = threading.Lock()
cls.server.auth_config = AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
app = srv_mod.create_app(
workstreams=mock_mgr,
global_queue=queue.Queue(),
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
auth_config=AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
),
)
port = cls.server.server_address[1]
cls.base = f"http://127.0.0.1:{port}"
cls._thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls._thread.start()
cls.client = TestClient(app, raise_server_exceptions=False)
@classmethod
def teardown_class(cls):
cls.server.shutdown()
cls._thread.join(timeout=5)
cls.client.close()
def test_health_no_token_200(self):
import httpx
resp = httpx.get(f"{self.base}/health", timeout=5)
resp = self.client.get("/health")
assert resp.status_code == 200
def test_metrics_no_token_passes_auth(self):
import httpx
try:
resp = httpx.get(f"{self.base}/metrics", timeout=5)
# Public path — should never be 401/403
assert resp.status_code not in (401, 403)
except httpx.RemoteProtocolError:
# Server crashes in metrics handler due to MagicMock —
# the important thing is auth didn't reject it (no 401/403 before crash)
pass
resp = self.client.get("/metrics")
# Public path — should never be 401/403
assert resp.status_code not in (401, 403)
def test_root_no_token_200(self):
import httpx
resp = httpx.get(f"{self.base}/", timeout=5)
resp = self.client.get("/")
assert resp.status_code == 200
def test_static_css_no_token_200(self):
import httpx
resp = httpx.get(f"{self.base}/static/style.css", timeout=5)
resp = self.client.get("/static/style.css")
assert resp.status_code == 200
def test_api_workstreams_no_token_401(self):
import httpx
resp = httpx.get(f"{self.base}/api/workstreams", timeout=5)
resp = self.client.get("/v1/api/workstreams")
assert resp.status_code == 401
assert "Unauthorized" in resp.json().get("error", "")
def test_api_workstreams_read_token_200(self):
import httpx
resp = httpx.get(
f"{self.base}/api/workstreams",
resp = self.client.get(
"/v1/api/workstreams",
headers={"Authorization": "Bearer tok_read"},
timeout=5,
)
assert resp.status_code == 200
def test_api_workstreams_full_token_200(self):
import httpx
resp = httpx.get(
f"{self.base}/api/workstreams",
resp = self.client.get(
"/v1/api/workstreams",
headers={"Authorization": "Bearer tok_full"},
timeout=5,
)
assert resp.status_code == 200
def test_api_send_read_token_403(self):
import httpx
resp = httpx.post(
f"{self.base}/api/send",
resp = self.client.post(
"/v1/api/send",
headers={"Authorization": "Bearer tok_read"},
json={"message": "hello", "ws_id": "x"},
timeout=5,
)
assert resp.status_code == 403
assert "Forbidden" in resp.json().get("error", "")
def test_api_send_full_token_passes_auth(self):
import httpx
resp = httpx.post(
f"{self.base}/api/send",
resp = self.client.post(
"/v1/api/send",
headers={"Authorization": "Bearer tok_full"},
json={"message": "hello", "ws_id": "nonexistent"},
timeout=5,
)
# Should get 404 (unknown workstream), not 401/403
assert resp.status_code not in (401, 403)
def test_api_send_no_token_401(self):
import httpx
resp = httpx.post(
f"{self.base}/api/send",
resp = self.client.post(
"/v1/api/send",
json={"message": "hello", "ws_id": "x"},
timeout=5,
)
assert resp.status_code == 401
def test_invalid_token_401(self):
import httpx
resp = httpx.get(
f"{self.base}/api/workstreams",
resp = self.client.get(
"/v1/api/workstreams",
headers={"Authorization": "Bearer wrong_token"},
timeout=5,
)
assert resp.status_code == 401
def test_options_no_auth_required(self):
import httpx
resp = httpx.options(f"{self.base}/api/send", timeout=5)
resp = self.client.options(
"/v1/api/send",
headers={
"Origin": "http://example.com",
"Access-Control-Request-Method": "POST",
},
)
assert resp.status_code == 200
allowed = resp.headers.get("access-control-allow-headers", "")
assert "Authorization" in allowed
assert "authorization" in allowed.lower()
def test_cors_includes_authorization(self):
import httpx
resp = httpx.options(f"{self.base}/api/workstreams", timeout=5)
resp = self.client.options(
"/v1/api/workstreams",
headers={
"Origin": "http://example.com",
"Access-Control-Request-Method": "GET",
},
)
allowed = resp.headers.get("access-control-allow-headers", "")
assert "Authorization" in allowed
assert "authorization" in allowed.lower()
def test_shared_css_no_token_200(self):
resp = self.client.get("/shared/base.css")
assert resp.status_code == 200
class TestConsoleAuth:
"""Spin up a console server with auth enabled and test endpoints."""
"""Test console server with auth enabled using TestClient."""
@classmethod
def setup_class(cls):
import threading
from unittest.mock import MagicMock
from starlette.testclient import TestClient
from turnstone.console.collector import ClusterCollector
from turnstone.console.server import (
ConsoleHTTPHandler,
ThreadedHTTPServer,
_load_static,
)
from turnstone.console.server import _load_static, create_app
_load_static()
@@ -725,69 +830,50 @@ class TestConsoleAuth:
"aggregate": {"total_tokens": 100},
}
cls.server = ThreadedHTTPServer(("127.0.0.1", 0), ConsoleHTTPHandler)
cls.server.collector = mock_collector
cls.server.auth_config = AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
app = create_app(
collector=mock_collector,
broker=MagicMock(),
auth_config=AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
),
)
port = cls.server.server_address[1]
cls.base = f"http://127.0.0.1:{port}"
cls._thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls._thread.start()
cls.test_client = TestClient(app, raise_server_exceptions=False)
@classmethod
def teardown_class(cls):
cls.server.shutdown()
cls._thread.join(timeout=5)
cls.test_client.close()
def test_health_no_token_200(self):
import httpx
resp = httpx.get(f"{self.base}/health", timeout=5)
resp = self.test_client.get("/health")
assert resp.status_code == 200
def test_root_no_token_200(self):
import httpx
resp = httpx.get(f"{self.base}/", timeout=5)
resp = self.test_client.get("/")
assert resp.status_code == 200
def test_api_overview_no_token_401(self):
import httpx
resp = httpx.get(f"{self.base}/api/cluster/overview", timeout=5)
resp = self.test_client.get("/v1/api/cluster/overview")
assert resp.status_code == 401
def test_api_overview_read_token_200(self):
import httpx
resp = httpx.get(
f"{self.base}/api/cluster/overview",
resp = self.test_client.get(
"/v1/api/cluster/overview",
headers={"Authorization": "Bearer tok_read"},
timeout=5,
)
assert resp.status_code == 200
def test_api_overview_full_token_200(self):
import httpx
resp = httpx.get(
f"{self.base}/api/cluster/overview",
resp = self.test_client.get(
"/v1/api/cluster/overview",
headers={"Authorization": "Bearer tok_full"},
timeout=5,
)
assert resp.status_code == 200
def test_invalid_token_401(self):
import httpx
resp = httpx.get(
f"{self.base}/api/cluster/overview",
resp = self.test_client.get(
"/v1/api/cluster/overview",
headers={"Authorization": "Bearer wrong"},
timeout=5,
)
assert resp.status_code == 401
@@ -806,6 +892,8 @@ class TestServerLogin:
import threading
from unittest.mock import MagicMock
from starlette.testclient import TestClient
import turnstone.server as srv_mod
from turnstone.core.metrics import MetricsCollector
from turnstone.core.workstream import WorkstreamState
@@ -813,40 +901,38 @@ class TestServerLogin:
srv_mod._metrics = MetricsCollector()
srv_mod._metrics.model = "test-model"
mock_session = MagicMock()
mock_session.session_id = "test-session-id"
mock_ws = MagicMock()
mock_ws.id = "test-ws"
mock_ws.name = "test"
mock_ws.state = WorkstreamState.IDLE
mock_ws.session = mock_session
mock_mgr = MagicMock()
mock_mgr.list_all.return_value = [mock_ws]
cls.server = srv_mod.ThreadedHTTPServer(("127.0.0.1", 0), srv_mod.TurnstoneHTTPHandler)
cls.server.workstreams = mock_mgr
cls.server.skip_permissions = False
cls.server.global_listeners = []
cls.server.global_queue = queue.Queue()
cls.server.global_listeners_lock = threading.Lock()
cls.server.auth_config = AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
app = srv_mod.create_app(
workstreams=mock_mgr,
global_queue=queue.Queue(),
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
auth_config=AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
),
)
port = cls.server.server_address[1]
cls.base = f"http://127.0.0.1:{port}"
cls._thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls._thread.start()
cls.test_client = TestClient(app, raise_server_exceptions=False)
@classmethod
def teardown_class(cls):
cls.server.shutdown()
cls._thread.join(timeout=5)
cls.test_client.close()
def test_login_valid_token_sets_cookie(self):
import httpx
resp = httpx.post(
f"{self.base}/api/auth/login",
resp = self.test_client.post(
"/v1/api/auth/login",
json={"token": "tok_full"},
timeout=5,
)
assert resp.status_code == 200
data = resp.json()
@@ -856,55 +942,41 @@ class TestServerLogin:
assert "HttpOnly" in cookie
def test_login_invalid_token_401(self):
import httpx
resp = httpx.post(
f"{self.base}/api/auth/login",
resp = self.test_client.post(
"/v1/api/auth/login",
json={"token": "wrong"},
timeout=5,
)
assert resp.status_code == 401
def test_login_no_auth_required(self):
import httpx
# /api/auth/login is public — shouldn't require auth itself
resp = httpx.post(
f"{self.base}/api/auth/login",
# /v1/api/auth/login is public — shouldn't require auth itself
resp = self.test_client.post(
"/v1/api/auth/login",
json={"token": "tok_read"},
timeout=5,
)
assert resp.status_code == 200
def test_cookie_auth_on_api(self):
import httpx
# Login to get cookie
client = httpx.Client(base_url=self.base, timeout=5)
login_resp = client.post("/api/auth/login", json={"token": "tok_read"})
# Login to get cookie (TestClient tracks cookies automatically)
login_resp = self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
assert login_resp.status_code == 200
# Use cookie to access API
resp = client.get("/api/workstreams")
# Use cookie to access API — TestClient forwards cookies
resp = self.test_client.get("/v1/api/workstreams")
assert resp.status_code == 200
client.close()
def test_logout_clears_cookie(self):
import httpx
client = httpx.Client(base_url=self.base, timeout=5)
client.post("/api/auth/login", json={"token": "tok_read"})
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
# Logout
logout_resp = client.post("/api/auth/logout")
logout_resp = self.test_client.post("/v1/api/auth/logout")
assert logout_resp.status_code == 200
cookie = logout_resp.headers.get("set-cookie", "")
assert "Max-Age=0" in cookie
# API should now fail
resp = client.get("/api/workstreams")
# API should now fail (cookie cleared)
resp = self.test_client.get("/v1/api/workstreams")
assert resp.status_code == 401
client.close()
class TestConsoleLogin:
@@ -912,15 +984,12 @@ class TestConsoleLogin:
@classmethod
def setup_class(cls):
import threading
from unittest.mock import MagicMock
from starlette.testclient import TestClient
from turnstone.console.collector import ClusterCollector
from turnstone.console.server import (
ConsoleHTTPHandler,
ThreadedHTTPServer,
_load_static,
)
from turnstone.console.server import _load_static, create_app
_load_static()
@@ -932,60 +1001,42 @@ class TestConsoleLogin:
"aggregate": {"total_tokens": 100},
}
cls.server = ThreadedHTTPServer(("127.0.0.1", 0), ConsoleHTTPHandler)
cls.server.collector = mock_collector
cls.server.auth_config = AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
app = create_app(
collector=mock_collector,
broker=MagicMock(),
auth_config=AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
),
)
port = cls.server.server_address[1]
cls.base = f"http://127.0.0.1:{port}"
cls._thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls._thread.start()
cls.test_client = TestClient(app, raise_server_exceptions=False)
@classmethod
def teardown_class(cls):
cls.server.shutdown()
cls._thread.join(timeout=5)
cls.test_client.close()
def test_login_valid_token(self):
import httpx
resp = httpx.post(
f"{self.base}/api/auth/login",
resp = self.test_client.post(
"/v1/api/auth/login",
json={"token": "tok_read"},
timeout=5,
)
assert resp.status_code == 200
assert "turnstone_auth" in resp.headers.get("set-cookie", "")
def test_login_invalid_token(self):
import httpx
resp = httpx.post(
f"{self.base}/api/auth/login",
resp = self.test_client.post(
"/v1/api/auth/login",
json={"token": "wrong"},
timeout=5,
)
assert resp.status_code == 401
def test_cookie_auth_on_api(self):
import httpx
client = httpx.Client(base_url=self.base, timeout=5)
client.post("/api/auth/login", json={"token": "tok_read"})
resp = client.get("/api/cluster/overview")
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
resp = self.test_client.get("/v1/api/cluster/overview")
assert resp.status_code == 200
client.close()
def test_logout_then_api_fails(self):
import httpx
client = httpx.Client(base_url=self.base, timeout=5)
client.post("/api/auth/login", json={"token": "tok_read"})
client.post("/api/auth/logout")
resp = client.get("/api/cluster/overview")
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
self.test_client.post("/v1/api/auth/logout")
resp = self.test_client.get("/v1/api/cluster/overview")
assert resp.status_code == 401
client.close()
+6 -10
View File
@@ -153,27 +153,23 @@ def test_apply_config_model_section(tmp_path, monkeypatch):
def test_tavily_key_from_config(tmp_path, monkeypatch):
"""get_tavily_key() reads from config.toml [api] tavily_key."""
_reset_cache()
import turnstone.core.memory as mem
mem._tavily_key = None
mem._tavily_key_loaded = False
config_mod._tavily_key = None
config_mod._tavily_key_loaded = False
cfg = tmp_path / "config.toml"
cfg.write_text('[api]\ntavily_key = "tvly-from-config"\n')
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
key = mem.get_tavily_key()
key = config_mod.get_tavily_key()
assert key == "tvly-from-config"
def test_tavily_key_fallback_to_env(tmp_path, monkeypatch):
"""get_tavily_key() falls back to $TAVILY_API_KEY env var."""
_reset_cache()
import turnstone.core.memory as mem
mem._tavily_key = None
mem._tavily_key_loaded = False
config_mod._tavily_key = None
config_mod._tavily_key_loaded = False
# Config exists but no tavily_key in it
cfg = tmp_path / "config.toml"
@@ -181,5 +177,5 @@ def test_tavily_key_fallback_to_env(tmp_path, monkeypatch):
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
monkeypatch.setenv("TAVILY_API_KEY", "tvly-from-env")
key = mem.get_tavily_key()
key = config_mod.get_tavily_key()
assert key == "tvly-from-env"
+699 -44
View File
@@ -2,10 +2,8 @@
import json
import queue
import threading
from unittest.mock import MagicMock
import httpx
import pytest
from turnstone.console.collector import ClusterCollector, NodeSnapshot
@@ -59,7 +57,7 @@ def _make_collector(broker=None, poll_interval=999, discovery_interval=999):
def _dashboard_response(workstreams=None, aggregate=None):
"""Build a /api/dashboard-style response dict."""
"""Build a /v1/api/dashboard-style response dict."""
return {
"workstreams": workstreams or [],
"aggregate": aggregate
@@ -150,7 +148,7 @@ class TestCollectorDiscovery:
class TestCollectorPolling:
"""Polling /api/dashboard from nodes."""
"""Polling /v1/api/dashboard from nodes."""
def test_apply_poll_populates_workstreams(self):
c = _make_collector()
@@ -540,52 +538,50 @@ class TestConsoleHTTPEndpoints:
return collector
@pytest.fixture()
def server(self, mock_collector):
from turnstone.console.server import (
ConsoleHTTPHandler,
ThreadedHTTPServer,
_load_static,
)
def client(self, mock_collector):
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
_load_static()
from turnstone.core.auth import AuthConfig
httpd = ThreadedHTTPServer(("127.0.0.1", 0), ConsoleHTTPHandler)
httpd.collector = mock_collector
httpd.auth_config = AuthConfig() # auth disabled by default
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{port}"
httpd.shutdown()
app = create_app(
collector=mock_collector,
broker=MagicMock(),
auth_config=AuthConfig(),
)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def _get(self, server, path):
resp = httpx.get(f"{server}{path}", timeout=5)
def _get(self, client, path):
resp = client.get(path)
return resp.status_code, resp.json()
def _get_raw(self, server, path):
resp = httpx.get(f"{server}{path}", timeout=5)
def _get_raw(self, client, path):
resp = client.get(path)
return resp.status_code, resp.text, resp.headers.get("content-type")
def test_get_overview(self, server, mock_collector):
status, data = self._get(server, "/api/cluster/overview")
def test_get_overview(self, client, mock_collector):
status, data = self._get(client, "/v1/api/cluster/overview")
assert status == 200
assert data["nodes"] == 3
assert data["workstreams"] == 15
assert data["states"]["running"] == 5
mock_collector.get_overview.assert_called_once()
def test_get_nodes(self, server, mock_collector):
status, data = self._get(server, "/api/cluster/nodes?sort=activity&limit=10&offset=0")
def test_get_nodes(self, client, mock_collector):
status, data = self._get(client, "/v1/api/cluster/nodes?sort=activity&limit=10&offset=0")
assert status == 200
assert len(data["nodes"]) == 1
assert data["total"] == 1
mock_collector.get_nodes.assert_called_once_with(sort_by="activity", limit=10, offset=0)
def test_get_workstreams(self, server, mock_collector):
def test_get_workstreams(self, client, mock_collector):
status, data = self._get(
server, "/api/cluster/workstreams?state=running&page=1&per_page=25"
client, "/v1/api/cluster/workstreams?state=running&page=1&per_page=25"
)
assert status == 200
assert len(data["workstreams"]) == 1
@@ -601,46 +597,705 @@ class TestConsoleHTTPEndpoints:
per_page=25,
)
def test_get_workstreams_per_page_capped(self, server, mock_collector):
self._get(server, "/api/cluster/workstreams?per_page=999")
def test_get_workstreams_per_page_capped(self, client, mock_collector):
self._get(client, "/v1/api/cluster/workstreams?per_page=999")
call_kwargs = mock_collector.get_workstreams.call_args
assert call_kwargs.kwargs["per_page"] == 200
def test_get_node_detail(self, server, mock_collector):
status, data = self._get(server, "/api/cluster/node/node-a")
def test_get_node_detail(self, client, mock_collector):
status, data = self._get(client, "/v1/api/cluster/node/node-a")
assert status == 200
assert data["node_id"] == "node-a"
mock_collector.get_node_detail.assert_called_once_with("node-a")
def test_get_node_detail_not_found(self, server, mock_collector):
def test_get_node_detail_not_found(self, client, mock_collector):
mock_collector.get_node_detail.return_value = None
status, data = self._get(server, "/api/cluster/node/nonexistent")
status, data = self._get(client, "/v1/api/cluster/node/nonexistent")
assert status == 404
assert "error" in data
def test_health_endpoint(self, server, mock_collector):
status, data = self._get(server, "/health")
def test_health_endpoint(self, client, mock_collector):
status, data = self._get(client, "/health")
assert status == 200
assert data["status"] == "ok"
assert data["service"] == "turnstone-console"
assert data["nodes"] == 3
def test_index_html(self, server):
status, body, ct = self._get_raw(server, "/")
def test_index_html(self, client):
status, body, ct = self._get_raw(client, "/")
assert status == 200
assert "text/html" in ct
assert "turnstone console" in body
def test_static_css(self, server):
status, body, ct = self._get_raw(server, "/static/style.css")
def test_static_css(self, client):
status, body, ct = self._get_raw(client, "/static/style.css")
assert status == 200
assert "text/css" in ct
def test_static_js(self, server):
status, body, ct = self._get_raw(server, "/static/app.js")
def test_static_js(self, client):
status, body, ct = self._get_raw(client, "/static/app.js")
assert status == 200
assert "javascript" in ct
def test_404(self, server):
resp = httpx.get(f"{server}/nonexistent", timeout=5)
def test_404(self, client):
resp = client.get("/nonexistent")
assert resp.status_code == 404
def test_index_has_new_ws_button(self, client):
status, body, ct = self._get_raw(client, "/")
assert status == 200
assert 'id="new-ws-btn"' in body
assert "showNewWsModal" in body
def test_index_has_new_ws_modal(self, client):
status, body, ct = self._get_raw(client, "/")
assert 'id="new-ws-overlay"' in body
assert 'id="new-ws-node"' in body
# ---------------------------------------------------------------------------
# Version tracking / drift detection
# ---------------------------------------------------------------------------
class TestCollectorVersionInfo:
"""Version extraction and drift detection."""
def test_get_overview_no_drift(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a", health={"status": "ok", "version": "0.3.0"}
)
c._nodes["node-b"] = NodeSnapshot(
node_id="node-b", health={"status": "ok", "version": "0.3.0"}
)
overview = c.get_overview()
assert overview["version_drift"] is False
assert overview["versions"] == ["0.3.0"]
def test_get_overview_drift_detected(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a", health={"status": "ok", "version": "0.3.0"}
)
c._nodes["node-b"] = NodeSnapshot(
node_id="node-b", health={"status": "ok", "version": "0.3.1"}
)
overview = c.get_overview()
assert overview["version_drift"] is True
assert sorted(overview["versions"]) == ["0.3.0", "0.3.1"]
def test_get_overview_no_version_in_health(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", health={"status": "ok"})
overview = c.get_overview()
assert overview["version_drift"] is False
assert overview["versions"] == []
def test_get_overview_single_node_no_drift(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", health={"version": "0.3.0"})
overview = c.get_overview()
assert overview["version_drift"] is False
assert overview["versions"] == ["0.3.0"]
def test_get_nodes_includes_version(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a",
server_url="http://a:8080",
health={"status": "ok", "version": "0.3.0"},
)
nodes, _ = c.get_nodes()
assert nodes[0]["version"] == "0.3.0"
def test_get_nodes_version_empty_when_missing(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080", health={})
nodes, _ = c.get_nodes()
assert nodes[0]["version"] == ""
def test_get_version_info(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", health={"version": "0.3.0"})
c._nodes["node-b"] = NodeSnapshot(node_id="node-b", health={"version": "0.3.1"})
info = c.get_version_info()
assert info["drift"] is True
assert info["versions"]["node-a"] == "0.3.0"
assert info["versions"]["node-b"] == "0.3.1"
assert sorted(info["unique_versions"]) == ["0.3.0", "0.3.1"]
def test_get_version_info_no_drift(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", health={"version": "0.3.0"})
c._nodes["node-b"] = NodeSnapshot(node_id="node-b", health={"version": "0.3.0"})
info = c.get_version_info()
assert info["drift"] is False
assert info["unique_versions"] == ["0.3.0"]
# ---------------------------------------------------------------------------
# Workstream creation tests
# ---------------------------------------------------------------------------
class TestConsoleWorkstreamCreation:
"""Tests for POST /v1/api/cluster/workstreams/new."""
@pytest.fixture()
def mock_collector(self):
collector = MagicMock(spec=ClusterCollector)
collector.get_overview.return_value = {
"nodes": 2,
"workstreams": 5,
"states": {"running": 1, "idle": 4, "thinking": 0, "attention": 0, "error": 0},
"aggregate": {"total_tokens": 0, "total_tool_calls": 0},
}
collector.get_node_detail.return_value = {
"node_id": "node-a",
"server_url": "http://a:8080",
"health": {},
"workstreams": [],
"aggregate": {},
"reachable": True,
}
collector.get_nodes.return_value = (
[
{"node_id": "node-a", "reachable": True, "max_ws": 10, "ws_total": 8},
{"node_id": "node-b", "reachable": True, "max_ws": 10, "ws_total": 3},
],
2,
)
return collector
@pytest.fixture()
def client_and_broker(self, mock_collector):
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
mock_broker = MagicMock()
app = create_app(
collector=mock_collector,
broker=mock_broker,
auth_config=AuthConfig(),
)
client = TestClient(app, raise_server_exceptions=False)
yield client, mock_broker
client.close()
def test_create_with_explicit_node(self, client_and_broker, mock_collector):
client, broker = client_and_broker
resp = client.post(
"/v1/api/cluster/workstreams/new",
json={"node_id": "node-a", "name": "test-ws"},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["target_node"] == "node-a"
assert "correlation_id" in data
broker.push_inbound.assert_called_once()
# Verify the pushed message
msg_json = broker.push_inbound.call_args[0][0]
msg = json.loads(msg_json)
assert msg["type"] == "create_workstream"
assert msg["target_node"] == "node-a"
assert msg["name"] == "test-ws"
def test_create_with_model(self, client_and_broker, mock_collector):
client, broker = client_and_broker
resp = client.post(
"/v1/api/cluster/workstreams/new",
json={"node_id": "node-a", "model": "gpt-5"},
)
assert resp.status_code == 200
msg_json = broker.push_inbound.call_args[0][0]
msg = json.loads(msg_json)
assert msg["model"] == "gpt-5"
def test_create_with_initial_message_directed(self, client_and_broker, mock_collector):
client, broker = client_and_broker
resp = client.post(
"/v1/api/cluster/workstreams/new",
json={"node_id": "node-a", "initial_message": "Do the thing"},
)
assert resp.status_code == 200
msg = json.loads(broker.push_inbound.call_args[0][0])
assert msg["initial_message"] == "Do the thing"
def test_create_with_initial_message_pool(self, client_and_broker, mock_collector):
client, broker = client_and_broker
resp = client.post(
"/v1/api/cluster/workstreams/new",
json={"node_id": "pool", "initial_message": "Pool task"},
)
assert resp.status_code == 200
msg = json.loads(broker.push_inbound.call_args[0][0])
assert msg["initial_message"] == "Pool task"
def test_create_auto_selects_best_node(self, client_and_broker, mock_collector):
client, broker = client_and_broker
resp = client.post(
"/v1/api/cluster/workstreams/new",
json={"name": "auto-test"},
)
assert resp.status_code == 200
data = resp.json()
# node-b has more headroom (10-3=7 vs 10-8=2)
assert data["target_node"] == "node-b"
def test_create_no_reachable_nodes(self, client_and_broker, mock_collector):
client, broker = client_and_broker
mock_collector.get_nodes.return_value = ([], 0)
resp = client.post("/v1/api/cluster/workstreams/new", json={})
assert resp.status_code == 503
assert "No reachable nodes" in resp.json()["error"]
def test_create_unknown_node(self, client_and_broker, mock_collector):
client, broker = client_and_broker
mock_collector.get_node_detail.return_value = None
resp = client.post(
"/v1/api/cluster/workstreams/new",
json={"node_id": "nonexistent"},
)
assert resp.status_code == 404
def test_create_invalid_json(self, client_and_broker):
client, broker = client_and_broker
resp = client.post(
"/v1/api/cluster/workstreams/new",
content=b"not json",
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 400
def test_create_pushes_to_directed_queue(self, client_and_broker, mock_collector):
client, broker = client_and_broker
resp = client.post(
"/v1/api/cluster/workstreams/new",
json={"node_id": "node-a"},
)
assert resp.status_code == 200
# Verify push_inbound called with node_id kwarg
call_kwargs = broker.push_inbound.call_args
assert call_kwargs[1]["node_id"] == "node-a"
def test_create_pool_pushes_to_shared_queue(self, client_and_broker, mock_collector):
client, broker = client_and_broker
resp = client.post(
"/v1/api/cluster/workstreams/new",
json={"node_id": "pool", "name": "pool-task"},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["target_node"] == "pool"
broker.push_inbound.assert_called_once()
# Shared queue: no node_id kwarg (or empty)
call_args = broker.push_inbound.call_args
assert call_args[1].get("node_id", "") == ""
# Message should have no target_node
msg = json.loads(call_args[0][0])
assert msg["type"] == "create_workstream"
assert msg["target_node"] == ""
assert msg["name"] == "pool-task"
def test_create_pool_skips_node_validation(self, client_and_broker, mock_collector):
"""Pool mode doesn't need a valid node_id — it goes to the shared queue."""
client, broker = client_and_broker
mock_collector.get_node_detail.return_value = None # would 404 for directed
resp = client.post(
"/v1/api/cluster/workstreams/new",
json={"node_id": "pool"},
)
assert resp.status_code == 200
assert resp.json()["target_node"] == "pool"
# ---------------------------------------------------------------------------
# Proxy tests
# ---------------------------------------------------------------------------
class TestConsoleProxy:
"""Tests for /node/{node_id}/ reverse proxy."""
@pytest.fixture()
def mock_collector(self):
collector = MagicMock(spec=ClusterCollector)
collector.get_overview.return_value = {
"nodes": 1,
"workstreams": 2,
"states": {"running": 0, "idle": 2, "thinking": 0, "attention": 0, "error": 0},
"aggregate": {"total_tokens": 0, "total_tool_calls": 0},
}
collector.get_node_detail.return_value = {
"node_id": "node-a",
"server_url": "http://a:8080",
"health": {},
"workstreams": [],
"aggregate": {},
"reachable": True,
}
return collector
@pytest.fixture()
def client(self, mock_collector):
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
app = create_app(
collector=mock_collector,
broker=MagicMock(),
auth_config=AuthConfig(),
)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def test_proxy_unknown_node_returns_404(self, client, mock_collector):
mock_collector.get_node_detail.return_value = None
resp = client.get("/node/unknown/")
assert resp.status_code == 404
def test_proxy_static_unknown_node_returns_404(self, client, mock_collector):
mock_collector.get_node_detail.return_value = None
resp = client.get("/node/unknown/static/app.js")
assert resp.status_code == 404
def test_proxy_api_unknown_node_returns_404(self, client, mock_collector):
mock_collector.get_node_detail.return_value = None
resp = client.get("/node/unknown/api/workstreams")
assert resp.status_code == 404
def test_proxy_api_post_unknown_node_returns_404(self, client, mock_collector):
mock_collector.get_node_detail.return_value = None
resp = client.post(
"/node/unknown/api/send",
json={"message": "hello", "ws_id": "ws1"},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Proxy URL rewriting unit tests (no HTTP needed)
# ---------------------------------------------------------------------------
class TestProxyRewriting:
"""Test the JS shim and HTML rewriting logic."""
def test_js_shim_contains_prefix_placeholder(self):
from turnstone.console.server import _JS_PROXY_SHIM
assert "PREFIX_PLACEHOLDER" in _JS_PROXY_SHIM
replaced = _JS_PROXY_SHIM.replace("PREFIX_PLACEHOLDER", "/node/my-node")
assert "/node/my-node" in replaced
assert "PREFIX_PLACEHOLDER" not in replaced
def test_js_shim_overrides_fetch_and_eventsource(self):
from turnstone.console.server import _JS_PROXY_SHIM
assert "window.fetch" in _JS_PROXY_SHIM
assert "window.EventSource" in _JS_PROXY_SHIM
def test_console_banner_contains_placeholder(self):
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE
assert "NODE_ID_PLACEHOLDER" in _CONSOLE_BANNER_TEMPLATE
assert "Console" in _CONSOLE_BANNER_TEMPLATE
def test_html_rewriting_changes_static_paths(self):
"""Simulate the proxy_index rewriting logic."""
sample_html = (
'<link rel="stylesheet" href="/static/style.css">\n'
'<script src="/static/app.js"></script>'
)
prefix = "/node/test-node"
rewritten = sample_html.replace('href="/static/', f'href="{prefix}/static/')
rewritten = rewritten.replace('src="/static/', f'src="{prefix}/static/')
assert "/node/test-node/static/style.css" in rewritten
assert "/node/test-node/static/app.js" in rewritten
# Originals should be gone
assert 'href="/static/' not in rewritten
assert 'src="/static/' not in rewritten
def test_banner_injection_after_body(self):
"""Simulate the banner injection logic."""
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE
sample_html = "<html><body><div>content</div></body></html>"
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", "node-a")
result = sample_html.replace("<body>", "<body>" + banner, 1)
assert "node-a" in result
assert "Console" in result
assert result.startswith("<html><body><div")
# ---------------------------------------------------------------------------
# _pick_best_node unit tests
# ---------------------------------------------------------------------------
class TestPickBestNode:
"""Test the _pick_best_node helper."""
def test_picks_node_with_most_headroom(self):
from turnstone.console.server import _pick_best_node
collector = MagicMock(spec=ClusterCollector)
collector.get_nodes.return_value = (
[
{"node_id": "busy", "reachable": True, "max_ws": 10, "ws_total": 9},
{"node_id": "free", "reachable": True, "max_ws": 10, "ws_total": 2},
{"node_id": "mid", "reachable": True, "max_ws": 10, "ws_total": 5},
],
3,
)
assert _pick_best_node(collector) == "free"
def test_skips_unreachable_nodes(self):
from turnstone.console.server import _pick_best_node
collector = MagicMock(spec=ClusterCollector)
collector.get_nodes.return_value = (
[
{"node_id": "down", "reachable": False, "max_ws": 10, "ws_total": 0},
{"node_id": "up", "reachable": True, "max_ws": 10, "ws_total": 5},
],
2,
)
assert _pick_best_node(collector) == "up"
def test_returns_empty_when_no_nodes(self):
from turnstone.console.server import _pick_best_node
collector = MagicMock(spec=ClusterCollector)
collector.get_nodes.return_value = ([], 0)
assert _pick_best_node(collector) == ""
def test_returns_empty_when_all_unreachable(self):
from turnstone.console.server import _pick_best_node
collector = MagicMock(spec=ClusterCollector)
collector.get_nodes.return_value = (
[{"node_id": "down", "reachable": False, "max_ws": 10, "ws_total": 0}],
1,
)
assert _pick_best_node(collector) == ""
# ---------------------------------------------------------------------------
# Version tracking endpoint tests
# ---------------------------------------------------------------------------
class TestConsoleVersionEndpoints:
"""HTTP endpoint tests for version drift fields."""
@pytest.fixture()
def mock_collector(self):
collector = MagicMock(spec=ClusterCollector)
collector.get_overview.return_value = {
"nodes": 2,
"workstreams": 5,
"states": {"running": 1, "thinking": 0, "attention": 0, "idle": 4, "error": 0},
"aggregate": {"total_tokens": 10000, "total_tool_calls": 50},
"version_drift": True,
"versions": ["0.3.0", "0.3.1"],
}
return collector
@pytest.fixture()
def client(self, mock_collector):
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
app = create_app(
collector=mock_collector,
broker=MagicMock(),
auth_config=AuthConfig(),
)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def _get(self, client, path):
resp = client.get(path)
return resp.status_code, resp.json()
def test_overview_includes_version_drift(self, client, mock_collector):
status, data = self._get(client, "/v1/api/cluster/overview")
assert status == 200
assert data["version_drift"] is True
assert "0.3.0" in data["versions"]
assert "0.3.1" in data["versions"]
def test_health_includes_version_drift(self, client, mock_collector):
status, data = self._get(client, "/health")
assert status == 200
assert data["version_drift"] is True
assert "0.3.0" in data["versions"]
# ---------------------------------------------------------------------------
# Shared static serving
# ---------------------------------------------------------------------------
class TestSharedStatic:
"""Tests for /shared/ static file serving."""
@pytest.fixture()
def client(self):
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
collector = MagicMock(spec=ClusterCollector)
collector.get_overview.return_value = {
"nodes": 0,
"workstreams": 0,
"states": {},
"aggregate": {},
}
app = create_app(
collector=collector,
broker=MagicMock(),
auth_config=AuthConfig(),
)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def test_shared_base_css(self, client):
resp = client.get("/shared/base.css")
assert resp.status_code == 200
assert "text/css" in resp.headers.get("content-type", "")
def test_shared_utils_js(self, client):
resp = client.get("/shared/utils.js")
assert resp.status_code == 200
assert "javascript" in resp.headers.get("content-type", "")
def test_shared_auth_js(self, client):
resp = client.get("/shared/auth.js")
assert resp.status_code == 200
assert "javascript" in resp.headers.get("content-type", "")
def test_shared_toast_js(self, client):
resp = client.get("/shared/toast.js")
assert resp.status_code == 200
def test_shared_theme_js(self, client):
resp = client.get("/shared/theme.js")
assert resp.status_code == 200
def test_shared_kb_js(self, client):
resp = client.get("/shared/kb.js")
assert resp.status_code == 200
def test_shared_nonexistent_returns_404(self, client):
resp = client.get("/shared/nonexistent.js")
assert resp.status_code == 404
def test_index_imports_shared_base_css(self, client):
resp = client.get("/")
assert resp.status_code == 200
assert '/shared/base.css"' in resp.text
def test_index_imports_shared_scripts(self, client):
resp = client.get("/")
body = resp.text
assert "/shared/utils.js" in body
assert "/shared/toast.js" in body
assert "/shared/theme.js" in body
assert "/shared/auth.js" in body
assert "/shared/kb.js" in body
def test_shared_scripts_load_before_app_js(self, client):
"""Shared scripts must appear before page-specific app.js."""
body = client.get("/").text
shared_pos = body.find("/shared/utils.js")
app_pos = body.find("/static/app.js")
assert shared_pos < app_pos
class TestProxySharedStatic:
"""Tests for proxy rewriting of /shared/ paths."""
def test_html_rewriting_includes_shared_paths(self):
"""Verify proxy_index rewrites /shared/ paths like /static/ paths."""
sample_html = (
'<link rel="stylesheet" href="/shared/base.css">\n'
'<link rel="stylesheet" href="/static/style.css">\n'
'<script src="/shared/utils.js"></script>\n'
'<script src="/static/app.js"></script>'
)
prefix = "/node/test-node"
rewritten = sample_html.replace('href="/static/', f'href="{prefix}/static/')
rewritten = rewritten.replace('src="/static/', f'src="{prefix}/static/')
rewritten = rewritten.replace('href="/shared/', f'href="{prefix}/shared/')
rewritten = rewritten.replace('src="/shared/', f'src="{prefix}/shared/')
assert "/node/test-node/shared/base.css" in rewritten
assert "/node/test-node/shared/utils.js" in rewritten
assert "/node/test-node/static/style.css" in rewritten
assert "/node/test-node/static/app.js" in rewritten
assert 'href="/shared/' not in rewritten
assert 'src="/shared/' not in rewritten
def test_proxy_shim_injected_in_html(self):
"""Verify shim is injected as inline script in proxied HTML."""
import json
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE, _JS_PROXY_SHIM
sample_html = "<html><body><div>content</div></body></html>"
prefix = "/node/test-node"
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", "test-node")
shim = (
"<script>"
+ _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
+ "</script>"
)
result = sample_html.replace("<body>", "<body>" + banner + shim, 1)
assert "<script>" in result
assert "/node/test-node" in result
assert "window.fetch" in result
assert "window.EventSource" in result
def test_proxy_shared_static_unknown_node_returns_404(self):
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
collector = MagicMock(spec=ClusterCollector)
collector.get_overview.return_value = {
"nodes": 0,
"workstreams": 0,
"states": {},
"aggregate": {},
}
collector.get_node_detail.return_value = None
app = create_app(
collector=collector,
broker=MagicMock(),
auth_config=AuthConfig(),
)
client = TestClient(app, raise_server_exceptions=False)
resp = client.get("/node/unknown/shared/base.css")
assert resp.status_code == 404
client.close()
+10 -19
View File
@@ -1,38 +1,30 @@
"""Tests for turnstone.core.memory — database operations."""
import sqlalchemy as sa
from turnstone.core.memory import (
normalize_key,
open_db,
save_message,
search_history,
search_history_recent,
)
from turnstone.core.storage import get_storage
class TestOpenDb:
class TestSchemaCreation:
def test_creates_tables(self, tmp_db):
conn = open_db()
try:
# Check memories table exists
engine = get_storage()._engine # noqa: SLF001
with engine.connect() as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='memories'"
sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name='memories'")
).fetchall()
assert len(rows) == 1
# Check conversations table exists
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='conversations'"
sa.text(
"SELECT name FROM sqlite_master WHERE type='table' AND name='conversations'"
)
).fetchall()
assert len(rows) == 1
finally:
conn.close()
def test_idempotent_open(self, tmp_db):
# Opening twice should not raise
conn1 = open_db()
conn1.close()
conn2 = open_db()
conn2.close()
class TestSaveAndSearchHistory:
@@ -40,7 +32,6 @@ class TestSaveAndSearchHistory:
save_message("sess1", "user", "hello world test message")
results = search_history("hello")
assert len(results) >= 1
# Result tuple: (timestamp, session_id, role, content, tool_name)
found = any(r[3] == "hello world test message" for r in results)
assert found
+14 -16
View File
@@ -1,50 +1,48 @@
"""Tests for turnstone.core.memory — fts5_query and escape_like."""
"""Tests for SQLite FTS5 query building and LIKE escaping."""
from turnstone.core.memory import escape_like, fts5_query
from turnstone.core.storage._sqlite import _escape_like, _fts5_query
class TestFts5Query:
def test_single_word(self):
result = fts5_query("hello")
result = _fts5_query("hello")
assert result == '"hello"'
def test_multiple_words_joined_with_and(self):
result = fts5_query("hello world")
# Each word is quoted; space between = implicit AND
result = _fts5_query("hello world")
assert result == '"hello" "world"'
def test_special_chars_safely_quoted(self):
result = fts5_query("test*")
result = _fts5_query("test*")
assert result == '"test*"'
def test_dash_safely_quoted(self):
result = fts5_query("-negative")
result = _fts5_query("-negative")
assert result == '"-negative"'
def test_embedded_double_quotes(self):
# Double quotes inside a term are doubled per FTS5 convention
result = fts5_query('say"hello')
result = _fts5_query('say"hello')
assert result == '"say""hello"'
def test_empty_query(self):
assert fts5_query("") == ""
assert _fts5_query("") == ""
def test_whitespace_only(self):
assert fts5_query(" ") == ""
assert _fts5_query(" ") == ""
class TestEscapeLike:
def test_percent_escaped(self):
assert escape_like("100%") == "100\\%"
assert _escape_like("100%") == "100\\%"
def test_underscore_escaped(self):
assert escape_like("a_b") == "a\\_b"
assert _escape_like("a_b") == "a\\_b"
def test_backslash_escaped(self):
assert escape_like("a\\b") == "a\\\\b"
assert _escape_like("a\\b") == "a\\\\b"
def test_no_metacharacters(self):
assert escape_like("hello") == "hello"
assert _escape_like("hello") == "hello"
def test_combined(self):
assert escape_like("50%_off\\sale") == "50\\%\\_off\\\\sale"
assert _escape_like("50%_off\\sale") == "50\\%\\_off\\\\sale"
+48 -7
View File
@@ -91,7 +91,7 @@ class TestBackendHealthMonitor:
mon = _make_monitor(mock_client, failure_threshold=1, cooldown=9999.0)
mon.record_failure()
assert mon.circuit_state == CircuitState.OPEN
assert mon.should_allow_request is False
assert mon.acquire_request_permit() is False
@patch("turnstone.core.healthcheck.time")
def test_half_open_after_cooldown(
@@ -109,7 +109,7 @@ class TestBackendHealthMonitor:
# Advance past cooldown
mock_time.monotonic.return_value = t + 61.0
assert mon.should_allow_request is True
assert mon.acquire_request_permit() is True
assert mon.circuit_state == CircuitState.HALF_OPEN # type: ignore[comparison-overlap]
def test_success_resets(self, mock_client: MagicMock, mock_metrics: MagicMock) -> None:
@@ -126,18 +126,59 @@ class TestBackendHealthMonitor:
def test_should_allow_when_closed(self, mock_client: MagicMock) -> None:
mon = _make_monitor(mock_client)
assert mon.should_allow_request is True
assert mon.acquire_request_permit() is True
def test_should_allow_half_open(self, mock_client: MagicMock, mock_metrics: MagicMock) -> None:
"""HALF_OPEN state allows requests (one probe attempt)."""
def test_half_open_allows_only_one_request(
self, mock_client: MagicMock, mock_metrics: MagicMock
) -> None:
"""HALF_OPEN permits exactly one probe; subsequent callers are blocked."""
mon = _make_monitor(mock_client, failure_threshold=1)
mon.record_failure()
assert mon.circuit_state == CircuitState.OPEN
# Force into HALF_OPEN
# Force into HALF_OPEN with permit
with mon._lock:
mon._state = CircuitState.HALF_OPEN
assert mon.should_allow_request is True
mon._half_open_permit = True
# First caller gets through
assert mon.acquire_request_permit() is True
# Second caller is blocked
assert mon.acquire_request_permit() is False
# Third caller is also blocked
assert mon.acquire_request_permit() is False
def test_half_open_success_reopens_to_all(
self, mock_client: MagicMock, mock_metrics: MagicMock
) -> None:
"""After probe succeeds in HALF_OPEN, circuit closes and all requests pass."""
mon = _make_monitor(mock_client, failure_threshold=1)
mon.record_failure()
with mon._lock:
mon._state = CircuitState.HALF_OPEN
mon._half_open_permit = False # permit already consumed
# Probe succeeds
mon.record_success()
assert mon.circuit_state == CircuitState.CLOSED # type: ignore[comparison-overlap]
# All callers pass now
assert mon.acquire_request_permit() is True
assert mon.acquire_request_permit() is True
def test_half_open_failure_blocks_all(
self, mock_client: MagicMock, mock_metrics: MagicMock
) -> None:
"""After probe fails in HALF_OPEN, circuit reopens and all requests blocked."""
mon = _make_monitor(mock_client, failure_threshold=1, cooldown=9999.0)
mon.record_failure()
with mon._lock:
mon._state = CircuitState.HALF_OPEN
mon._half_open_permit = False
# Probe fails
mon.record_failure()
assert mon.circuit_state == CircuitState.OPEN
assert mon.acquire_request_permit() is False
def test_half_open_failure_reopens(
self, mock_client: MagicMock, mock_metrics: MagicMock
+6 -4
View File
@@ -243,11 +243,13 @@ class TestMCPClientManager:
class TestSessionIntegration:
@pytest.fixture()
def tmp_db(self, tmp_path, monkeypatch):
monkeypatch.setenv("TURNSTONE_DB_PATH", str(tmp_path / "test.db"))
from turnstone.core.memory import open_db
def tmp_db(self, tmp_path):
from turnstone.core.storage import init_storage, reset_storage
open_db()
reset_storage()
init_storage("sqlite", path=str(tmp_path / "test.db"), run_migrations=False)
yield
reset_storage()
def _make_session(self, mcp_client=None, **kwargs):
from turnstone.core.session import ChatSession
+2 -2
View File
@@ -457,7 +457,7 @@ class TestSessionFallback:
# _try_stream: first call (primary) raises, second call (fallback) succeeds
call_count = 0
def fake_try_stream(client: Any, model: str, msgs: Any) -> str:
def fake_try_stream(client: Any, model: str, msgs: Any, **kwargs: Any) -> str:
nonlocal call_count
call_count += 1
if call_count == 1:
@@ -473,7 +473,7 @@ class TestSessionFallback:
def test_no_fallback_without_registry(self) -> None:
session = _make_session()
def fake_try_stream(client: Any, model: str, msgs: Any) -> str:
def fake_try_stream(client: Any, model: str, msgs: Any, **kwargs: Any) -> str:
raise ConnectionError("Down")
session._try_stream = fake_try_stream # type: ignore[assignment]
+113
View File
@@ -0,0 +1,113 @@
"""Tests for OpenAPI spec generation."""
import json
class TestServerSpec:
"""Validate the generated server OpenAPI spec."""
def test_valid_openapi_version(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
assert spec["openapi"] == "3.1.0"
def test_has_info(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
assert "title" in spec["info"]
assert "version" in spec["info"]
def test_has_all_api_endpoints(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
paths = set(spec["paths"].keys())
expected = {
"/v1/api/workstreams",
"/v1/api/dashboard",
"/v1/api/sessions",
"/v1/api/send",
"/v1/api/approve",
"/v1/api/plan",
"/v1/api/command",
"/v1/api/events",
"/v1/api/events/global",
"/v1/api/workstreams/new",
"/v1/api/workstreams/close",
"/v1/api/auth/login",
"/v1/api/auth/logout",
"/health",
}
assert expected.issubset(paths), f"Missing: {expected - paths}"
def test_schemas_not_empty(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
assert len(spec["components"]["schemas"]) > 0
def test_json_serializable(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
result = json.dumps(spec)
assert len(result) > 100
def test_send_endpoint_has_request_body(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
send = spec["paths"]["/v1/api/send"]["post"]
assert "requestBody" in send
assert "application/json" in send["requestBody"]["content"]
def test_health_endpoint_not_versioned(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
assert "/health" in spec["paths"]
assert "/v1/health" not in spec["paths"]
class TestConsoleSpec:
"""Validate the generated console OpenAPI spec."""
def test_valid_openapi_version(self):
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
assert spec["openapi"] == "3.1.0"
def test_has_cluster_endpoints(self):
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
paths = set(spec["paths"].keys())
expected = {
"/v1/api/cluster/overview",
"/v1/api/cluster/nodes",
"/v1/api/cluster/workstreams",
"/v1/api/cluster/node/{node_id}",
"/v1/api/cluster/workstreams/new",
"/v1/api/cluster/events",
}
assert expected.issubset(paths), f"Missing: {expected - paths}"
def test_json_serializable(self):
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
result = json.dumps(spec)
assert len(result) > 100
def test_nodes_endpoint_has_query_params(self):
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
nodes = spec["paths"]["/v1/api/cluster/nodes"]["get"]
assert "parameters" in nodes
param_names = [p["name"] for p in nodes["parameters"]]
assert "sort" in param_names
assert "limit" in param_names
File diff suppressed because it is too large Load Diff
+109
View File
@@ -132,3 +132,112 @@ class TestRateLimiter:
# 10.0.0.2 last_refill=1060, age=0 < 3600 => kept
assert removed == 0
assert len(limiter._buckets) == 2
# ---------------------------------------------------------------------------
# resolve_client_ip / parse_trusted_proxies
# ---------------------------------------------------------------------------
class TestResolveClientIp:
"""X-Forwarded-For parsing with trusted proxy validation."""
def test_no_trusted_proxies_returns_direct(self):
from turnstone.core.ratelimit import resolve_client_ip
result = resolve_client_ip("192.168.1.1", "10.0.0.1", frozenset())
assert result == "192.168.1.1"
def test_no_xff_returns_direct(self):
from turnstone.core.ratelimit import parse_trusted_proxies, resolve_client_ip
trusted = parse_trusted_proxies("127.0.0.0/8")
result = resolve_client_ip("127.0.0.1", "", trusted)
assert result == "127.0.0.1"
def test_trusted_proxy_extracts_xff(self):
from turnstone.core.ratelimit import parse_trusted_proxies, resolve_client_ip
trusted = parse_trusted_proxies("127.0.0.1/32")
result = resolve_client_ip("127.0.0.1", "1.2.3.4", trusted)
assert result == "1.2.3.4"
def test_untrusted_direct_ignores_xff(self):
"""If the direct client is not a trusted proxy, XFF is ignored (anti-spoof)."""
from turnstone.core.ratelimit import parse_trusted_proxies, resolve_client_ip
trusted = parse_trusted_proxies("10.0.0.0/8")
result = resolve_client_ip("203.0.113.5", "1.2.3.4", trusted)
assert result == "203.0.113.5"
def test_chained_proxies(self):
"""XFF: 'client, proxy1, proxy2' with proxy1+proxy2 trusted → returns client."""
from turnstone.core.ratelimit import parse_trusted_proxies, resolve_client_ip
trusted = parse_trusted_proxies("10.0.0.0/8")
result = resolve_client_ip("10.0.0.3", "1.2.3.4, 10.0.0.1, 10.0.0.2", trusted)
assert result == "1.2.3.4"
def test_all_trusted_returns_direct(self):
"""If all XFF entries are trusted proxies, fall back to direct IP."""
from turnstone.core.ratelimit import parse_trusted_proxies, resolve_client_ip
trusted = parse_trusted_proxies("10.0.0.0/8")
result = resolve_client_ip("10.0.0.3", "10.0.0.1, 10.0.0.2", trusted)
assert result == "10.0.0.3"
def test_invalid_direct_ip_returns_direct(self):
from turnstone.core.ratelimit import parse_trusted_proxies, resolve_client_ip
trusted = parse_trusted_proxies("10.0.0.0/8")
result = resolve_client_ip("not-an-ip", "1.2.3.4", trusted)
assert result == "not-an-ip"
def test_invalid_xff_entry_skipped(self):
from turnstone.core.ratelimit import parse_trusted_proxies, resolve_client_ip
trusted = parse_trusted_proxies("10.0.0.0/8")
result = resolve_client_ip("10.0.0.1", "garbage, 1.2.3.4", trusted)
assert result == "1.2.3.4"
def test_ipv6_trusted_proxy(self):
from turnstone.core.ratelimit import parse_trusted_proxies, resolve_client_ip
trusted = parse_trusted_proxies("::1/128")
result = resolve_client_ip("::1", "2001:db8::1", trusted)
assert result == "2001:db8::1"
class TestParseTrustedProxies:
def test_empty_string(self):
from turnstone.core.ratelimit import parse_trusted_proxies
assert parse_trusted_proxies("") == frozenset()
def test_single_cidr(self):
from turnstone.core.ratelimit import parse_trusted_proxies
result = parse_trusted_proxies("10.0.0.0/8")
assert len(result) == 1
def test_multiple_cidrs(self):
from turnstone.core.ratelimit import parse_trusted_proxies
result = parse_trusted_proxies("10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16")
assert len(result) == 3
def test_single_ip_becomes_host_network(self):
from turnstone.core.ratelimit import parse_trusted_proxies
result = parse_trusted_proxies("127.0.0.1")
assert len(result) == 1
def test_invalid_entry_skipped(self):
from turnstone.core.ratelimit import parse_trusted_proxies
result = parse_trusted_proxies("10.0.0.0/8, not-valid, 172.16.0.0/12")
assert len(result) == 2
def test_constructor_parses_trusted_proxies(self):
limiter = RateLimiter(enabled=True, rate=10.0, burst=5, trusted_proxies="10.0.0.0/8")
assert len(limiter.trusted_proxies) == 1
+242
View File
@@ -0,0 +1,242 @@
"""Tests for turnstone.sdk.console — console client with mocked HTTP transport."""
from __future__ import annotations
import httpx
import pytest
from turnstone.sdk._types import TurnstoneAPIError
from turnstone.sdk.console import AsyncTurnstoneConsole
def _json_response(data: dict, status: int = 200) -> httpx.Response:
return httpx.Response(status, json=data)
def _mock_transport(
responses: dict[str, httpx.Response] | None = None,
) -> httpx.MockTransport:
table = responses or {}
def handler(request: httpx.Request) -> httpx.Response:
key = f"{request.method} {request.url.path}"
if key in table:
return table[key]
return httpx.Response(404, json={"error": "not found"})
return httpx.MockTransport(handler)
# ---------------------------------------------------------------------------
# Cluster overview
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_overview():
transport = _mock_transport(
{
"GET /v1/api/cluster/overview": _json_response(
{
"nodes": 2,
"workstreams": 5,
"states": {"running": 1, "idle": 4},
"aggregate": {"total_tokens": 1000, "total_tool_calls": 20},
"version_drift": False,
"versions": ["0.3.0"],
}
)
}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneConsole(httpx_client=hc)
resp = await client.overview()
assert resp.nodes == 2
assert resp.workstreams == 5
@pytest.mark.anyio
async def test_nodes():
transport = _mock_transport(
{
"GET /v1/api/cluster/nodes": _json_response(
{
"nodes": [
{
"node_id": "n1",
"server_url": "http://localhost:8080",
"ws_total": 3,
"ws_running": 1,
"ws_thinking": 0,
"ws_attention": 0,
"ws_idle": 2,
"ws_error": 0,
"total_tokens": 500,
"started": 1700000000.0,
"reachable": True,
"health": {"status": "ok"},
"version": "0.3.0",
}
],
"total": 1,
}
)
}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneConsole(httpx_client=hc)
resp = await client.nodes(sort="tokens", limit=50)
assert resp.total == 1
assert resp.nodes[0].node_id == "n1"
@pytest.mark.anyio
async def test_workstreams():
transport = _mock_transport(
{
"GET /v1/api/cluster/workstreams": _json_response(
{
"workstreams": [
{
"id": "ws1",
"name": "test",
"state": "running",
"node": "n1",
}
],
"total": 1,
"page": 1,
"per_page": 50,
"pages": 1,
}
)
}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneConsole(httpx_client=hc)
resp = await client.workstreams(state="running", page=1)
assert resp.total == 1
@pytest.mark.anyio
async def test_node_detail():
transport = _mock_transport(
{
"GET /v1/api/cluster/node/n1": _json_response(
{
"node_id": "n1",
"server_url": "http://localhost:8080",
"health": {"status": "ok"},
"workstreams": [],
"aggregate": {"total_tokens": 0, "total_tool_calls": 0},
}
)
}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneConsole(httpx_client=hc)
resp = await client.node_detail("n1")
assert resp.node_id == "n1"
@pytest.mark.anyio
async def test_create_workstream():
transport = _mock_transport(
{
"POST /v1/api/cluster/workstreams/new": _json_response(
{"status": "dispatched", "correlation_id": "abc123", "target_node": "n1"}
)
}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneConsole(httpx_client=hc)
resp = await client.create_workstream(node_id="n1", name="test")
assert resp.correlation_id == "abc123"
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_login():
transport = _mock_transport(
{"POST /v1/api/auth/login": _json_response({"status": "ok", "role": "read"})}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneConsole(httpx_client=hc)
resp = await client.login("tok_test")
assert resp.role == "read"
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_health():
transport = _mock_transport(
{
"GET /health": _json_response(
{
"status": "ok",
"service": "turnstone-console",
"nodes": 2,
"workstreams": 5,
"version_drift": False,
"versions": ["0.3.0"],
}
)
}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneConsole(httpx_client=hc)
resp = await client.health()
assert resp.status == "ok"
assert resp.nodes == 2
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_node_not_found():
transport = _mock_transport(
{"GET /v1/api/cluster/node/bad": httpx.Response(404, json={"error": "Node not found"})}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneConsole(httpx_client=hc)
with pytest.raises(TurnstoneAPIError) as exc_info:
await client.node_detail("bad")
assert exc_info.value.status_code == 404
@pytest.mark.anyio
async def test_query_params_passed():
"""Verify query params are sent correctly for paginated endpoints."""
captured_url: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_url.append(str(request.url))
return httpx.Response(
200,
json={
"workstreams": [],
"total": 0,
"page": 2,
"per_page": 25,
"pages": 0,
},
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneConsole(httpx_client=hc)
await client.workstreams(state="running", page=2, per_page=25)
assert "state=running" in captured_url[0]
assert "page=2" in captured_url[0]
assert "per_page=25" in captured_url[0]
+287
View File
@@ -0,0 +1,287 @@
"""Tests for turnstone.sdk.events — SSE event deserialization."""
from turnstone.sdk.events import (
ApproveRequestEvent,
BusyErrorEvent,
ClearUiEvent,
ClusterEvent,
ClusterStateEvent,
ClusterWsClosedEvent,
ClusterWsCreatedEvent,
ClusterWsRenameEvent,
ConnectedEvent,
ContentEvent,
ErrorEvent,
HistoryEvent,
InfoEvent,
NodeJoinedEvent,
NodeLostEvent,
PlanReviewEvent,
ReasoningEvent,
ServerEvent,
StatusEvent,
StreamEndEvent,
ThinkingStartEvent,
ThinkingStopEvent,
ToolInfoEvent,
ToolOutputChunkEvent,
ToolResultEvent,
WsActivityEvent,
WsClosedEvent,
WsRenameEvent,
WsStateEvent,
)
# ---------------------------------------------------------------------------
# Per-workstream events
# ---------------------------------------------------------------------------
def test_connected_event():
e = ServerEvent.from_dict(
{"type": "connected", "model": "gpt-5", "model_alias": "fast", "skip_permissions": True}
)
assert isinstance(e, ConnectedEvent)
assert e.model == "gpt-5"
assert e.model_alias == "fast"
assert e.skip_permissions is True
def test_history_event():
msgs = [{"role": "user", "content": "hi"}]
e = ServerEvent.from_dict({"type": "history", "messages": msgs})
assert isinstance(e, HistoryEvent)
assert e.messages == msgs
def test_thinking_start_stop():
e1 = ServerEvent.from_dict({"type": "thinking_start"})
e2 = ServerEvent.from_dict({"type": "thinking_stop"})
assert isinstance(e1, ThinkingStartEvent)
assert isinstance(e2, ThinkingStopEvent)
def test_content_event():
e = ServerEvent.from_dict({"type": "content", "text": "hello"})
assert isinstance(e, ContentEvent)
assert e.text == "hello"
def test_reasoning_event():
e = ServerEvent.from_dict({"type": "reasoning", "text": "step 1"})
assert isinstance(e, ReasoningEvent)
assert e.text == "step 1"
def test_stream_end_event():
e = ServerEvent.from_dict({"type": "stream_end"})
assert isinstance(e, StreamEndEvent)
def test_tool_info_event():
items = [{"name": "search", "call_id": "c1"}]
e = ServerEvent.from_dict({"type": "tool_info", "items": items})
assert isinstance(e, ToolInfoEvent)
assert e.items == items
def test_approve_request_event():
items = [{"name": "bash", "call_id": "c2", "arguments": "ls"}]
e = ServerEvent.from_dict({"type": "approve_request", "items": items})
assert isinstance(e, ApproveRequestEvent)
assert len(e.items) == 1
def test_tool_result_event():
e = ServerEvent.from_dict(
{"type": "tool_result", "call_id": "c1", "name": "search", "output": "found it"}
)
assert isinstance(e, ToolResultEvent)
assert e.call_id == "c1"
assert e.name == "search"
assert e.output == "found it"
def test_tool_output_chunk_event():
e = ServerEvent.from_dict({"type": "tool_output_chunk", "call_id": "c1", "chunk": "line1\n"})
assert isinstance(e, ToolOutputChunkEvent)
assert e.chunk == "line1\n"
def test_status_event():
e = ServerEvent.from_dict(
{
"type": "status",
"prompt_tokens": 100,
"completion_tokens": 50,
"total_tokens": 150,
"context_window": 128000,
"pct": 0.12,
"effort": "medium",
}
)
assert isinstance(e, StatusEvent)
assert e.prompt_tokens == 100
assert e.total_tokens == 150
assert e.pct == 0.12
assert e.effort == "medium"
def test_plan_review_event():
e = ServerEvent.from_dict({"type": "plan_review", "content": "## Plan\n1. Do X"})
assert isinstance(e, PlanReviewEvent)
assert "Plan" in e.content
def test_info_event():
e = ServerEvent.from_dict({"type": "info", "message": "[compacted]"})
assert isinstance(e, InfoEvent)
assert e.message == "[compacted]"
def test_error_event():
e = ServerEvent.from_dict({"type": "error", "message": "Something broke"})
assert isinstance(e, ErrorEvent)
assert e.message == "Something broke"
def test_busy_error_event():
e = ServerEvent.from_dict({"type": "busy_error", "message": "Already processing a request."})
assert isinstance(e, BusyErrorEvent)
assert "Already" in e.message
def test_clear_ui_event():
e = ServerEvent.from_dict({"type": "clear_ui"})
assert isinstance(e, ClearUiEvent)
# ---------------------------------------------------------------------------
# Global events
# ---------------------------------------------------------------------------
def test_ws_state_event():
e = ServerEvent.from_dict(
{
"type": "ws_state",
"ws_id": "ws1",
"state": "thinking",
"tokens": 500,
"context_ratio": 0.3,
"activity": "Writing code",
"activity_state": "thinking",
}
)
assert isinstance(e, WsStateEvent)
assert e.ws_id == "ws1"
assert e.state == "thinking"
assert e.tokens == 500
def test_ws_activity_event():
e = ServerEvent.from_dict(
{"type": "ws_activity", "ws_id": "ws1", "activity": "reading", "activity_state": "tool"}
)
assert isinstance(e, WsActivityEvent)
assert e.activity == "reading"
def test_ws_rename_event():
e = ServerEvent.from_dict({"type": "ws_rename", "ws_id": "ws1", "name": "My Chat"})
assert isinstance(e, WsRenameEvent)
assert e.name == "My Chat"
def test_ws_closed_event():
e = ServerEvent.from_dict({"type": "ws_closed", "ws_id": "ws1", "name": "old"})
assert isinstance(e, WsClosedEvent)
assert e.name == "old"
# ---------------------------------------------------------------------------
# Cluster events
# ---------------------------------------------------------------------------
def test_node_joined_event():
e = ClusterEvent.from_dict({"type": "node_joined", "node_id": "host1_abc"})
assert isinstance(e, NodeJoinedEvent)
assert e.node_id == "host1_abc"
def test_node_lost_event():
e = ClusterEvent.from_dict({"type": "node_lost", "node_id": "host2_def"})
assert isinstance(e, NodeLostEvent)
assert e.node_id == "host2_def"
def test_cluster_state_event():
e = ClusterEvent.from_dict(
{
"type": "cluster_state",
"ws_id": "ws1",
"node_id": "n1",
"state": "running",
"tokens": 1000,
"context_ratio": 0.5,
"activity": "executing tool",
"activity_state": "tool",
}
)
assert isinstance(e, ClusterStateEvent)
assert e.node_id == "n1"
assert e.state == "running"
assert e.tokens == 1000
def test_cluster_ws_created_event():
e = ClusterEvent.from_dict(
{"type": "ws_created", "ws_id": "ws2", "node_id": "n1", "name": "New WS"}
)
assert isinstance(e, ClusterWsCreatedEvent)
assert e.ws_id == "ws2"
assert e.name == "New WS"
def test_cluster_ws_closed_event():
e = ClusterEvent.from_dict({"type": "ws_closed", "ws_id": "ws2"})
assert isinstance(e, ClusterWsClosedEvent)
assert e.ws_id == "ws2"
def test_cluster_ws_rename_event():
e = ClusterEvent.from_dict({"type": "ws_rename", "ws_id": "ws2", "name": "Renamed"})
assert isinstance(e, ClusterWsRenameEvent)
assert e.name == "Renamed"
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
def test_unknown_server_event_falls_back():
e = ServerEvent.from_dict({"type": "future_event", "ws_id": "ws1"})
assert type(e) is ServerEvent
assert e.type == "future_event"
assert e.ws_id == "ws1"
def test_unknown_cluster_event_falls_back():
e = ClusterEvent.from_dict({"type": "future_cluster_event"})
assert type(e) is ClusterEvent
assert e.type == "future_cluster_event"
def test_extra_fields_ignored():
e = ServerEvent.from_dict({"type": "content", "text": "hi", "extra_field": 999})
assert isinstance(e, ContentEvent)
assert e.text == "hi"
def test_missing_type_defaults_to_base():
e = ServerEvent.from_dict({"ws_id": "ws1"})
assert type(e) is ServerEvent
assert e.ws_id == "ws1"
assert e.type == ""
+281
View File
@@ -0,0 +1,281 @@
"""Tests for turnstone.sdk.server — server client with mocked HTTP transport."""
from __future__ import annotations
import json
import httpx
import pytest
from turnstone.sdk._types import TurnstoneAPIError
from turnstone.sdk.server import AsyncTurnstoneServer
def _mock_transport(
responses: dict[str, httpx.Response] | None = None,
) -> httpx.MockTransport:
"""Create a mock transport that routes by method+path."""
table = responses or {}
def handler(request: httpx.Request) -> httpx.Response:
key = f"{request.method} {request.url.path}"
if key in table:
return table[key]
return httpx.Response(404, json={"error": "not found"})
return httpx.MockTransport(handler)
def _json_response(data: dict, status: int = 200) -> httpx.Response:
return httpx.Response(status, json=data)
# ---------------------------------------------------------------------------
# Workstream management
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_list_workstreams():
transport = _mock_transport(
{
"GET /v1/api/workstreams": _json_response(
{"workstreams": [{"id": "ws1", "name": "test", "state": "idle"}]}
)
}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.list_workstreams()
assert len(resp.workstreams) == 1
assert resp.workstreams[0].id == "ws1"
@pytest.mark.anyio
async def test_dashboard():
transport = _mock_transport(
{
"GET /v1/api/dashboard": _json_response(
{
"workstreams": [
{
"id": "ws1",
"name": "demo",
"state": "idle",
"tokens": 100,
"context_ratio": 0.1,
}
],
"aggregate": {
"total_tokens": 100,
"total_tool_calls": 5,
"active_count": 1,
"total_count": 1,
},
}
)
}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.dashboard()
assert resp.aggregate.total_tokens == 100
assert len(resp.workstreams) == 1
@pytest.mark.anyio
async def test_create_workstream():
transport = _mock_transport(
{"POST /v1/api/workstreams/new": _json_response({"ws_id": "ws_new", "name": "Analysis"})}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.create_workstream(name="Analysis")
assert resp.ws_id == "ws_new"
assert resp.name == "Analysis"
@pytest.mark.anyio
async def test_close_workstream():
transport = _mock_transport(
{"POST /v1/api/workstreams/close": _json_response({"status": "ok"})}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.close_workstream("ws1")
assert resp.status == "ok"
# ---------------------------------------------------------------------------
# Chat interaction
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_send():
transport = _mock_transport({"POST /v1/api/send": _json_response({"status": "ok"})})
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.send("Hello", "ws1")
assert resp.status == "ok"
@pytest.mark.anyio
async def test_approve():
transport = _mock_transport({"POST /v1/api/approve": _json_response({"status": "ok"})})
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.approve(ws_id="ws1", approved=True, feedback="looks good")
assert resp.status == "ok"
@pytest.mark.anyio
async def test_plan_feedback():
transport = _mock_transport({"POST /v1/api/plan": _json_response({"status": "ok"})})
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.plan_feedback(ws_id="ws1", feedback="approved")
assert resp.status == "ok"
@pytest.mark.anyio
async def test_command():
transport = _mock_transport({"POST /v1/api/command": _json_response({"status": "ok"})})
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.command(ws_id="ws1", command="/clear")
assert resp.status == "ok"
# ---------------------------------------------------------------------------
# Sessions
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_list_sessions():
transport = _mock_transport(
{
"GET /v1/api/sessions": _json_response(
{
"sessions": [
{
"session_id": "s1",
"title": "test",
"created": "2024-01-01",
"updated": "2024-01-02",
"message_count": 5,
}
]
}
)
}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.list_sessions()
assert len(resp.sessions) == 1
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_login():
transport = _mock_transport(
{"POST /v1/api/auth/login": _json_response({"status": "ok", "role": "full"})}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.login("test_token")
assert resp.role == "full"
@pytest.mark.anyio
async def test_logout():
transport = _mock_transport({"POST /v1/api/auth/logout": _json_response({"status": "ok"})})
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.logout()
assert resp.status == "ok"
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_health():
transport = _mock_transport(
{
"GET /health": _json_response(
{
"status": "ok",
"version": "0.3.0",
"uptime_seconds": 120.0,
"model": "gpt-5",
"workstreams": {"total": 1, "idle": 1},
}
)
}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
resp = await client.health()
assert resp.status == "ok"
assert resp.version == "0.3.0"
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_api_error_raised():
transport = _mock_transport(
{"POST /v1/api/send": httpx.Response(404, json={"error": "Unknown workstream"})}
)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
with pytest.raises(TurnstoneAPIError) as exc_info:
await client.send("hi", "bad_ws")
assert exc_info.value.status_code == 404
assert "Unknown workstream" in exc_info.value.message
@pytest.mark.anyio
async def test_auth_header_injected():
"""Verify the Authorization header is set when a token is provided."""
captured_headers: dict[str, str] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured_headers.update(dict(request.headers))
return httpx.Response(200, json={"workstreams": []})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
# Manually set auth header since we're injecting the client
hc.headers["Authorization"] = "Bearer tok_test"
client = AsyncTurnstoneServer(httpx_client=hc)
await client.list_workstreams()
assert captured_headers.get("authorization") == "Bearer tok_test"
@pytest.mark.anyio
async def test_request_body_correct():
"""Verify POST requests send the correct JSON body."""
captured_body: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured_body.update(json.loads(request.content))
return httpx.Response(200, json={"status": "ok"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
await client.send("Hello world", "ws_123")
assert captured_body == {"message": "Hello world", "ws_id": "ws_123"}

Some files were not shown because too many files have changed in this diff Show More