mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 23:42:25 -06:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 498f23c19e | |||
| e057c364b8 | |||
| e785a94539 | |||
| 2b58c127b1 | |||
| 5ee539c983 | |||
| 62a4ceac96 | |||
| 29c00c0cdf | |||
| b6e0f0fcca | |||
| 206e37e73e | |||
| 6c5441435b | |||
| f02972c11d | |||
| d28879208f | |||
| a1f00092f5 | |||
| bb3b735d69 | |||
| 87ffad18c2 |
@@ -12,3 +12,12 @@ venv/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.hypothesis/
|
||||
deploy/
|
||||
docs/
|
||||
tests/
|
||||
sdk/
|
||||
*.md
|
||||
!README.md
|
||||
!LICENSE
|
||||
.coverage
|
||||
.swp
|
||||
|
||||
+20
-77
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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:-}
|
||||
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 = ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = {}
|
||||
}
|
||||
Executable
+5
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d5a2bd1c55ac8cf3b777a8decb6f3bb3d063c10c8f3a9e63457079830e48f456
|
||||
size 162310
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:09cee5819bb7820641a466ed29a53f1b479bc2ded83079fc798c7410bf741a62
|
||||
size 329703
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fa1c94a0a7489cb9e6e6cad17f7e3577c458ec89d2a47fb2669fe935768837cf
|
||||
size 276863
|
||||
oid sha256:fca0957b54101ce5c2b2e06d639b04dc5fff733f2641af0d732c49ea86883882
|
||||
size 279397
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) ==
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:341a8ab1483b1e0146878bd384a11d56bc78d29262de8262d06ef924317e2762
|
||||
size 139969
|
||||
oid sha256:9a1b0361c466327d0011a488847ea3c0365983713537d4a7c27cd7f5538ba33c
|
||||
size 164829
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:29534422fc31eee613f70a479aa14de5278b98c49bb75fce7a63b72e248f1149
|
||||
size 323269
|
||||
oid sha256:7d75da92a657525bcbb7a425dc6c8d3cafe074c3ac3bff3cf4b1d44aea607b50
|
||||
size 330156
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f85f24081d32318e079d26a4855ebb6e66df349ca7c7c703db50788af528426b
|
||||
size 376282
|
||||
oid sha256:637458e0d78df82752746e519cd7300a830c8ce211f21625694ad0c162ca316d
|
||||
size 481637
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a90dec1546dd0f8343e3e27cf6f235d95f3ffc375928d34d0df5d8f25d63e5ad
|
||||
size 269901
|
||||
oid sha256:dc3b64c9e48153641af62ed43fbc1d89a31d1a8a61e7e71cfc550c805000310d
|
||||
size 288290
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cb36b4924394cf54d6aaef454cced317b72e336e580cc6ac25cd4b9d0917bec5
|
||||
size 243422
|
||||
oid sha256:b842683d238664a3e35d04358fecfc56cefd013f7dca5f13357b0376f881e1b3
|
||||
size 245043
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e660f453a3708d1a7d827f07cd967f122500eb1c4845f5a75cc1309091bce6af
|
||||
size 185796
|
||||
oid sha256:b22d5980fe5cc4b8466ba0797113dc8fa83fab8df24b5dacceaf97e62e2e25b0
|
||||
size 187649
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8cc7c94d5ac4862c3c09450346f0af923818e02ea0550cc8023f039fdc701179
|
||||
size 221528
|
||||
oid sha256:90e4f74be795b530e711faa87bc6eb2b3bf6abb68d8fac8ebff7aaf30c6fbe53
|
||||
size 222032
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:793d7c2b28a751c6f467f2de788fcd462d3b8fd9cd5cb7adb5b32d78fb185394
|
||||
size 236004
|
||||
oid sha256:97e7210cd8f1ad195f4d5e25e778d82df3c08c5c6e0f09722e84a7a453714867
|
||||
size 411664
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -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
Generated
+1346
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
+6
-6
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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 == ""
|
||||
@@ -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
Reference in New Issue
Block a user