mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83577739e0 | |||
| 71d13936fe | |||
| 0cd061196c | |||
| 19abc0cc65 | |||
| c5cdfc8f44 | |||
| 8895bf07eb | |||
| 101afd84da | |||
| efd98712e9 | |||
| 67f43a7ee0 | |||
| 2888e8ce0a | |||
| d1a248b413 | |||
| 723cad24bb | |||
| 73cacc8ad6 | |||
| ccd1c1a9ad | |||
| 1295919613 | |||
| 09ea3d164d | |||
| 02d9c5c797 | |||
| f1f448277f | |||
| 2f7f70825b | |||
| 4866c9873c | |||
| 8b2e2130fc | |||
| f81c06761d | |||
| be165c1971 | |||
| 3264fdefca | |||
| 28cb3a5c51 | |||
| 8b11e0a6f9 | |||
| 648ba477e1 | |||
| 7960784786 | |||
| e06554d1ec | |||
| 8eb8722346 | |||
| a2e2ffacd8 | |||
| c6ba8d59b0 | |||
| 087f5b49f6 | |||
| fd507c6a3c | |||
| 562c3c8ab7 | |||
| 4773535bb8 | |||
| 7492816ab2 | |||
| d6ba1d5e25 | |||
| 41d1b27d34 | |||
| 8bc284c60e | |||
| a322d6b1d1 | |||
| 70d495aa5b | |||
| de64535221 | |||
| 187d004033 | |||
| 7ea150fa71 | |||
| 4d665a5f62 | |||
| 3bc3250869 | |||
| db937486cf | |||
| 554257ac4d | |||
| 5f0004dc91 | |||
| 6cc1b3a5bd | |||
| cc9afe94cd | |||
| 136b75fdef | |||
| 4d1107839b | |||
| 7d66bc2159 | |||
| 165cbb2d29 | |||
| c79c47b940 | |||
| 660c273e8e | |||
| c7586abd0a | |||
| 14d57176ce | |||
| 96084ca5f3 | |||
| 8b92302247 | |||
| e195ca54a6 | |||
| 50277cd4de | |||
| fb190f8977 | |||
| 25b5e32089 | |||
| 339981a258 | |||
| 06de9ff83b | |||
| 924b976f1f | |||
| d5db817391 | |||
| fc8ceb4c72 | |||
| 07234dec4d | |||
| dd4cc0b30d | |||
| e7fe8fca9d | |||
| 42b9f89988 | |||
| 77c0a7736b | |||
| 872e1770e6 | |||
| a6e929b0a0 | |||
| 047680d669 | |||
| 0fd0ad3b2d | |||
| f3dba836dd | |||
| 7adda343fc | |||
| a20a058c59 |
@@ -22,6 +22,7 @@ OPENAI_API_KEY=sk-...
|
||||
# -- Authentication ------------------------------------------------------------
|
||||
# TURNSTONE_AUTH_ENABLED=true
|
||||
# TURNSTONE_AUTH_TOKEN=your-secret-token
|
||||
# TURNSTONE_JWT_SECRET=python -c "import secrets; print(secrets.token_hex(32))"
|
||||
|
||||
# -- Ports ---------------------------------------------------------------------
|
||||
# SERVER_PORT=8080
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
@@ -19,3 +20,10 @@ jobs:
|
||||
- run: pip install build
|
||||
- run: python -m build
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref, '-') }}
|
||||
|
||||
@@ -17,3 +17,5 @@ venv/
|
||||
.plan.md
|
||||
.plan-*.md
|
||||
.hypothesis/
|
||||
PROGRESS.md
|
||||
.coverage
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ RUN useradd --create-home --shell /bin/bash turnstone
|
||||
|
||||
# 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,postgres]" \
|
||||
RUN pip install --no-cache-dir "$(ls /tmp/wheels/*.whl)[mq,console,sim,postgres,discord]" \
|
||||
&& rm -rf /tmp/wheels
|
||||
|
||||
# Health check script (stdlib only, no pip deps needed)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Bootstrap Wizard
|
||||
|
||||
Interactive, AI-guided setup for Turnstone deployments. Instead of manually
|
||||
editing `.env` files and reading deployment docs, the wizard walks you through
|
||||
every decision conversationally and generates all the config files for you.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
turnstone-bootstrap
|
||||
```
|
||||
|
||||
That's it — no flags, no arguments. The wizard prompts for everything.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Pick a model** — Choose OpenAI, Anthropic, or a local/vLLM endpoint to
|
||||
power the wizard. Local endpoints auto-detect available models.
|
||||
2. **Answer questions** — The AI walks you through deployment mode, LLM
|
||||
provider, database, authentication, ports, and optional features.
|
||||
3. **Review generated files** — Each file is previewed before writing. You
|
||||
confirm or reject every write.
|
||||
4. **Start the stack** — The wizard prints the exact `docker compose` command
|
||||
and a `setup.sh` script to create your first admin user, roles, and policies.
|
||||
|
||||
## What Gets Generated
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `.env` | All environment variables for `compose.yaml` |
|
||||
| `setup.sh` | Post-start script: creates admin user, roles, tool policies, prompt templates via the API |
|
||||
| `docker-compose.override.yaml` | Only if customizations beyond env vars are needed |
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python 3.11+** with turnstone installed (`pip install turnstone`)
|
||||
- **An LLM API key** — for the wizard itself (OpenAI, Anthropic, or a local
|
||||
model). This can differ from the LLM your deployment will use.
|
||||
- **Docker & Docker Compose** — needed to run the stack. The wizard detects
|
||||
whether Docker is installed and gives platform-specific install instructions
|
||||
if it's missing. You can still generate config files without Docker.
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
The wizard supports two deployment modes:
|
||||
|
||||
- **Single-node production** (`docker compose --profile production up`) —
|
||||
1 server + bridge + console + PostgreSQL + Redis. Good for most use cases.
|
||||
- **Multi-node cluster** (`docker compose --profile cluster up`) —
|
||||
10-node server/bridge fleet + PostgreSQL + Redis. For high-throughput or
|
||||
HA deployments.
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
$ turnstone-bootstrap
|
||||
|
||||
Turnstone Bootstrap Wizard v0.5.4
|
||||
────────────────────────────────────────────────
|
||||
|
||||
Which provider for this wizard?
|
||||
[1] OpenAI
|
||||
[2] Anthropic
|
||||
[3] OpenAI-compatible (local/vLLM)
|
||||
|
||||
> 3
|
||||
|
||||
Base URL [http://localhost:8000/v1]:
|
||||
API key (press Enter for 'none'):
|
||||
|
||||
Querying http://localhost:8000/v1 for available models...
|
||||
Found model: Qwen/Qwen3-32B
|
||||
|
||||
Connected to Qwen/Qwen3-32B. Handing off to AI assistant...
|
||||
|
||||
> (AI walks you through the rest interactively)
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- **Re-run safely** — running the wizard again detects your existing `.env`
|
||||
and offers to update it rather than overwriting.
|
||||
- **Duplicate writes are skipped** — if the LLM tries to write the same file
|
||||
twice with identical content, it's silently ignored.
|
||||
- **Type `quit` to exit** at any time during the conversation.
|
||||
- **Ctrl+C** is handled gracefully — press once to interrupt, twice to exit.
|
||||
|
||||
## See Also
|
||||
|
||||
- [Docker Deployment](docker.md) — manual compose setup and profiles
|
||||
- [Security](security.md) — auth architecture and token types
|
||||
- [Governance](governance.md) — roles, policies, and templates
|
||||
@@ -16,16 +16,16 @@ 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 and workstreams, workstream creation with node targeting, reverse proxy for server UIs (only the console port needs network access)
|
||||
- **Cluster dashboard** — real-time view of all nodes and workstreams, reverse proxy for server UIs
|
||||
- **Intent validation** — an LLM judge evaluates every tool call before approval, presenting risk assessments and evidence-based recommendations so users can make informed decisions instead of blindly approving raw tool calls
|
||||
- **Governance & compliance** — RBAC, tool policies, prompt templates, workstream templates, usage tracking, and append-only audit logs
|
||||
- **Cluster simulator** — test the stack at scale (up to 1000 nodes) without an LLM backend
|
||||
|
||||
```
|
||||
External System → Message Queue → Bridge (per node) → Turnstone Server → LLM + Tools
|
||||
↓
|
||||
Pub/Sub → Progress Events → External System
|
||||
↓
|
||||
turnstone-console → Cluster Dashboard (browser)
|
||||
```
|
||||
Works with any OpenAI-compatible API (vLLM, llama.cpp, NVIDIA NIM) or Anthropic's native Messages API. Supports [MCP](https://modelcontextprotocol.io/) for external tool servers with native deferred tool loading on Anthropic and OpenAI APIs (BM25 fallback for local models).
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/architecture-overview.svg" alt="Turnstone system architecture — data flow from clients through gateways, Redis MQ, cluster nodes, to LLM providers" width="960"/>
|
||||
</p>
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -107,73 +107,9 @@ 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.) or Anthropic's native Messages API, and auto-detect the model.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
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 # 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
|
||||
│ ├── edit.py # File editing (fuzzy match, indentation)
|
||||
│ ├── safety.py # Path validation, sandbox checks
|
||||
│ ├── sandbox.py # Command sandboxing
|
||||
│ └── web.py # Web fetch/search helpers
|
||||
├── mq/ # Message queue integration
|
||||
│ ├── protocol.py # Typed message dataclasses (JSON serialization)
|
||||
│ ├── broker.py # Abstract MessageBroker + RedisBroker
|
||||
│ ├── bridge.py # Bridge service (queue ↔ HTTP API, multi-node routing)
|
||||
│ └── client.py # TurnstoneClient — Python API for external systems
|
||||
├── console/ # Cluster dashboard
|
||||
│ ├── collector.py # ClusterCollector — aggregates all nodes via Redis + HTTP
|
||||
│ ├── 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
|
||||
│ └── static/ # Web UI (HTML, CSS, JS)
|
||||
├── sim/ # Cluster simulator
|
||||
│ ├── cluster.py # SimCluster — orchestrates N nodes + dispatchers
|
||||
│ ├── node.py # SimNode + SimWorkstream — protocol-compatible node
|
||||
│ ├── engine.py # LLM + tool execution simulation
|
||||
│ ├── scenario.py # 5 workload scenarios (steady, burst, node_failure, …)
|
||||
│ ├── 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 (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
|
||||
├── tools.md # Tool schemas, execution pipeline, approval flow
|
||||
├── 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
|
||||
### Diagrams
|
||||
|
||||
Detailed UML diagrams are available in [`docs/diagrams/`](docs/diagrams/):
|
||||
|
||||
@@ -193,6 +129,46 @@ Detailed UML diagrams are available in [`docs/diagrams/`](docs/diagrams/):
|
||||
| [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) |
|
||||
| [Auth Architecture](docs/diagrams/png/15-auth-architecture.png) | JWT, scopes, token types, login flows |
|
||||
| [Channel Architecture](docs/diagrams/png/16-channel-architecture.png) | Discord/Slack adapter protocol and routing |
|
||||
| [Notify Flow](docs/diagrams/png/17-notify-flow.png) | Channel notification dispatch |
|
||||
| [Watch Architecture](docs/diagrams/png/18-watch-architecture.png) | Periodic command polling daemon |
|
||||
| [Governance Architecture](docs/diagrams/png/19-governance-architecture.png) | RBAC, policies, audit, usage enforcement flow |
|
||||
| [WS Template Architecture](docs/diagrams/png/21-ws-template-architecture.png) | Workstream template application and lifecycle |
|
||||
| [Judge Architecture](docs/diagrams/png/22-judge-architecture.png) | Intent validation two-tier evaluation pipeline |
|
||||
|
||||
### Governance
|
||||
|
||||
Turnstone includes a built-in governance layer for enterprise deployments — manage who can do what, which tools run unattended, and where every token goes.
|
||||
|
||||
- **RBAC** — 15 granular permissions, 3 built-in roles (admin / operator / viewer), custom roles, privilege escalation prevention
|
||||
- **Tool policies** — glob-pattern rules (`allow` / `deny` / `ask`) with priority ordering; automate approvals or lock down dangerous tools
|
||||
- **Prompt templates** — reusable system messages with `{{variable}}` substitution and categories
|
||||
- **Usage tracking** — per-request token and tool metrics, aggregation by day / model / user, automatic 90-day pruning
|
||||
- **Audit logging** — append-only event trail for all admin mutations, IP-aware, 365-day retention
|
||||
|
||||
All governance features are managed through the console admin panel (13 tabs) and the full REST API. Runtime settings (model, tools, rate limiting, health, judge, memory) are configurable via the admin Settings tab — no config file edits or restarts needed for most changes. See [docs/governance.md](docs/governance.md) for setup and [docs/settings.md](docs/settings.md) for the settings reference.
|
||||
|
||||
### Intent Validation (LLM Judge)
|
||||
|
||||
Every tool call that requires human approval is evaluated by an intent validation judge that provides a structured risk assessment alongside the approval prompt — so instead of "approve this bash command?", users see a verdict with risk level, confidence, recommendation, and reasoning.
|
||||
|
||||
The system uses a two-tier evaluation pipeline:
|
||||
|
||||
1. **Heuristic tier** (instant, free) — 23 pattern-based rules classify tool calls by severity. Catches destructive commands (`rm -rf /`, `DROP TABLE`), privilege escalation (`sudo`), credential access, and more. Results appear immediately.
|
||||
2. **LLM judge tier** (async) — A full LLM evaluation runs in the background with access to `read_file` and `list_directory` for evidence gathering. The judge can inspect files that a write would overwrite, check directory contents before a delete, and cite specific evidence in its reasoning. Results update the UI progressively when ready.
|
||||
|
||||
The judge defaults to the same model as the session (self-consistency) but can be configured to use a separate model — useful when running a small local model for tasks but wanting a commercial model for safety evaluation.
|
||||
|
||||
```toml
|
||||
[judge]
|
||||
enabled = true # on by default
|
||||
model = "" # empty = same as session model
|
||||
provider = "" # empty = same as session provider
|
||||
timeout = 60.0 # generous for local models
|
||||
```
|
||||
|
||||
Verdicts are persisted for audit and exposed via Prometheus metrics (`turnstone_judge_verdicts_total`, `turnstone_judge_llm_latency_seconds`). See [docs/judge.md](docs/judge.md) for the full guide.
|
||||
|
||||
## Multi-node routing
|
||||
|
||||
@@ -217,12 +193,12 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
|
||||
|
||||
## Tools
|
||||
|
||||
14 built-in tools, 2 agent tools, plus external tools via MCP:
|
||||
15 built-in tools, 2 agent tools, plus external tools via MCP:
|
||||
|
||||
| Tool | Description | Auto-approved |
|
||||
|------|-------------|:---:|
|
||||
| `bash` | Execute shell commands | |
|
||||
| `read_file` | Read file contents | yes |
|
||||
| `read_file` | Read file contents (text or images with vision models) | yes |
|
||||
| `write_file` | Write/create files | |
|
||||
| `edit_file` | Fuzzy-match file editing | |
|
||||
| `search` | Search files by name/content | yes |
|
||||
@@ -230,16 +206,19 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
|
||||
| `man` | Read man pages | yes |
|
||||
| `web_fetch` | Fetch URL content | |
|
||||
| `web_search` | Web search (provider-native or Tavily) | |
|
||||
| `remember` | Save persistent facts | yes |
|
||||
| `recall` | Search memories and history | yes |
|
||||
| `forget` | Remove a memory | yes |
|
||||
| `memory` | Structured persistent memory (save/search/delete/list) | yes |
|
||||
| `recall` | Search conversation history | yes |
|
||||
| `notify` | Send notifications to linked channels | yes |
|
||||
| `watch` | Periodic command polling with conditions | |
|
||||
| `task` | Spawn autonomous sub-agent | |
|
||||
| `plan` | Explore codebase, write .plan.md | |
|
||||
| `mcp__*` | External tools from MCP servers | |
|
||||
|
||||
When the total tool count exceeds a configurable threshold (default 20), MCP tools are automatically deferred using native `defer_loading` on Anthropic and OpenAI APIs, or a transparent client-side BM25 search for local models. The LLM discovers deferred tools on demand via a `tool_search` capability — no configuration needed beyond `--tool-search auto` (the default).
|
||||
|
||||
### MCP Tool Servers
|
||||
|
||||
Turnstone supports the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) for connecting external tool servers. MCP tools are discovered at startup, converted to OpenAI function-calling format, and merged with built-in tools. Each MCP tool is prefixed with `mcp__{server}__{tool}` to avoid name collisions.
|
||||
Turnstone supports the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) for connecting external tool servers. MCP tools are discovered at startup, converted to OpenAI function-calling format, and merged with built-in tools. Each MCP tool is prefixed with `mcp__{server}__{tool}` to avoid name collisions. Tool lists stay fresh via push notifications (`tools.listChanged`), periodic polling for servers without push, and manual `/mcp refresh`.
|
||||
|
||||
Configure via `config.toml` or `--mcp-config`:
|
||||
|
||||
@@ -259,7 +238,7 @@ turnstone --mcp-config ~/.config/turnstone/mcp.json
|
||||
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).
|
||||
Use `/mcp` in the REPL to list connected tools, `/mcp refresh` to re-fetch tool lists from servers. MCP tools require user approval by default (overridden by `--skip-permissions` or UI auto-approve).
|
||||
|
||||
### Multi-Model and Multi-Provider Support
|
||||
|
||||
@@ -314,6 +293,9 @@ agent_model = "" # model alias for plan/task sub-agents
|
||||
[tools]
|
||||
timeout = 30
|
||||
skip_permissions = false
|
||||
search = "auto" # "auto" (enable when >threshold tools), "on", "off"
|
||||
search_threshold = 20 # min tools before tool search activates
|
||||
search_max_results = 5 # max tools returned per search query
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
@@ -352,8 +334,16 @@ 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
|
||||
|
||||
[judge]
|
||||
enabled = true # intent validation for tool approvals (--no-judge to disable)
|
||||
model = "" # empty = same as session model (self-consistency)
|
||||
provider = "" # empty = same as session provider
|
||||
timeout = 60.0 # LLM judge timeout in seconds
|
||||
confidence_threshold = 0.7
|
||||
|
||||
[mcp]
|
||||
config_path = "" # path to MCP JSON config file (alternative to TOML sections)
|
||||
refresh_interval = 14400 # periodic refresh for servers without push notifications (seconds, 0 to disable)
|
||||
|
||||
[mcp.servers.example] # one section per MCP server
|
||||
command = "npx"
|
||||
@@ -392,6 +382,9 @@ Idle workstreams are automatically cleaned up after 2 hours (configurable). In m
|
||||
- `turnstone_backend_up` — LLM backend reachability (0/1)
|
||||
- `turnstone_circuit_state` — circuit breaker state (0=closed, 1=open, 2=half_open)
|
||||
- `turnstone_workstreams_evicted_total` — workstreams auto-evicted at capacity
|
||||
- `turnstone_judge_verdicts_total{tier,risk_level}` — intent validation verdicts by tier and risk
|
||||
- `turnstone_judge_llm_latency_seconds` — LLM judge evaluation latency histogram
|
||||
- `turnstone_judge_enabled` — whether the intent validation judge is active (0/1)
|
||||
|
||||
Per-workstream metrics are labeled by `ws_id` (bounded to 10 max workstreams).
|
||||
|
||||
@@ -412,6 +405,7 @@ Per-workstream metrics are labeled by `ws_id` (bounded to 10 max workstreams).
|
||||
- Redis (for message queue bridge — `pip install turnstone[mq]`)
|
||||
- Anthropic provider (optional — `pip install turnstone[anthropic]`)
|
||||
- PostgreSQL (optional, for production — `pip install turnstone[postgres]`)
|
||||
- [Git LFS](https://git-lfs.com/) (for cloning — diagram PNGs are stored in LFS)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+315
-3
@@ -2,11 +2,12 @@
|
||||
# Turnstone Docker Compose Stack
|
||||
#
|
||||
# Usage:
|
||||
# Default (SQLite): docker compose up
|
||||
# Infra only: docker compose up
|
||||
# Single node: docker compose --profile production up
|
||||
# Production (PG): DB_BACKEND=postgresql docker compose --profile production up
|
||||
# (or set DB_BACKEND=postgresql in .env)
|
||||
# 10-node cluster: docker compose --profile cluster up
|
||||
# Cluster + DDG: docker compose --profile ddgCluster up
|
||||
# With simulator: docker compose --profile sim up
|
||||
# Scale bridges: docker compose up --scale bridge=3
|
||||
# =============================================================================
|
||||
|
||||
name: turnstone
|
||||
@@ -28,6 +29,8 @@ services:
|
||||
image: postgres:17-alpine
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
- ddgCluster
|
||||
environment:
|
||||
POSTGRES_DB: turnstone
|
||||
POSTGRES_USER: ${POSTGRES_USER:-turnstone}
|
||||
@@ -87,6 +90,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
profiles:
|
||||
- production
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -98,10 +103,12 @@ services:
|
||||
--api-key "$${OPENAI_API_KEY}"
|
||||
$${MODEL:+--model $$MODEL}
|
||||
$${SKIP_PERMISSIONS:+--skip-permissions}
|
||||
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
|
||||
ports:
|
||||
- "${SERVER_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ./docker/mcp-ddg.json:/etc/turnstone/mcp-ddg.json:ro
|
||||
environment:
|
||||
- LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-dummy}
|
||||
@@ -109,9 +116,12 @@ services:
|
||||
- SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-}
|
||||
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
- MODEL=${MODEL:-}
|
||||
- MCP_CONFIG=${MCP_CONFIG:-}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-}
|
||||
- TURNSTONE_NODE_ID=${TURNSTONE_NODE_ID:-}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
@@ -122,6 +132,9 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
ddg-search:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
@@ -138,6 +151,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
profiles:
|
||||
- production
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server:8080
|
||||
@@ -148,6 +163,7 @@ services:
|
||||
environment:
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
@@ -177,6 +193,9 @@ services:
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-}
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
@@ -190,6 +209,78 @@ services:
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# turnstone-channel — Channel gateway (Discord, Slack, etc.)
|
||||
# Requires TURNSTONE_DISCORD_TOKEN to enable Discord adapter
|
||||
# -------------------------------------------------------------------
|
||||
channel:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
- ddgCluster
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
turnstone-channel
|
||||
--redis-host=redis
|
||||
--redis-port=6379
|
||||
--http-host=0.0.0.0
|
||||
$${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD}
|
||||
environment:
|
||||
- TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-}
|
||||
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-}
|
||||
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# ddg-search — DuckDuckGo Search MCP server (HTTP transport)
|
||||
# Provides web search + content fetch tools to turnstone via MCP.
|
||||
# No API key required.
|
||||
#
|
||||
# Start with: MCP_CONFIG=/etc/turnstone/mcp-ddg.json \
|
||||
# docker compose --profile ddgCluster up
|
||||
# -------------------------------------------------------------------
|
||||
ddg-search:
|
||||
image: python:3.13-slim
|
||||
profiles:
|
||||
- ddgCluster
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
pip install --no-cache-dir duckduckgo-mcp-server &&
|
||||
python -c "from mcp.server.transport_security import TransportSecuritySettings; import duckduckgo_mcp_server.server as s; s.safe_search=s.SafeSearchMode.OFF; s.mcp.settings.host='0.0.0.0'; s.mcp.settings.port=3000; s.mcp.settings.transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False); s.mcp.run(transport='streamable-http')"
|
||||
networks:
|
||||
- turnstone-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import socket; s=socket.create_connection(('0.0.0.0',3000),2); s.close()\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
cpus: '0.25'
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# turnstone-sim — Multi-node cluster simulator (no LLM needed)
|
||||
# Start with: docker compose --profile sim up
|
||||
@@ -229,3 +320,224 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
# ===================================================================
|
||||
# 10-node cluster (profile: cluster)
|
||||
#
|
||||
# Each node is a server + bridge pair. All share the same PostgreSQL
|
||||
# and Redis instances. Access via console at :8090.
|
||||
#
|
||||
# Start: docker compose --profile cluster up
|
||||
# ===================================================================
|
||||
|
||||
# -- cluster servers ------------------------------------------------
|
||||
|
||||
server-1: &cluster-server
|
||||
build: { context: ., dockerfile: Dockerfile }
|
||||
profiles: [cluster, ddgCluster]
|
||||
command: &cluster-server-cmd
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
turnstone-server
|
||||
--host 0.0.0.0
|
||||
--port 8080
|
||||
--base-url "$${LLM_BASE_URL}"
|
||||
--api-key "$${OPENAI_API_KEY}"
|
||||
$${MODEL:+--model $$MODEL}
|
||||
$${SKIP_PERMISSIONS:+--skip-permissions}
|
||||
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ./docker/mcp-ddg.json:/etc/turnstone/mcp-ddg.json:ro
|
||||
environment: &cluster-server-env
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy}
|
||||
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
|
||||
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
|
||||
TURNSTONE_AUTH_ENABLED: ${TURNSTONE_AUTH_ENABLED:-}
|
||||
TURNSTONE_AUTH_TOKEN: ${TURNSTONE_AUTH_TOKEN:-}
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:-}
|
||||
MODEL: ${MODEL:-}
|
||||
MCP_CONFIG: ${MCP_CONFIG:-}
|
||||
TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql}
|
||||
TURNSTONE_DB_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone}
|
||||
TURNSTONE_NODE_ID: node-1
|
||||
extra_hosts: ["host.docker.internal:host-gateway"]
|
||||
networks: [turnstone-net]
|
||||
depends_on:
|
||||
redis: { condition: service_healthy }
|
||||
postgres: { condition: service_healthy }
|
||||
ddg-search: { condition: service_healthy, required: false }
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
deploy:
|
||||
resources:
|
||||
limits: { memory: 384M, cpus: '0.5' }
|
||||
restart: unless-stopped
|
||||
|
||||
server-2:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-2 }
|
||||
server-3:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-3 }
|
||||
server-4:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-4 }
|
||||
server-5:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-5 }
|
||||
server-6:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-6 }
|
||||
server-7:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-7 }
|
||||
server-8:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-8 }
|
||||
server-9:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-9 }
|
||||
server-10:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-10 }
|
||||
|
||||
# -- cluster bridges ------------------------------------------------
|
||||
|
||||
bridge-1: &cluster-bridge
|
||||
build: { context: ., dockerfile: Dockerfile }
|
||||
profiles: [cluster, ddgCluster]
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-1:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
environment: &cluster-bridge-env
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
TURNSTONE_AUTH_TOKEN: ${TURNSTONE_AUTH_TOKEN:-}
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:-}
|
||||
networks: [turnstone-net]
|
||||
depends_on:
|
||||
server-1: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
deploy:
|
||||
resources:
|
||||
limits: { memory: 256M, cpus: '0.25' }
|
||||
restart: unless-stopped
|
||||
|
||||
bridge-2:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-2:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-2: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-3:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-3:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-3: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-4:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-4:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-4: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-5:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-5:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-5: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-6:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-6:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-6: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-7:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-7:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-7: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-8:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-8:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-8: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-9:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-9:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-9: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-10:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-10:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-10: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 520" font-family="ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace" font-size="13">
|
||||
<style>
|
||||
@keyframes pulse-green { 0%,100% { opacity:0.5 } 50% { opacity:1 } }
|
||||
@keyframes pulse-yellow { 0%,100% { opacity:0.4 } 50% { opacity:1 } }
|
||||
@keyframes pulse-blue { 0%,100% { opacity:0.3 } 50% { opacity:1 } }
|
||||
@keyframes fadein { from { opacity:0 } to { opacity:1 } }
|
||||
.pg { animation: pulse-green 2s infinite }
|
||||
.py { animation: pulse-yellow 1.8s infinite }
|
||||
.pb { animation: pulse-blue 2.2s infinite }
|
||||
.f1 { animation: fadein 0.4s 0.2s both }
|
||||
.f2 { animation: fadein 0.4s 0.4s both }
|
||||
.f3 { animation: fadein 0.4s 0.6s both }
|
||||
.f4 { animation: fadein 0.4s 0.8s both }
|
||||
.f5 { animation: fadein 0.4s 1.0s both }
|
||||
.f6 { animation: fadein 0.4s 1.3s both }
|
||||
.f7 { animation: fadein 0.4s 1.5s both }
|
||||
.f8 { animation: fadein 0.4s 1.7s both }
|
||||
.f9 { animation: fadein 0.4s 1.9s both }
|
||||
.f10 { animation: fadein 0.4s 2.1s both }
|
||||
.f11 { animation: fadein 0.4s 2.3s both }
|
||||
.f12 { animation: fadein 0.4s 2.5s both }
|
||||
</style>
|
||||
|
||||
<!-- Window chrome -->
|
||||
<rect rx="10" width="860" height="520" fill="#1a1b26"/>
|
||||
<rect width="860" height="36" rx="10" fill="#16161e"/>
|
||||
<rect y="26" width="860" height="10" fill="#16161e"/>
|
||||
<circle cx="20" cy="18" r="6" fill="#f7768e"/>
|
||||
<circle cx="40" cy="18" r="6" fill="#e0af68"/>
|
||||
<circle cx="60" cy="18" r="6" fill="#9ece6a"/>
|
||||
<text x="430" y="22" text-anchor="middle" fill="#565f89" font-size="12">turnstone — console</text>
|
||||
|
||||
<!-- Header -->
|
||||
<rect y="36" width="860" height="30" fill="#24283b"/>
|
||||
<rect y="66" width="860" height="1" fill="#3b4261"/>
|
||||
<text x="16" y="56" fill="#7aa2f7" font-size="14" font-weight="bold">turnstone console</text>
|
||||
<text x="200" y="56" fill="#565f89" font-size="12">6 nodes · 10 workstreams</text>
|
||||
|
||||
<!-- ====== State cards ====== -->
|
||||
<g transform="translate(16, 78)" class="f1" opacity="0">
|
||||
<!-- RUN card -->
|
||||
<rect x="0" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="0" y="0" width="156" height="3" rx="6" fill="#9ece6a"/>
|
||||
<text x="78" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">3</text>
|
||||
<text x="78" y="50" text-anchor="middle" fill="#565f89" font-size="10">▸ RUN</text>
|
||||
|
||||
<!-- THINK card -->
|
||||
<rect x="168" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="168" y="0" width="156" height="3" rx="6" fill="#7aa2f7"/>
|
||||
<text x="246" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">2</text>
|
||||
<text x="246" y="50" text-anchor="middle" fill="#565f89" font-size="10">◌ THINK</text>
|
||||
|
||||
<!-- ATTN card -->
|
||||
<rect x="336" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="336" y="0" width="156" height="3" rx="6" fill="#e0af68"/>
|
||||
<text x="414" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">1</text>
|
||||
<text x="414" y="50" text-anchor="middle" fill="#565f89" font-size="10">◆ ATTN</text>
|
||||
|
||||
<!-- ERR card -->
|
||||
<rect x="504" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="504" y="0" width="156" height="3" rx="6" fill="#f7768e"/>
|
||||
<text x="582" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">0</text>
|
||||
<text x="582" y="50" text-anchor="middle" fill="#565f89" font-size="10">✖ ERR</text>
|
||||
|
||||
<!-- IDLE card -->
|
||||
<rect x="672" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="672" y="0" width="156" height="3" rx="6" fill="#565f89"/>
|
||||
<text x="750" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">4</text>
|
||||
<text x="750" y="50" text-anchor="middle" fill="#565f89" font-size="10">· IDLE</text>
|
||||
</g>
|
||||
|
||||
<!-- Aggregate bar -->
|
||||
<text x="16" y="160" fill="#565f89" font-size="11" class="f2" opacity="0">197k tokens · 42 tool calls</text>
|
||||
|
||||
<!-- ====== NODES section ====== -->
|
||||
<text x="16" y="182" fill="#7aa2f7" font-size="12" font-weight="bold" class="f3" opacity="0">NODES</text>
|
||||
|
||||
<!-- Node column headers -->
|
||||
<g transform="translate(0, 190)" class="f4" opacity="0">
|
||||
<rect width="860" height="20" fill="#24283b"/>
|
||||
<rect y="20" width="860" height="1" fill="#3b4261"/>
|
||||
<text y="14" fill="#565f89" font-size="10" letter-spacing="0.5">
|
||||
<tspan x="36">NODE</tspan>
|
||||
<tspan x="560">WS</tspan>
|
||||
<tspan x="610">RUN</tspan>
|
||||
<tspan x="660">ATTN</tspan>
|
||||
<tspan x="710">TOKENS</tspan>
|
||||
<tspan x="790">LOAD</tspan>
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Node rows -->
|
||||
<g transform="translate(0, 214)">
|
||||
|
||||
<!-- Node 1: db-west-04 — 3 ws, 1 running, has-running bar -->
|
||||
<g class="f5" opacity="0">
|
||||
<rect y="0" width="860" height="38" fill="#1a1b26"/>
|
||||
<rect y="0" width="3" height="38" fill="#9ece6a"/>
|
||||
<circle cx="22" cy="19" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="23" fill="#a9b1d6" font-size="12" font-weight="bold">db-west-04</text>
|
||||
<text x="566" y="23" fill="#a9b1d6" font-size="11">3</text>
|
||||
<text x="616" y="23" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="666" y="23" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="23" fill="#565f89" font-size="11">57.6k</text>
|
||||
<!-- Load bar: 3/10 = 30% -->
|
||||
<rect x="770" y="15" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="15" width="18" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="23" fill="#565f89" font-size="11">30%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 2: api-east-01 — 3 ws, 1 attention, has-attention bar -->
|
||||
<g class="f6" opacity="0">
|
||||
<rect y="40" width="860" height="38" fill="#24283b"/>
|
||||
<rect y="40" width="3" height="38" fill="#e0af68"/>
|
||||
<circle cx="22" cy="59" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="63" fill="#a9b1d6" font-size="12" font-weight="bold">api-east-01</text>
|
||||
<text x="566" y="63" fill="#a9b1d6" font-size="11">3</text>
|
||||
<text x="616" y="63" fill="#565f89" font-size="11">0</text>
|
||||
<text x="666" y="63" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="710" y="63" fill="#565f89" font-size="11">109k</text>
|
||||
<!-- Load bar: 3/10 = 30% -->
|
||||
<rect x="770" y="55" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="55" width="18" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="63" fill="#565f89" font-size="11">30%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 3: sre-node-03 — 2 ws, 1 running, has-running bar -->
|
||||
<g class="f7" opacity="0">
|
||||
<rect y="80" width="860" height="38" fill="#1a1b26"/>
|
||||
<rect y="80" width="3" height="38" fill="#9ece6a"/>
|
||||
<circle cx="22" cy="99" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="103" fill="#a9b1d6" font-size="12" font-weight="bold">sre-node-03</text>
|
||||
<text x="566" y="103" fill="#a9b1d6" font-size="11">2</text>
|
||||
<text x="616" y="103" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="666" y="103" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="103" fill="#565f89" font-size="11">64.4k</text>
|
||||
<!-- Load bar: 2/10 = 20% -->
|
||||
<rect x="770" y="95" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="95" width="12" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="103" fill="#565f89" font-size="11">20%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 4: analytics-02 — 1 ws, thinking, has-thinking bar -->
|
||||
<g class="f8" opacity="0">
|
||||
<rect y="120" width="860" height="38" fill="#24283b"/>
|
||||
<rect y="120" width="3" height="38" fill="#7aa2f7"/>
|
||||
<circle cx="22" cy="139" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="143" fill="#a9b1d6" font-size="12" font-weight="bold">analytics-02</text>
|
||||
<text x="566" y="143" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="616" y="143" fill="#565f89" font-size="11">0</text>
|
||||
<text x="666" y="143" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="143" fill="#565f89" font-size="11">18.3k</text>
|
||||
<!-- Load bar: 1/10 = 10% -->
|
||||
<rect x="770" y="135" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="135" width="6" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="143" fill="#565f89" font-size="11">10%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 5: data-ops-05 — 1 ws, thinking, has-thinking bar -->
|
||||
<g class="f9" opacity="0">
|
||||
<rect y="160" width="860" height="38" fill="#1a1b26"/>
|
||||
<rect y="160" width="3" height="38" fill="#7aa2f7"/>
|
||||
<circle cx="22" cy="179" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="183" fill="#a9b1d6" font-size="12" font-weight="bold">data-ops-05</text>
|
||||
<text x="566" y="183" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="616" y="183" fill="#565f89" font-size="11">0</text>
|
||||
<text x="666" y="183" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="183" fill="#565f89" font-size="11">8.7k</text>
|
||||
<!-- Load bar: 1/10 = 10% -->
|
||||
<rect x="770" y="175" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="175" width="6" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="183" fill="#565f89" font-size="11">10%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 6: ml-gpu-07 — 0 ws, empty, no bar -->
|
||||
<g class="f10" opacity="0">
|
||||
<rect y="200" width="860" height="38" fill="#24283b"/>
|
||||
<rect y="200" width="3" height="38" fill="transparent"/>
|
||||
<circle cx="22" cy="219" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="223" fill="#a9b1d6" font-size="12" font-weight="bold">ml-gpu-07</text>
|
||||
<text x="566" y="223" fill="#565f89" font-size="11">0</text>
|
||||
<text x="616" y="223" fill="#565f89" font-size="11">0</text>
|
||||
<text x="666" y="223" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="223" fill="#565f89" font-size="11">0</text>
|
||||
<!-- Load bar: 0/10 = 0% (empty track) -->
|
||||
<rect x="770" y="215" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<text x="842" y="223" fill="#565f89" font-size="11">0%</text>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
|
||||
<!-- ====== Footer ====== -->
|
||||
<g transform="translate(0, 468)" class="f12" opacity="0">
|
||||
<rect width="860" height="1" fill="#3b4261"/>
|
||||
<rect y="1" width="860" height="24" fill="#16161e"/>
|
||||
|
||||
<circle cx="20" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="28" y="18" fill="#565f89" font-size="10">db-west-04</text>
|
||||
|
||||
<circle cx="120" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="128" y="18" fill="#565f89" font-size="10">api-east-01</text>
|
||||
|
||||
<circle cx="225" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="233" y="18" fill="#565f89" font-size="10">sre-node-03</text>
|
||||
|
||||
<circle cx="335" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="343" y="18" fill="#565f89" font-size="10">analytics-02</text>
|
||||
|
||||
<circle cx="450" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="458" y="18" fill="#565f89" font-size="10">data-ops-05</text>
|
||||
|
||||
<circle cx="560" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="568" y="18" fill="#565f89" font-size="10">ml-gpu-07</text>
|
||||
|
||||
<text x="680" y="18" fill="#3b4261" font-size="10">258k tokens · 42 calls · 12m</text>
|
||||
</g>
|
||||
|
||||
<!-- Bottom edge -->
|
||||
<rect y="493" width="860" height="27" fill="#16161e"/>
|
||||
<rect y="510" width="860" height="10" rx="10" fill="#16161e"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ddg": {
|
||||
"url": "http://ddg-search:3000/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
+831
-28
@@ -56,6 +56,170 @@ console.log(result.content);
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
When auth is enabled (`[auth].enabled = true` or `TURNSTONE_AUTH_ENABLED=1`), all API endpoints except public paths require a valid token.
|
||||
|
||||
### Sending Credentials
|
||||
|
||||
Include a token in one of two ways:
|
||||
|
||||
- **Bearer header**: `Authorization: Bearer <token>`
|
||||
- **Cookie**: `turnstone_auth=<token>` (set automatically by the login endpoint)
|
||||
|
||||
The server accepts three token types:
|
||||
|
||||
| Type | Format | Example |
|
||||
|------|--------|---------|
|
||||
| JWT | Base64 segments separated by dots | `eyJhbG...` |
|
||||
| API token | `ts_` prefix + 64 hex chars | `ts_a1b2c3d4...` |
|
||||
| Config token | Arbitrary string from `config.toml` | `my-secret-token` |
|
||||
|
||||
JWTs are the recommended credential for browser sessions. API tokens are suitable for programmatic access and CI/CD. Config tokens are a simple option for single-node deployments.
|
||||
|
||||
### `POST /v1/api/auth/login`
|
||||
|
||||
Authenticate with credentials and receive a JWT. Accepts two credential formats:
|
||||
|
||||
**Username + password:**
|
||||
|
||||
```json
|
||||
{"username": "alice", "password": "hunter2"}
|
||||
```
|
||||
|
||||
**API token:**
|
||||
|
||||
```json
|
||||
{"token": "ts_a1b2c3d4e5f6..."}
|
||||
```
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"role": "full",
|
||||
"scopes": "approve,read,write",
|
||||
"jwt": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"user_id": "u_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT.
|
||||
|
||||
**Response (failure):** `401`
|
||||
|
||||
```json
|
||||
{"error": "Invalid credentials"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/auth/logout`
|
||||
|
||||
Clears the `turnstone_auth` cookie. No request body required.
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
The response includes a `Set-Cookie` header that expires the auth cookie.
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/auth/status`
|
||||
|
||||
Returns the current authentication state. Works with or without a valid token.
|
||||
|
||||
**Response (authenticated):** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"user_id": "u_abc123",
|
||||
"scopes": ["approve", "read", "write"],
|
||||
"source": "jwt"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (not authenticated):** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": false,
|
||||
"user_id": null,
|
||||
"scopes": [],
|
||||
"source": null
|
||||
}
|
||||
```
|
||||
|
||||
**Response (auth disabled):** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": false,
|
||||
"auth_enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/auth/setup`
|
||||
|
||||
Creates the first admin user when no users exist in the database. This is a
|
||||
public endpoint (no authentication required) that only succeeds when auth is
|
||||
enabled and the user database is empty. Both the server and console expose
|
||||
this endpoint.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"display_name": "Admin",
|
||||
"password": "strongpass"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Validation |
|
||||
|----------------|--------|----------|-----------------------------|
|
||||
| `username` | string | yes | 1-64 ASCII characters |
|
||||
| `display_name` | string | yes | Non-empty |
|
||||
| `password` | string | yes | Minimum 8 characters |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"user_id": "u_abc123",
|
||||
"username": "admin",
|
||||
"role": "full",
|
||||
"scopes": "approve,read,write",
|
||||
"jwt": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
```
|
||||
|
||||
The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT.
|
||||
|
||||
**Response (already set up):** `409`
|
||||
|
||||
```json
|
||||
{"error": "Setup already completed"}
|
||||
```
|
||||
|
||||
Returned when one or more users already exist in the database.
|
||||
|
||||
**Response (auth disabled):** `400`
|
||||
|
||||
```json
|
||||
{"error": "Auth is not enabled"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /`
|
||||
@@ -284,6 +448,58 @@ after `/clear` or `/new` commands).
|
||||
{"type": "clear_ui"}
|
||||
```
|
||||
|
||||
**`cancelled`** -- the generation was cancelled by the user (via the Stop
|
||||
button or `POST /v1/api/cancel`). The client should finalize any in-progress
|
||||
assistant message with whatever partial content was streamed.
|
||||
|
||||
```json
|
||||
{"type": "cancelled"}
|
||||
```
|
||||
|
||||
**`intent_verdict`** -- delivered asynchronously when the LLM judge completes
|
||||
its evaluation of a pending tool call. Only sent when intent validation is
|
||||
enabled (`--judge` or `[judge] enabled = true`). The `call_id` correlates with
|
||||
the item in the preceding `approve_request` event.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "intent_verdict",
|
||||
"verdict_id": "f7e8d9c0b1a2",
|
||||
"call_id": "call_abc123",
|
||||
"func_name": "bash",
|
||||
"intent_summary": "Install Express.js web framework via npm",
|
||||
"risk_level": "medium",
|
||||
"confidence": 0.85,
|
||||
"recommendation": "review",
|
||||
"reasoning": "The command installs express from npm. This is a well-known package but will modify node_modules and package.json.",
|
||||
"evidence": ["Checked package.json -- express is not currently a dependency"],
|
||||
"tier": "llm",
|
||||
"judge_model": "gpt-5",
|
||||
"latency_ms": 2340
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------------|------------|--------------------------------------------------------|
|
||||
| `verdict_id` | string | Unique verdict identifier |
|
||||
| `call_id` | string | Tool call ID (matches `approve_request` item) |
|
||||
| `func_name` | string | Tool function name |
|
||||
| `intent_summary` | string | One-sentence description of the tool call's intent |
|
||||
| `risk_level` | string | `"low"`, `"medium"`, `"high"`, or `"critical"` |
|
||||
| `confidence` | float | 0.0--1.0 confidence in the assessment |
|
||||
| `recommendation` | string | `"approve"`, `"review"`, or `"deny"` |
|
||||
| `reasoning` | string | Evidence-based explanation |
|
||||
| `evidence` | list | Supporting evidence (file excerpts, rule names) |
|
||||
| `tier` | string | Always `"llm"` for this event |
|
||||
| `judge_model` | string | Model that produced the verdict |
|
||||
| `latency_ms` | int | Evaluation time in milliseconds |
|
||||
|
||||
When intent validation is active, the `approve_request` event is also extended:
|
||||
each item in `items` gains a `verdict` field containing the heuristic verdict
|
||||
(same schema as above but with `tier: "heuristic"`), and the event gains a
|
||||
top-level `judge_pending` boolean indicating whether an LLM verdict is in
|
||||
flight.
|
||||
|
||||
#### Keepalive
|
||||
|
||||
The server sends an SSE comment every 5 seconds when no events are pending:
|
||||
@@ -296,13 +512,13 @@ The server sends an SSE comment every 5 seconds when no events are pending:
|
||||
This prevents proxies and browsers from closing the connection due to
|
||||
inactivity.
|
||||
|
||||
#### Generation mechanism
|
||||
#### Multi-consumer fan-out
|
||||
|
||||
Each new SSE connection to a workstream increments an internal
|
||||
`_sse_generation` counter. The previous SSE handler detects the generation
|
||||
mismatch and exits its event loop, ensuring only one active SSE connection per
|
||||
workstream at a time. The event queue is drained of stale events before the new
|
||||
connection begins streaming.
|
||||
Each SSE connection to a workstream receives its own delivery queue. Events
|
||||
produced by the worker thread are fanned out to all registered listener queues,
|
||||
so multiple consumers (browser, bridge, console proxy, SDK) can connect
|
||||
simultaneously and each receives every event. On reconnect the client receives
|
||||
a full history replay, so no catch-up mechanism is needed.
|
||||
|
||||
---
|
||||
|
||||
@@ -351,8 +567,8 @@ Returns a list of all active workstreams.
|
||||
```json
|
||||
{
|
||||
"workstreams": [
|
||||
{"id": "abc123", "name": "default", "state": "idle", "session_id": "a1b2c3d4e5f6"},
|
||||
{"id": "def456", "name": "hacker-news", "state": "thinking", "session_id": "c5d6e7f8a9b0"}
|
||||
{"id": "abc123", "name": "default", "state": "idle"},
|
||||
{"id": "def456", "name": "hacker-news", "state": "thinking"}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -364,22 +580,21 @@ 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 `/v1/api/sessions` |
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/sessions`
|
||||
### `GET /v1/api/workstreams/saved`
|
||||
|
||||
Returns a list of saved sessions from the database, ordered by most recently
|
||||
Returns a list of saved workstreams from the database, ordered by most recently
|
||||
updated.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
"workstreams": [
|
||||
{
|
||||
"session_id": "a1b2c3d4e5f6",
|
||||
"ws_id": "a1b2c3d4e5f6",
|
||||
"alias": "refactor",
|
||||
"title": "JWT Authentication Refactor",
|
||||
"created": "2026-03-01 10:00:00",
|
||||
@@ -390,16 +605,16 @@ updated.
|
||||
}
|
||||
```
|
||||
|
||||
Each session object:
|
||||
Each saved workstream object:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------------|-------------|--------------------------------------------|
|
||||
| `session_id` | string | Unique 12-char hex session identifier |
|
||||
| `ws_id` | string | Unique workstream identifier |
|
||||
| `alias` | string/null | User-assigned short name |
|
||||
| `title` | string/null | LLM-generated title |
|
||||
| `created` | string | ISO timestamp of session creation |
|
||||
| `created` | string | ISO timestamp of workstream creation |
|
||||
| `updated` | string | ISO timestamp of last message |
|
||||
| `message_count` | int | Number of messages in the session |
|
||||
| `message_count` | int | Number of messages in the workstream |
|
||||
|
||||
---
|
||||
|
||||
@@ -538,6 +753,43 @@ containing the resumed session's messages.
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/cancel`
|
||||
|
||||
Cancels the active generation in a workstream. Sets a cooperative cancellation
|
||||
flag that is checked at multiple points in the generation loop (per streaming
|
||||
chunk, before tool execution, inside bash commands). The session transitions to
|
||||
`idle` state and preserves any partial content already streamed.
|
||||
|
||||
If the workstream is waiting for tool approval or plan review, the pending
|
||||
prompt is automatically denied/rejected to unblock the worker thread.
|
||||
|
||||
Calling this endpoint when the workstream is already idle is a harmless no-op.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{"ws_id": "abc123"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------|--------|----------|----------------------|
|
||||
| `ws_id`| string | yes | Target workstream ID |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
**Error responses:**
|
||||
|
||||
| Status | Body | Condition |
|
||||
|--------|------------------------------------|------------------------|
|
||||
| 400 | `{"error": "No session"}` | Session not initialized|
|
||||
| 404 | `{"error": "Unknown workstream"}` | `ws_id` not found |
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/workstreams/new`
|
||||
|
||||
Creates a new workstream. The server supports up to 10 concurrent workstreams.
|
||||
@@ -550,22 +802,29 @@ Creates a new workstream. The server supports up to 10 concurrent workstreams.
|
||||
|
||||
All fields are optional. The body can be empty or an empty JSON object.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|----------------|--------|---------|------------------------------------------------|
|
||||
| `name` | string | auto | Workstream display name |
|
||||
| `model` | string | default | Model alias from the registry (`[models.*]`) |
|
||||
| `auto_approve` | bool | false | Auto-approve all tool calls for this workstream |
|
||||
| Field | Type | Default | Description |
|
||||
|------------------|--------|---------|----------------------------------------------------------------|
|
||||
| `name` | string | auto | Workstream display name |
|
||||
| `model` | string | default | Model alias from the registry (`[models.*]`) |
|
||||
| `auto_approve` | bool | false | Auto-approve all tool calls for this workstream |
|
||||
| `resume_ws` | string | "" | Workstream ID to resume atomically during creation (empty = fresh)|
|
||||
| `template` | string | "" | Prompt template name (replaces default templates; 400 if not found)|
|
||||
| `ws_template` | string | "" | Workstream template name. Applies model, temperature, reasoning effort, max tokens, auto-approve policy, and token budget. Returns 400 if not found or disabled. |
|
||||
|
||||
> **Template precedence:** When `ws_template` is specified, its model override takes effect before workstream creation. Both `template` (prompt template) and `ws_template` (workstream template) can be used together — `ws_template` controls the behavioral profile while `template` sets the system message text. If `ws_template` defines its own system prompt or prompt template reference, that takes precedence over the `template` parameter.
|
||||
|
||||
**Response (success):**
|
||||
|
||||
```json
|
||||
{"ws_id": "ghi789", "name": "ws-3"}
|
||||
{"ws_id": "ghi789", "name": "ws-3", "resumed": false, "message_count": 0}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------|--------|------------------------------------|
|
||||
| `ws_id` | string | Unique ID of the new workstream |
|
||||
| `name` | string | Auto-generated workstream name |
|
||||
| Field | Type | Description |
|
||||
|-----------------|--------|-----------------------------------------------------|
|
||||
| `ws_id` | string | Unique ID of the new workstream |
|
||||
| `name` | string | Auto-generated workstream name |
|
||||
| `resumed` | bool | Whether a previous session was successfully resumed |
|
||||
| `message_count` | int | Number of messages in the resumed session (0 if fresh) |
|
||||
|
||||
**Error (limit reached):**
|
||||
|
||||
@@ -608,6 +867,548 @@ Status code: `400`
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/watches`
|
||||
|
||||
List active watches on this server node. Optionally filter by workstream.
|
||||
Requires `write` scope.
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|------------------------------------|
|
||||
| `ws_id` | string | no | Filter to watches for this workstream. If omitted, returns all watches on the node. |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"watches": [
|
||||
{
|
||||
"watch_id": "abc123def456...",
|
||||
"ws_id": "ws-1",
|
||||
"node_id": "host_a1b2",
|
||||
"name": "pr-review",
|
||||
"command": "gh pr view --json state",
|
||||
"interval_secs": 300.0,
|
||||
"stop_on": "data[\"state\"] == \"MERGED\"",
|
||||
"max_polls": 100,
|
||||
"poll_count": 5,
|
||||
"last_output": "{\"state\": \"OPEN\"}",
|
||||
"last_poll": "2026-03-09T12:00:00",
|
||||
"next_poll": "2026-03-09T12:05:00",
|
||||
"active": 1,
|
||||
"created": "2026-03-09T11:30:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/watches/{watch_id}/cancel`
|
||||
|
||||
Cancel an active watch. Sets `active=0` and clears `next_poll`.
|
||||
Requires `write` scope. Verifies node ownership in multi-node deployments.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|------------|--------|-----------------|
|
||||
| `watch_id` | string | Watch ID to cancel |
|
||||
|
||||
**Response (success):**
|
||||
|
||||
```json
|
||||
{"status": "ok", "watch_id": "abc123def456..."}
|
||||
```
|
||||
|
||||
**Error (not found):**
|
||||
|
||||
```json
|
||||
{"error": "Watch not found"}
|
||||
```
|
||||
|
||||
Status code: `404`
|
||||
|
||||
**Error (wrong node):**
|
||||
|
||||
```json
|
||||
{"error": "Watch belongs to another node"}
|
||||
```
|
||||
|
||||
Status code: `403`
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/memories`
|
||||
|
||||
List structured memories with optional filters. Requires `read` scope.
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|------------|--------|----------|---------|------------------------------|
|
||||
| `type` | string | no | `""` | Filter by memory type (user, project, feedback, reference) |
|
||||
| `scope` | string | no | `""` | Filter by scope (global, workstream, user) |
|
||||
| `scope_id` | string | no | `""` | Scope qualifier. Auto-resolved for `scope=user` when auth is active. |
|
||||
| `limit` | int | no | `100` | Max results (capped at 200) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"memory_id": "a1b2c3d4-e5f6-...",
|
||||
"name": "project_architecture",
|
||||
"description": "Core architecture patterns",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "The project uses a hexagonal architecture...",
|
||||
"created": "2026-03-10T10:00:00",
|
||||
"updated": "2026-03-12T14:30:00"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/memories`
|
||||
|
||||
Save or upsert a structured memory. Requires `write` scope. Returns `201` on
|
||||
create, `200` on update.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "deployment_process",
|
||||
"content": "Deploy via GitHub Actions. Staging auto-deploys on push to main.",
|
||||
"description": "CI/CD deployment workflow",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": ""
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|--------------|--------|----------|-------------|--------------------------------------|
|
||||
| `name` | string | yes | -- | Memory name (max 256 chars) |
|
||||
| `content` | string | yes | -- | Memory content (max 65536 chars) |
|
||||
| `description`| string | no | `""` | Short description for search ranking |
|
||||
| `type` | string | no | `"project"` | One of: user, project, feedback, reference |
|
||||
| `scope` | string | no | `"global"` | One of: global, workstream, user |
|
||||
| `scope_id` | string | no | `""` | Scope qualifier (auto-resolved for user scope) |
|
||||
|
||||
**Response (created):** `201`
|
||||
|
||||
```json
|
||||
{
|
||||
"memory_id": "a1b2c3d4-e5f6-...",
|
||||
"name": "deployment_process",
|
||||
"description": "CI/CD deployment workflow",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "Deploy via GitHub Actions...",
|
||||
"created": "2026-03-14T10:00:00",
|
||||
"updated": "2026-03-14T10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Error responses:**
|
||||
|
||||
| Status | Condition |
|
||||
|--------|--------------------------------------------------------|
|
||||
| 400 | Missing name, empty content, invalid type/scope, name too long, content too long |
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/memories/search`
|
||||
|
||||
Search memories by query. Uses POST for the request body but is non-mutating
|
||||
(requires only `read` scope).
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "authentication",
|
||||
"type": "project",
|
||||
"scope": "",
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|------------|--------|----------|---------|--------------------------------|
|
||||
| `query` | string | yes | -- | Search query |
|
||||
| `type` | string | no | `""` | Filter by type |
|
||||
| `scope` | string | no | `""` | Filter by scope |
|
||||
| `scope_id` | string | no | `""` | Filter by scope ID |
|
||||
| `limit` | int | no | `20` | Max results (capped at 50) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"memory_id": "a1b2c3d4-e5f6-...",
|
||||
"name": "auth_patterns",
|
||||
"description": "Authentication architecture",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "JWT tokens with HS256...",
|
||||
"created": "2026-03-10T10:00:00",
|
||||
"updated": "2026-03-12T14:30:00"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Error:** `400` with `{"error": "query is required"}` if `query` is empty.
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /v1/api/memories/{name}`
|
||||
|
||||
Delete a memory by name and scope. Requires `write` scope.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------|----------------------|
|
||||
| `name` | string | Memory name |
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|------------|--------|----------|------------|---------------------|
|
||||
| `scope` | string | no | `"global"` | Scope of the memory |
|
||||
| `scope_id` | string | no | `""` | Scope qualifier |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{"status": "ok", "name": "deployment_process"}
|
||||
```
|
||||
|
||||
**Error (not found):** `404`
|
||||
|
||||
```json
|
||||
{"error": "Memory 'deployment_process' not found"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/admin/memories` (Console)
|
||||
|
||||
List structured memories across all scopes. Requires `admin.memories`
|
||||
permission.
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|------------|--------|----------|---------|------------------------------|
|
||||
| `type` | string | no | `""` | Filter by type |
|
||||
| `scope` | string | no | `""` | Filter by scope |
|
||||
| `scope_id` | string | no | `""` | Filter by scope ID |
|
||||
| `limit` | int | no | `100` | Max results (capped at 200) |
|
||||
|
||||
**Response:** `200` -- same schema as `GET /v1/api/memories`.
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/admin/memories/search` (Console)
|
||||
|
||||
Search memories by query. Requires `admin.memories` permission.
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|------------|--------|----------|---------|-------------------------------|
|
||||
| `q` | string | yes | -- | Search query |
|
||||
| `type` | string | no | `""` | Filter by type |
|
||||
| `scope` | string | no | `""` | Filter by scope |
|
||||
| `scope_id` | string | no | `""` | Filter by scope ID |
|
||||
| `limit` | int | no | `20` | Max results (capped at 50) |
|
||||
|
||||
**Response:** `200` -- same schema as `GET /v1/api/memories`.
|
||||
|
||||
**Error:** `400` with `{"error": "q is required"}` if `q` is empty.
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/admin/memories/{memory_id}` (Console)
|
||||
|
||||
Get a single memory by ID. Requires `admin.memories` permission.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-------------|--------|------------------------|
|
||||
| `memory_id` | string | Memory UUID |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"memory_id": "a1b2c3d4-e5f6-...",
|
||||
"name": "project_architecture",
|
||||
"description": "Core architecture patterns",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "The project uses...",
|
||||
"created": "2026-03-10T10:00:00",
|
||||
"updated": "2026-03-12T14:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Error (not found):** `404`
|
||||
|
||||
```json
|
||||
{"error": "Memory not found"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /v1/api/admin/memories/{memory_id}` (Console)
|
||||
|
||||
Delete a memory by ID. Records an audit event (`memory.delete`). Requires
|
||||
`admin.memories` permission.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-------------|--------|------------------------|
|
||||
| `memory_id` | string | Memory UUID |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
**Error (not found):** `404`
|
||||
|
||||
```json
|
||||
{"error": "Memory not found"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/admin/verdicts` (Console)
|
||||
|
||||
List intent validation verdicts from the `intent_verdicts` table. This endpoint
|
||||
is on the **console** server and requires the `admin.judge` permission.
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|--------------|--------|----------|----------------------------------------------------|
|
||||
| `ws_id` | string | no | Filter by workstream ID |
|
||||
| `since` | string | no | ISO timestamp lower bound |
|
||||
| `until` | string | no | ISO timestamp upper bound |
|
||||
| `risk_level` | string | no | Filter by risk level (`low`/`medium`/`high`/`critical`) |
|
||||
| `limit` | int | no | Max results (default 100, max 500) |
|
||||
| `offset` | int | no | Pagination offset (default 0) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"verdicts": [
|
||||
{
|
||||
"verdict_id": "a1b2c3d4e5f6",
|
||||
"ws_id": "ws-1",
|
||||
"call_id": "call_abc123",
|
||||
"func_name": "bash",
|
||||
"func_args": "{\"command\": \"npm install express\"}",
|
||||
"intent_summary": "Package installation: npm install express",
|
||||
"risk_level": "medium",
|
||||
"confidence": 0.70,
|
||||
"recommendation": "review",
|
||||
"reasoning": "Command installs a software package which may modify the environment.",
|
||||
"evidence": "[\"Matched rule: package-install\"]",
|
||||
"tier": "heuristic",
|
||||
"judge_model": "",
|
||||
"latency_ms": 0,
|
||||
"user_decision": "approved",
|
||||
"created": "2026-03-13T10:00:00"
|
||||
}
|
||||
],
|
||||
"total": 42
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/admin/settings` (Console)
|
||||
|
||||
List all settings with their effective values, defaults, and metadata. Requires
|
||||
the `admin.settings` permission.
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": [
|
||||
{
|
||||
"key": "model.temperature",
|
||||
"value": 0.7,
|
||||
"source": "storage",
|
||||
"type": "float",
|
||||
"description": "Sampling temperature",
|
||||
"section": "model",
|
||||
"is_secret": false,
|
||||
"node_id": "",
|
||||
"changed_by": "admin",
|
||||
"updated": "2026-03-14T10:00:00",
|
||||
"restart_required": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/admin/settings/schema` (Console)
|
||||
|
||||
Return the full registry catalog (all defined settings with metadata). Requires
|
||||
the `admin.settings` permission. Useful for building dynamic admin UIs.
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": [
|
||||
{
|
||||
"key": "model.temperature",
|
||||
"type": "float",
|
||||
"default": 0.5,
|
||||
"description": "Sampling temperature",
|
||||
"section": "model",
|
||||
"is_secret": false,
|
||||
"min_value": 0.0,
|
||||
"max_value": 2.0,
|
||||
"choices": null,
|
||||
"restart_required": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `PUT /v1/api/admin/settings/{key}` (Console)
|
||||
|
||||
Update a setting. Requires the `admin.settings` permission. The value is
|
||||
validated against the registry definition (type coercion, range checks, choices).
|
||||
Secret settings (`is_secret=true`) return `403`.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `key` | string | Dotted setting key (e.g. `model.temperature`) |
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"value": 0.7,
|
||||
"node_id": ""
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-----------|--------|----------|---------|-------------|
|
||||
| `value` | any | yes | -- | New value (type-coerced against registry) |
|
||||
| `node_id` | string | no | `""` | Node ID for per-node override |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "model.temperature",
|
||||
"value": 0.7,
|
||||
"source": "storage",
|
||||
"type": "float",
|
||||
"description": "Sampling temperature",
|
||||
"section": "model",
|
||||
"is_secret": false,
|
||||
"node_id": "",
|
||||
"changed_by": "admin",
|
||||
"updated": "",
|
||||
"restart_required": false
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Condition |
|
||||
|--------|-----------|
|
||||
| 400 | Unknown key, invalid value, type mismatch, out of range, missing `value` field |
|
||||
| 403 | Secret setting (must use config.toml or env) |
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /v1/api/admin/settings/{key}` (Console)
|
||||
|
||||
Reset a setting to its registry default by removing it from storage. Requires
|
||||
the `admin.settings` permission.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `key` | string | Dotted setting key |
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|--------|----------|---------|-------------|
|
||||
| `node_id` | string | no | `""` | Node ID (empty = global) |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{"status": "ok", "key": "model.temperature", "default": 0.5}
|
||||
```
|
||||
|
||||
**Response (not found):** `404`
|
||||
|
||||
```json
|
||||
{"error": "Setting 'model.temperature' has no stored value"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MCP Servers
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/v1/api/admin/mcp-servers` | List all MCP server definitions with live node status. Query: `?reveal=true` to show env/header secrets. |
|
||||
| POST | `/v1/api/admin/mcp-servers` | Create an MCP server definition. Body: `{name, transport, command?, args?, url?, headers?, env?, auto_approve?, enabled?}` |
|
||||
| GET | `/v1/api/admin/mcp-servers/{server_id}` | Get a single MCP server with per-node connection status. |
|
||||
| PUT | `/v1/api/admin/mcp-servers/{server_id}` | Update an MCP server definition. Partial updates supported. |
|
||||
| DELETE | `/v1/api/admin/mcp-servers/{server_id}` | Delete an MCP server definition. |
|
||||
| POST | `/v1/api/admin/mcp-servers/reload` | Tell all cluster nodes to re-read the `mcp_servers` DB table and reconcile (add new, remove stale, reconnect changed). |
|
||||
| POST | `/v1/api/admin/mcp-servers/import` | Import servers from a pasted JSON config. Body: `{config: {mcpServers: {...}}}`. Skips existing names. |
|
||||
|
||||
Permission: `admin.mcp`
|
||||
|
||||
Secrets (`env`, `headers` fields) are masked with `***` by default. Use `?reveal=true` on GET endpoints to see actual values.
|
||||
|
||||
---
|
||||
|
||||
### `OPTIONS` (any path)
|
||||
|
||||
Handles CORS preflight requests.
|
||||
@@ -692,7 +1493,8 @@ liveness probes.
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"node_id": "worker-01_a3f2",
|
||||
"uptime_seconds": 3614.72,
|
||||
"model": "llama-3.1-70b-instruct",
|
||||
"workstreams": {
|
||||
@@ -714,6 +1516,7 @@ liveness probes.
|
||||
|-------|------|-------------|
|
||||
| `status` | string | `"ok"` or `"degraded"` (degraded when backend unreachable) |
|
||||
| `version` | string | turnstone server version |
|
||||
| `node_id` | string | Server-generated node identity (`{hostname}_{4hex}`) |
|
||||
| `uptime_seconds` | number | Seconds since the server process started |
|
||||
| `model` | string | Model name detected or configured at startup |
|
||||
| `workstreams.total` | integer | Total active workstreams |
|
||||
|
||||
+359
-72
@@ -3,7 +3,7 @@
|
||||
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.) 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
|
||||
model 17 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**.
|
||||
@@ -21,6 +21,8 @@ plugs in.
|
||||
| `turnstone-bridge` | `turnstone.mq.bridge` | Bridge | Message queue ↔ HTTP API bridge |
|
||||
| `turnstone-console` | `turnstone.console.server` | ClusterCollector | Cluster dashboard (aggregates all nodes) |
|
||||
| `turnstone-eval` | `turnstone.eval` | `NullUI` | Headless evaluation and prompt optimization |
|
||||
| `turnstone-channel` | `turnstone.channels.cli` | ChannelAdapter | Channel gateway (Discord, Slack, etc.) via Redis MQ |
|
||||
| `turnstone-admin` | `turnstone.core.admin_cli` | — | Offline user and API token management |
|
||||
|
||||
---
|
||||
|
||||
@@ -40,9 +42,15 @@ turnstone/
|
||||
__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
|
||||
mcp_client.py MCPClientManager — MCP server connections, tool discovery, dynamic refresh, async-sync bridge
|
||||
tool_search.py Dynamic tool search — BM25 index, session-scoped tool visibility
|
||||
watch.py WatchRunner daemon — periodic command polling, condition DSL, result dispatch
|
||||
judge.py Intent validation — heuristic rules + LLM judge, advisory verdicts
|
||||
model_registry.py ModelRegistry — named model configs, lazy client creation, fallback routing
|
||||
memory.py Persistence facade (delegates to storage backend)
|
||||
memory.py Persistence facade + structured memory API (delegates to storage backend)
|
||||
config.py Config file loader (config.toml), apply_config(), warn_migrated_settings()
|
||||
config_store.py ConfigStore — database-backed settings with in-memory cache, thread-safe get/set
|
||||
settings_registry.py SettingDef catalog (~40 settings), validation, type coercion, serialization
|
||||
storage/ Pluggable storage: StorageBackend protocol, SQLite + PostgreSQL
|
||||
metrics.py Prometheus-compatible metrics collector (MetricsCollector)
|
||||
healthcheck.py BackendHealthMonitor — periodic probe + circuit breaker
|
||||
@@ -73,8 +81,15 @@ turnstone/
|
||||
client.py TurnstoneClient library + TurnResult for MQ-based access
|
||||
console/
|
||||
collector.py ClusterCollector — aggregates state from all nodes via Redis + HTTP
|
||||
scheduler.py TaskScheduler — background cron/at scheduler, dispatches via MQ
|
||||
server.py Cluster dashboard HTTP server + SSE + CLI entry point
|
||||
static/ Cluster dashboard web UI (page-specific HTML, CSS, JS)
|
||||
channels/
|
||||
cli.py Unified channel gateway entry point (turnstone-channel)
|
||||
_protocol.py ChannelAdapter protocol, ChannelEvent dataclass
|
||||
_routing.py ChannelRouter — channel/thread ↔ workstream mapping via MQ
|
||||
_config.py Base ChannelConfig dataclass
|
||||
discord/ Discord adapter (bot, cog, views, streaming, config)
|
||||
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
|
||||
@@ -85,7 +100,7 @@ turnstone/
|
||||
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)
|
||||
*.json 15 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/`.
|
||||
@@ -118,6 +133,7 @@ A user message flows through the system as follows:
|
||||
| on_reasoning_token() / on_content_token()
|
||||
| accumulate tool_calls from deltas
|
||||
| track finish_reason
|
||||
| _check_cancelled() per chunk (cooperative cancel)
|
||||
v
|
||||
finish_reason check:
|
||||
+--- "length" --> warn, discard partial tool_calls
|
||||
@@ -163,11 +179,13 @@ Phase 2: APPROVE (serial, blocking)
|
||||
_emit_state("running")
|
||||
|
||||
Phase 3: EXECUTE (parallel)
|
||||
_check_cancelled() <-- cancellation checkpoint before execution starts
|
||||
if len(items) == 1:
|
||||
run_one(items[0])
|
||||
else:
|
||||
ThreadPoolExecutor(max_workers=4).map(run_one, items)
|
||||
Bash tool streams stdout line-by-line via ui.on_tool_output_chunk(call_id, line)
|
||||
(cancel_event also checked per line — kills process group on cancel)
|
||||
Final output (stdout + stderr) delivered via ui.on_tool_result(call_id, name, output)
|
||||
call_id links tool_info items → streaming chunks → final result
|
||||
For plan tool: post-execution gate via ui.on_plan_review()
|
||||
@@ -198,6 +216,11 @@ The engine emits state changes via `_emit_state()` which calls
|
||||
"idle" ---> no more tool calls, turn complete
|
||||
|
|
||||
(or "error" ---> exception or KeyboardInterrupt)
|
||||
|
||||
cancel() may be called from any state. It sets a cooperative flag
|
||||
checked at each streaming chunk, before tool execution, and inside
|
||||
bash commands. The session transitions to "idle" with partial
|
||||
content preserved, emitting on_info("[Generation cancelled]").
|
||||
```
|
||||
|
||||
---
|
||||
@@ -416,13 +439,13 @@ from each schema and builds:
|
||||
- `PRIMARY_KEY_MAP` -- `{name: primary_key}` for JSON fallback recovery
|
||||
- `merge_mcp_tools(builtin, mcp_tools)` -- merges built-in + MCP tools at session init
|
||||
|
||||
### 14 Tools by Category
|
||||
### 13 Tools by Category
|
||||
|
||||
**Read-only (auto-approve)**:
|
||||
- `read_file` -- read file contents with optional offset/limit
|
||||
- `search` -- ripgrep-based codebase search
|
||||
- `man` -- read man pages
|
||||
- `recall` -- retrieve stored memories
|
||||
- `recall` -- search conversation history
|
||||
|
||||
**Write (requires approval)**:
|
||||
- `bash` -- execute shell commands (with safety checks via `turnstone.core.safety`)
|
||||
@@ -436,9 +459,8 @@ from each schema and builds:
|
||||
- `task` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
|
||||
- `plan` -- explore codebase and write a structured plan (`AGENT_TOOLS`)
|
||||
|
||||
**Memory (persistent key-value store)**:
|
||||
- `remember` -- save a fact
|
||||
- `forget` -- delete a fact
|
||||
**Memory (structured persistent store)**:
|
||||
- `memory` -- save, search, delete, or list memories (typed and scoped)
|
||||
|
||||
### Prepare / Execute Pattern
|
||||
|
||||
@@ -463,7 +485,7 @@ independently, then returns the final content as the tool result.
|
||||
|
||||
- **task**: uses `self._task_tools` (`TASK_AGENT_TOOLS` + MCP tools)
|
||||
- **plan**: uses `self._agent_tools` (`AGENT_TOOLS` + MCP tools). Writes output
|
||||
to `.plan-<session_id>.md` — unique per `ChatSession` so concurrent workstreams
|
||||
to `.plan-<ws_id>.md` — unique per `ChatSession` so concurrent workstreams
|
||||
don't collide. On repeat invocations the prior `plan` tool call and its result
|
||||
are forwarded from `self.messages` so the agent refines the existing plan rather
|
||||
than starting over. Planning instructions are injected as a developer message
|
||||
@@ -484,21 +506,46 @@ independently, then returns the final content as the tool result.
|
||||
and exposes their tools alongside built-in tools. The MCP SDK is fully async; turnstone
|
||||
bridges this with a background asyncio event loop in a daemon thread.
|
||||
|
||||
**Configuration sources:** MCP servers can be defined in config files (TOML/JSON)
|
||||
or in the database via the admin UI. Database-backed definitions are managed
|
||||
through the console admin panel's MCP Servers tab and stored in the
|
||||
`mcp_servers` table. On startup, `load_mcp_config(storage=)` uses
|
||||
first-match-wins priority: DB rows (if any enabled) take precedence over
|
||||
config files. The console can trigger a cluster-wide reload (`POST
|
||||
/_internal/mcp-reload`) that causes each node to call `reconcile_sync()`,
|
||||
which diffs the running MCP connections against the current DB state and
|
||||
adds, removes, or reconnects servers as needed.
|
||||
|
||||
**Lifecycle:**
|
||||
1. `create_mcp_client()` reads server configs from TOML or JSON
|
||||
1. `create_mcp_client()` reads server configs from TOML/JSON and database
|
||||
2. `MCPClientManager.start()` launches the background event loop thread
|
||||
3. `_connect_all()` connects to each server (stdio subprocess or HTTP), runs
|
||||
`initialize()` + `list_tools()`, converts schemas to OpenAI format
|
||||
4. `ChatSession.__init__` receives the manager and builds `self._tools` (built-in + MCP)
|
||||
`initialize()` + `list_tools()`, converts schemas to OpenAI format, detects
|
||||
`tools.listChanged` capability for push notification support
|
||||
4. `ChatSession.__init__` receives the manager, builds `self._tools` (built-in + MCP),
|
||||
and registers a listener callback for tool-change notifications
|
||||
5. `_prepare_tool()` routes MCP tools to `_prepare_mcp_tool()` / `_exec_mcp_tool()`
|
||||
6. `_exec_mcp_tool()` calls `call_tool_sync()` which dispatches to the async loop
|
||||
via `asyncio.run_coroutine_threadsafe()`
|
||||
|
||||
**Tool refresh:** Three mechanisms keep tools up-to-date without restart:
|
||||
- **Push:** Servers declaring `tools.listChanged` send `ToolListChangedNotification`;
|
||||
the registered `message_handler` triggers immediate single-server refresh.
|
||||
- **Periodic:** Servers without push support are polled on a staggered interval
|
||||
(default 4 h, configurable via `[mcp] refresh_interval` or `--mcp-refresh-interval`).
|
||||
- **Manual:** `/mcp refresh [server]` calls `refresh_sync()` for on-demand refresh
|
||||
(also attempts reconnection for disconnected servers).
|
||||
|
||||
When tools change, `_rebuild_tools()` creates new `_tools`/`_tool_map` objects
|
||||
(copy-on-write for thread safety) and notifies listener callbacks. Each `ChatSession`
|
||||
rebuilds its merged tool lists and reconstructs `ToolSearchManager` (preserving
|
||||
expanded tools).
|
||||
|
||||
**Tool naming:** `mcp__{server}__{tool}` — double underscore delimiter, validated
|
||||
at connection time (server names with `__` are rejected).
|
||||
|
||||
**Error isolation:** Per-server connection failures are caught and logged; other
|
||||
servers still connect. Tool execution errors return error strings to the LLM
|
||||
**Error isolation:** Per-server connection/refresh failures are caught and logged; other
|
||||
servers are unaffected. Tool execution errors return error strings to the LLM
|
||||
rather than crashing the session.
|
||||
|
||||
### Provider Adapter Layer
|
||||
@@ -535,21 +582,23 @@ LLMProvider (protocol)
|
||||
|------|--------|
|
||||
| `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` |
|
||||
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision` |
|
||||
| `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`).
|
||||
already in OpenAI format), including multi-part content blocks (text + images)
|
||||
in tool results. Model capability lookup table covers GPT-5/5.1/5.2/5.3/5.4,
|
||||
O-series, and search models (`gpt-5-search-api`) — all with `supports_vision`.
|
||||
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.
|
||||
permissive defaults with `supports_vision=False` 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
|
||||
blocks (converting `image_url` parts to Anthropic's `image` source format),
|
||||
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
|
||||
@@ -595,6 +644,18 @@ agent_model = "claude"
|
||||
|
||||
Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
|
||||
(default: `"openai"`). Supported values: `"openai"` and `"anthropic"`.
|
||||
An optional `[models.*.capabilities]` sub-table overrides per-model
|
||||
`ModelCapabilities` flags (useful for local models whose capabilities
|
||||
cannot be detected programmatically):
|
||||
|
||||
```toml
|
||||
[models.qwen-vl]
|
||||
base_url = "http://localhost:8000/v1"
|
||||
model = "qwen-3.5-vl"
|
||||
|
||||
[models.qwen-vl.capabilities]
|
||||
supports_vision = true
|
||||
```
|
||||
|
||||
**Lifecycle:**
|
||||
1. `load_model_registry()` reads `[models.*]` sections from config.toml and
|
||||
@@ -615,7 +676,8 @@ Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
|
||||
|
||||
**Per-workstream selection:** `POST /v1/api/workstreams/new` accepts an optional
|
||||
`"model"` field. The bridge `CreateWorkstreamMessage` carries the same field
|
||||
through the MQ protocol.
|
||||
through the MQ protocol, along with `ws_template` (workstream template name)
|
||||
which can override the model before workstream creation.
|
||||
|
||||
### Tool Output Truncation
|
||||
|
||||
@@ -679,8 +741,11 @@ memories
|
||||
created TEXT NOT NULL
|
||||
updated TEXT NOT NULL
|
||||
|
||||
sessions
|
||||
session_id TEXT PRIMARY KEY
|
||||
workstreams
|
||||
ws_id TEXT PRIMARY KEY
|
||||
node_id TEXT NOT NULL
|
||||
name TEXT NOT NULL
|
||||
state TEXT NOT NULL DEFAULT 'idle'
|
||||
alias TEXT UNIQUE -- user-assigned short name (nullable)
|
||||
title TEXT -- LLM-generated title (nullable)
|
||||
created TEXT NOT NULL
|
||||
@@ -688,7 +753,7 @@ sessions
|
||||
|
||||
conversations
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
session_id TEXT NOT NULL
|
||||
ws_id TEXT NOT NULL
|
||||
timestamp TEXT NOT NULL
|
||||
role TEXT NOT NULL -- user | assistant | tool_call | tool_result
|
||||
content TEXT
|
||||
@@ -697,8 +762,8 @@ conversations
|
||||
tool_call_id TEXT -- links tool_call ↔ tool_result for resume
|
||||
provider_data TEXT -- raw provider content (e.g. Anthropic encrypted)
|
||||
|
||||
session_config
|
||||
session_id TEXT NOT NULL -- composite PK with key
|
||||
workstream_config
|
||||
ws_id TEXT NOT NULL -- composite PK with key
|
||||
key TEXT NOT NULL
|
||||
value TEXT
|
||||
|
||||
@@ -713,18 +778,21 @@ and are the single source of truth for both backends and Alembic migrations.
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `register_session(session_id, title)` | Create a sessions row (no-op if exists) |
|
||||
| `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 |
|
||||
| `update_session_title(session_id, title)` | Set/update LLM-generated title |
|
||||
| `register_workstream(ws_id, node_id, name, state)` | Create a workstreams row (no-op if exists) |
|
||||
| `save_message(ws_id, role, content, ...)` | Log a message to conversations |
|
||||
| `load_messages(ws_id)` | Reconstruct OpenAI message format from DB rows |
|
||||
| `list_workstreams_with_history(limit)` | List workstreams with >=1 message, ordered by updated DESC |
|
||||
| `delete_workstream(ws_id)` | Delete workstream and cascade conversations + config |
|
||||
| `prune_workstreams(retention_days)` | Remove empty workstreams and old unnamed workstreams |
|
||||
| `resolve_workstream(alias_or_id)` | Resolve alias, exact id, or id prefix to full ws_id |
|
||||
| `save_workstream_config(ws_id, config)` | Persist workstream configuration key/value pairs |
|
||||
| `load_workstream_config(ws_id)` | Retrieve workstream configuration |
|
||||
| `set_workstream_alias(ws_id, alias)` | Set user-friendly alias (returns False if taken) |
|
||||
| `get_workstream_display_name(ws_id)` | Return alias if set, else title, else None |
|
||||
| `update_workstream_title(ws_id, title)` | Set/update LLM-generated title |
|
||||
| `update_workstream_state(ws_id, state)` | Update workstream state and bump timestamp |
|
||||
| `update_workstream_name(ws_id, name)` | Update workstream display name |
|
||||
| `list_workstreams(node_id, limit)` | List workstreams, optionally by node |
|
||||
| `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) |
|
||||
@@ -743,57 +811,59 @@ pool_size = 5 # PostgreSQL connection pool size
|
||||
|
||||
Environment variables: `TURNSTONE_DB_BACKEND`, `TURNSTONE_DB_URL`, `TURNSTONE_DB_PATH`.
|
||||
|
||||
### Session Persistence and Resume
|
||||
### Persistence and Resume
|
||||
|
||||
Each `ChatSession` generates a 12-char hex `_session_id` on creation and
|
||||
registers it in the `sessions` table. Messages are saved to `conversations`
|
||||
as they happen via `save_message()`.
|
||||
`ws_id` is the sole persistent identity for both routing and conversation
|
||||
history. There is no separate `session_id` — the `workstreams` table holds
|
||||
alias, title, and state alongside the routing fields (`node_id`, `name`).
|
||||
Messages are saved to `conversations` (keyed by `ws_id`) as they happen
|
||||
via `save_message()`. Workstream state changes are tracked via
|
||||
`update_workstream_state()`.
|
||||
|
||||
**Auto-titling:** After the first complete exchange (user message + assistant
|
||||
response), a background thread calls the LLM with a title-generation prompt
|
||||
(`reasoning_effort: "low"`, `max_completion_tokens: 200`). The generated
|
||||
title (3-8 words) is stored in `sessions.title`.
|
||||
title (3-8 words) is stored in `workstreams.title`.
|
||||
|
||||
**Resume flow:** `ChatSession.resume_session(session_id)` calls
|
||||
`load_session_messages()` which reconstructs the OpenAI message format from
|
||||
database rows:
|
||||
**Resume flow:** `ChatSession.resume(ws_id)` calls `load_messages()` which
|
||||
reconstructs the OpenAI message format from database rows:
|
||||
|
||||
- `user` and `assistant` rows map directly
|
||||
- Consecutive `tool_call` rows are grouped into one assistant message's
|
||||
`tool_calls` array, paired with subsequent `tool_result` rows via
|
||||
`tool_call_id` (or positional matching for legacy data)
|
||||
- **Interrupted session repair:** If the last assistant message has
|
||||
`tool_calls` but fewer tool results than expected (session was
|
||||
- **Interrupted conversation repair:** If the last assistant message has
|
||||
`tool_calls` but fewer tool results than expected (conversation was
|
||||
interrupted mid-execution), the incomplete turn is stripped so the
|
||||
LLM can re-generate cleanly
|
||||
- The session adopts the old `_session_id`, so new messages continue in
|
||||
the same session
|
||||
- The `ChatSession` adopts the resumed `_ws_id`, so new messages continue
|
||||
in the same workstream
|
||||
|
||||
**Config persistence:** LLM-affecting parameters (`temperature`,
|
||||
`reasoning_effort`, `max_tokens`, `instructions`, `creative_mode`) are
|
||||
persisted to the `session_config` table on creation and whenever changed
|
||||
via slash commands. `resume_session()` restores these values so resumed
|
||||
sessions behave identically to the original.
|
||||
persisted to the `workstream_config` table on creation and whenever changed
|
||||
via slash commands. `resume()` restores these values so resumed workstreams
|
||||
behave identically to the original.
|
||||
|
||||
**`/clear` vs `/new`:** `/clear` wipes in-memory context but preserves
|
||||
messages in the database for future resume. `/new` starts a fresh session
|
||||
(new `_session_id`), leaving the old session resumable.
|
||||
messages in the database for future resume. `/new` starts a fresh workstream
|
||||
(new `_ws_id`), leaving the old workstream resumable.
|
||||
|
||||
**Resolution:** `resolve_session()` accepts aliases, exact session IDs, or
|
||||
session ID prefixes, enabling `turnstone --resume refactor` or `/resume abc12`.
|
||||
**Resolution:** `resolve_workstream()` accepts aliases, exact workstream IDs,
|
||||
or ID prefixes, enabling `turnstone --resume refactor` or `/resume abc12`.
|
||||
|
||||
**Session listing:** `list_sessions()` only returns sessions that have at
|
||||
least one saved message (`WHERE EXISTS` on `conversations`). Sessions
|
||||
registered but never used (e.g., from process startup) are invisible until
|
||||
a message is sent.
|
||||
**Workstream listing:** `list_workstreams_with_history()` only returns
|
||||
workstreams that have at least one saved message (`WHERE EXISTS` on
|
||||
`conversations`). Workstreams registered but never used (e.g., from process
|
||||
startup) are invisible until a message is sent.
|
||||
|
||||
**Session pruning:** `prune_sessions(retention_days, log_fn)` runs once at
|
||||
startup (CLI and server). It removes:
|
||||
- Sessions with no messages (orphaned registrations)
|
||||
- Unnamed sessions (`alias IS NULL`) older than `retention_days` days (default 90)
|
||||
**Workstream pruning:** `prune_workstreams(retention_days, log_fn)` runs once
|
||||
at startup (CLI and server). It removes:
|
||||
- Workstreams with no messages (orphaned registrations)
|
||||
- Unnamed workstreams (`alias IS NULL`) older than `retention_days` days (default 90)
|
||||
|
||||
Named (aliased) sessions are never age-pruned. Configure with
|
||||
`--session-retention-days N` (0 = disable age pruning).
|
||||
Named (aliased) workstreams are never age-pruned. Configure with
|
||||
`--retention-days N` (0 = disable age pruning).
|
||||
|
||||
---
|
||||
|
||||
@@ -910,6 +980,98 @@ limits using a token-bucket algorithm. Each IP gets a `TokenBucket` with
|
||||
|
||||
---
|
||||
|
||||
## User Identity and Authentication
|
||||
|
||||
Turnstone supports three authentication mechanisms, unified behind an
|
||||
`AuthResult` dataclass that carries `user_id`, `scopes`, and `token_source`:
|
||||
|
||||
1. **Config-file tokens** — static secrets in `config.toml` `[[auth.tokens]]`
|
||||
or the `TURNSTONE_AUTH_TOKEN` env var. Validated in-memory via
|
||||
`hmac.compare_digest`. Map to scopes through their role (`read` or `full`).
|
||||
2. **API tokens** — database-backed, prefixed `ts_`, stored as SHA-256 hashes
|
||||
in the `api_tokens` table. Can be exchanged for JWTs via
|
||||
`POST /v1/api/auth/login`.
|
||||
3. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
|
||||
successful credential validation. Contain `sub` (user_id), `scopes`, and
|
||||
`src` (origin) in claims.
|
||||
|
||||
### Scope Model
|
||||
|
||||
Three hierarchical scopes control endpoint access:
|
||||
|
||||
| Scope | Grants | Endpoints |
|
||||
|-------|--------|-----------|
|
||||
| `read` | SSE streams, workstream listing, history | GET endpoints |
|
||||
| `write` | `read` + send, command, workstream create/close | POST to `/api/send`, `/api/command`, etc. |
|
||||
| `approve` | `write` + tool approval, admin operations | POST to `/api/approve`, `/api/admin/*` |
|
||||
|
||||
### Middleware Flow
|
||||
|
||||
`AuthMiddleware` (ASGI) intercepts every request:
|
||||
|
||||
1. **Public path check** — `/`, `/static/*`, `/shared/*`, `/health`,
|
||||
`/metrics`, `/openapi.json`, `/docs`, `/api/auth/*`, and `/api/auth/setup`
|
||||
are always allowed.
|
||||
2. **Token extraction** — `Authorization: Bearer <token>` header first, then
|
||||
`turnstone_auth` cookie as fallback.
|
||||
3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix
|
||||
indicates API token; otherwise config-file token.
|
||||
4. **Validation** — JWT signature check, API token hash lookup in storage, or
|
||||
config-token hmac comparison.
|
||||
5. **Scope check** — `required_scope(method, path)` determines the minimum
|
||||
scope; the request is rejected with 403 if the token lacks it.
|
||||
6. **Context propagation** — on success, `ctx_user_id` is set so structured
|
||||
logging includes the authenticated identity on every log event.
|
||||
|
||||
### Architecture Split
|
||||
|
||||
- **Console** is the auth management hub — it hosts the admin endpoints for
|
||||
creating users, issuing API tokens, and managing channel mappings. User
|
||||
records and token hashes live in the shared storage backend. The console
|
||||
dashboard includes an **admin panel** (14 tabs) for managing
|
||||
credentials, governance, MCP servers, and runtime settings through the browser.
|
||||
- **Server** is a JWT validator only — it validates tokens on each request but
|
||||
never creates users or tokens. Both processes share the same `jwt_secret`
|
||||
(via `TURNSTONE_JWT_SECRET` env var or `[auth].jwt_secret` config).
|
||||
- **First-time setup** — both server and console expose
|
||||
`POST /v1/api/auth/setup`, a public endpoint that creates the initial admin
|
||||
user when no users exist. This avoids the chicken-and-egg problem of needing
|
||||
`approve` scope to create the first user via `/api/admin/users`.
|
||||
|
||||
### Auth Storage Tables
|
||||
|
||||
Three tables in `storage/_schema.py` support identity:
|
||||
|
||||
```sql
|
||||
users
|
||||
user_id TEXT PRIMARY KEY
|
||||
username TEXT NOT NULL UNIQUE
|
||||
display_name TEXT NOT NULL
|
||||
password_hash TEXT NOT NULL -- bcrypt
|
||||
created TEXT NOT NULL
|
||||
|
||||
api_tokens
|
||||
token_id TEXT PRIMARY KEY
|
||||
token_hash TEXT NOT NULL UNIQUE -- SHA-256 of raw token
|
||||
token_prefix TEXT NOT NULL -- first 8 chars for display
|
||||
user_id TEXT NOT NULL
|
||||
name TEXT NOT NULL -- human-readable label
|
||||
scopes TEXT NOT NULL -- comma-separated
|
||||
created TEXT NOT NULL
|
||||
expires TEXT -- optional expiry timestamp
|
||||
|
||||
channel_users
|
||||
channel_type TEXT NOT NULL -- e.g. "slack", "discord"
|
||||
channel_user_id TEXT NOT NULL -- platform-specific user ID
|
||||
user_id TEXT NOT NULL -- FK to users
|
||||
PRIMARY KEY (channel_type, channel_user_id)
|
||||
```
|
||||
|
||||
See [docs/security.md](security.md) for full security details including token
|
||||
lifecycle, password hashing, and deployment hardening.
|
||||
|
||||
---
|
||||
|
||||
## Threading Model
|
||||
|
||||
### CLI
|
||||
@@ -973,7 +1135,7 @@ context manager handles startup/shutdown (health monitor, MCP client,
|
||||
registry).
|
||||
|
||||
Each workstream's `WebUI` has:
|
||||
- `_event_queue` (per-workstream SSE events, `queue.Queue`)
|
||||
- `_listeners` (per-client SSE queues, fan-out on `_enqueue()`)
|
||||
- `_approval_event` / `_plan_event` (`threading.Event` for blocking)
|
||||
- `_global_queue` (class variable, shared, for state broadcasts)
|
||||
|
||||
@@ -1033,17 +1195,27 @@ bridge auto-approves via `POST /v1/api/approve`. Otherwise, it publishes an
|
||||
`BLPOP` of a Redis response queue (`turnstone:resp:{request_id}`) until the client pushes
|
||||
a response or the approval timeout (default 3600s / 1 hour) expires.
|
||||
|
||||
**Cancellation:** The `CancelMessage` (type `"cancel"`) is a routed inbound message.
|
||||
The bridge dispatches it to `POST /v1/api/cancel` on the server owning the workstream,
|
||||
which sets the cooperative cancel flag and unblocks any pending approval/plan waits.
|
||||
|
||||
**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
|
||||
workstream, the bridge emits a synthetic `TurnCompleteEvent` with the correlation ID.
|
||||
|
||||
**Multi-node routing:** Each bridge has a `node_id` (defaults to hostname) and BLPOPs
|
||||
**Multi-node routing:** Each bridge retrieves its `node_id` from the server's
|
||||
`/health` endpoint on startup (with exponential backoff retry). The server
|
||||
generates the `node_id` (`{hostname}_{4hex}`) and is the sole authority for
|
||||
node identity. The bridge BLPOPs
|
||||
from both `turnstone:inbound:{node_id}` (directed, priority) and `turnstone:inbound` (shared).
|
||||
Messages with `target_node` set are pushed to the target's per-node queue. Messages
|
||||
for existing workstreams are auto-routed via `turnstone:ws:{ws_id}` ownership keys in Redis.
|
||||
If a bridge picks up a shared-queue message for a workstream owned by another node, it
|
||||
re-routes to that node's queue (1 extra hop). Bridges publish heartbeats to
|
||||
`turnstone:node:{node_id}` with configurable TTL for node discovery.
|
||||
On startup, `_recover_workstreams` re-registers ownership of existing
|
||||
workstreams and publishes `WorkstreamCreatedEvent` to the cluster channel
|
||||
so the console collector picks them up immediately.
|
||||
|
||||
### Cluster Console
|
||||
|
||||
@@ -1069,14 +1241,21 @@ 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.
|
||||
for parallel HTTP polling. The poll loop diffs workstream IDs between poll
|
||||
cycles and fans out synthetic `ws_created`/`ws_closed` SSE events for any
|
||||
changes, ensuring browser clients stay in sync even when real-time cluster
|
||||
events are missed (e.g. bridge startup recovery).
|
||||
|
||||
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.
|
||||
the most available capacity if no target is specified. When a `ws_template`
|
||||
field is present, the server resolves the template BEFORE `mgr.create()`
|
||||
(applying the model override to the creation request) and snapshot-applies
|
||||
remaining settings (auto-approve, token budget, temperature, etc.) to the
|
||||
workstream config AFTER creation.
|
||||
|
||||
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.
|
||||
@@ -1128,7 +1307,7 @@ typed event dataclasses.
|
||||
|
||||
**Two client pairs** (sync + async):
|
||||
|
||||
- `TurnstoneServer` / `AsyncTurnstoneServer` — server API (workstreams, chat, streaming, sessions)
|
||||
- `TurnstoneServer` / `AsyncTurnstoneServer` — server API (workstreams, chat, streaming)
|
||||
- `TurnstoneConsole` / `AsyncTurnstoneConsole` — console API (cluster overview, nodes, workstreams)
|
||||
|
||||
**Design**: async-first with thin sync wrappers. `_BaseClient` provides httpx
|
||||
@@ -1152,3 +1331,111 @@ with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
result = client.send_and_wait("Hello!", ws.ws_id)
|
||||
print(result.content)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Channel Integrations
|
||||
|
||||
> See also: [Channel Integrations guide](channels.md)
|
||||
|
||||
The `turnstone-channel` gateway bridges external messaging platforms
|
||||
(Discord, Slack, Teams) to the turnstone cluster via Redis MQ. Each
|
||||
platform adapter implements the `ChannelAdapter` protocol and translates
|
||||
between platform-native events and turnstone MQ messages.
|
||||
|
||||
The `ChannelRouter` manages bidirectional routing: it maps platform
|
||||
channel/thread IDs to turnstone workstream IDs, handles workstream
|
||||
creation and stale-route recovery, and resolves platform users to
|
||||
turnstone identities via the `channel_users` table. When an evicted
|
||||
workstream is reactivated, the router uses atomic resume via the
|
||||
`resume_ws` field on `CreateWorkstreamMessage` — the server resumes
|
||||
the old workstream's conversation during creation in a single HTTP
|
||||
request, eliminating ordering fragility. The bridge emits a
|
||||
`WorkstreamResumedEvent` to confirm success.
|
||||
|
||||
Discord ships as the first adapter. See [channels.md](channels.md) for
|
||||
setup instructions, configuration reference, and the adapter development
|
||||
guide.
|
||||
|
||||
### Notification Subsystem
|
||||
|
||||
The `notify` tool enables the LLM to send notifications to users or
|
||||
channels without going through MQ. The server calls the channel gateway
|
||||
directly over HTTP for lower latency: `_exec_notify()` queries the
|
||||
`services` database table for healthy channel gateways (heartbeat within
|
||||
120 seconds), authenticates with a service JWT (`aud: turnstone-channel`),
|
||||
and POSTs to `POST /v1/api/notify` on the first healthy gateway. The
|
||||
gateway validates the JWT, resolves the target (username lookup via
|
||||
`channel_users` or direct `channel_type`+`channel_id`), and delegates to
|
||||
the appropriate `ChannelAdapter.send()`. Delivery retries up to 3 times
|
||||
with backoff, re-querying the service registry on each attempt. See
|
||||
[Notification Flow diagram](diagrams/png/17-notify-flow.png).
|
||||
|
||||
---
|
||||
|
||||
## Governance
|
||||
|
||||
> See also: [Governance documentation](governance.md) | [Governance Architecture diagram](diagrams/19-governance-architecture.puml)
|
||||
|
||||
Turnstone governance extends the Phase 1 auth system with role-based access
|
||||
control (RBAC), tool execution policies, prompt templates, usage tracking,
|
||||
and audit logging. The permission model has two layers: legacy scopes
|
||||
(`read`, `write`, `approve`) checked by `AuthMiddleware`, and 15 granular
|
||||
permissions checked per-endpoint by `require_permission()`. Three built-in
|
||||
roles (admin, operator, viewer) are seeded by migration 008; custom roles
|
||||
can be created with any permission subset. JWTs carry both `scopes` and
|
||||
`permissions` claims for backward compatibility.
|
||||
|
||||
Tool policies use glob pattern matching (`fnmatch`) with priority-ordered
|
||||
first-match-wins evaluation to control tool execution (allow/deny/ask).
|
||||
Prompt templates provide reusable system messages with `{{variable}}`
|
||||
substitution. Usage events are recorded per-LLM-request for token
|
||||
accounting. An append-only audit log captures all admin mutations.
|
||||
|
||||
Workstream templates build on top of prompt templates as complete behavioral
|
||||
profiles applied at workstream creation. While prompt templates inject system
|
||||
message text, workstream templates define model, temperature, reasoning effort,
|
||||
max tokens, auto-approve policy, token budget, and agent max turns. Templates
|
||||
are snapshot-applied once at creation — not a live binding. The
|
||||
`workstream_templates` table (migration 011) supports auto-versioning, and
|
||||
workstreams record which template and version spawned them. Token budget
|
||||
enforcement tracks consumption in `session.send()` with 80% warning and
|
||||
100% approval gate via the `__budget_override__` synthetic tool name.
|
||||
|
||||
The console admin panel adds 6 governance tabs (Roles, Policies, Templates,
|
||||
WS Templates, Usage, Audit), a Memories tab, a Settings tab (form-based
|
||||
editor for all ConfigStore settings), and an MCP Servers tab (database-backed
|
||||
server definitions with live connection status and cluster-wide reload) for a
|
||||
total of 14 tabs, all permission-gated.
|
||||
Both Python and TypeScript SDKs expose governance methods on the console
|
||||
client.
|
||||
|
||||
## Intent Validation
|
||||
|
||||
> See also: [Intent Validation guide](judge.md) | [Judge Architecture diagram](diagrams/png/22-judge-architecture.png)
|
||||
|
||||
Intent validation provides advisory risk assessments for tool calls that
|
||||
require human approval. The system runs a two-tier evaluation pipeline
|
||||
implemented in `turnstone/core/judge.py`:
|
||||
|
||||
1. **Heuristic tier** (synchronous, sub-millisecond) -- A priority-ordered
|
||||
rule table using fnmatch tool patterns and regex argument patterns. Four
|
||||
severity levels: critical (deny), high (review), medium (review), low
|
||||
(approve). First match wins. The heuristic verdict is attached to the
|
||||
`approve_request` SSE event immediately.
|
||||
|
||||
2. **LLM judge tier** (asynchronous, daemon thread) -- A multi-turn evaluation
|
||||
where the judge LLM receives conversation context and tool call details,
|
||||
optionally uses `read_file`/`list_directory` to gather evidence (with
|
||||
security-hardened path blocking), and produces a structured JSON verdict.
|
||||
If the LLM verdict has higher confidence than the heuristic, it replaces
|
||||
it via an `intent_verdict` SSE event.
|
||||
|
||||
The judge is session-scoped (`IntentJudge`), lazy-initialized on first
|
||||
approval, and configured via the `[judge]` config section or `--judge` CLI
|
||||
flags. By default it uses self-consistency (same model), but supports
|
||||
cross-model and cross-provider configurations. Sub-agents (plan, task)
|
||||
are exempt. All verdicts are persisted to the `intent_verdicts` table
|
||||
(migration 012) with the user's final decision, enabling future calibration.
|
||||
The console exposes `GET /v1/api/admin/verdicts` for audit queries
|
||||
(requires `admin.judge` permission).
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# Channel Integrations
|
||||
|
||||
The `turnstone-channel` gateway connects external messaging platforms to
|
||||
turnstone workstreams via Redis MQ. Each platform adapter translates
|
||||
platform-native events (messages, button clicks, slash commands) into
|
||||
turnstone MQ messages, and renders workstream output back into the
|
||||
platform's UI.
|
||||
|
||||
Discord ships as the first adapter. The adapter protocol is designed for
|
||||
future Slack and Teams integrations.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Discord Gateway
|
||||
|
|
||||
v
|
||||
turnstone-channel (Discord adapter)
|
||||
|
|
||||
v
|
||||
Redis MQ
|
||||
|
|
||||
v
|
||||
turnstone-bridge ──> turnstone-server
|
||||
```
|
||||
|
||||
Key components:
|
||||
|
||||
- **ChannelAdapter protocol** (`turnstone/channels/_protocol.py`) — generic
|
||||
interface for any messaging platform. Defines `start()`, `stop()`,
|
||||
`send()`, `edit_message()`, `send_approval_request()`,
|
||||
`send_plan_review()`, and `create_thread()`.
|
||||
- **ChannelRouter** (`turnstone/channels/_routing.py`) — maps
|
||||
channel/thread IDs to turnstone workstream IDs. Handles workstream
|
||||
creation via MQ, stale route detection, and user identity resolution.
|
||||
- **AsyncRedisBroker** (`turnstone/mq/async_broker.py`) — async Redis
|
||||
client compatible with discord.py's event loop. Used by the router for
|
||||
pub/sub and queue operations.
|
||||
- **channel_users table** — maps `(channel_type, channel_user_id)` to a
|
||||
turnstone `user_id`. Messages from unlinked users are silently dropped.
|
||||
- **channel_routes table** — persistent channel-to-workstream mappings.
|
||||
Survives bot restarts. Stale routes (evicted workstreams) are detected
|
||||
and refreshed on the next message.
|
||||
|
||||
---
|
||||
|
||||
## Discord Setup
|
||||
|
||||
### 1. Create a Discord Application
|
||||
|
||||
1. Go to https://discord.com/developers/applications
|
||||
2. Click **New Application** and give it a name
|
||||
3. Navigate to the **Bot** tab and click **Reset Token** to generate a
|
||||
bot token. Copy it immediately — it is shown only once.
|
||||
4. On the same **Bot** tab, scroll down to **Privileged Gateway Intents**
|
||||
and enable **MESSAGE CONTENT INTENT**
|
||||
5. Navigate to **OAuth2 > URL Generator**
|
||||
6. Under **Scopes**, check `bot` and `applications.commands`
|
||||
7. Under **Bot Permissions**, check:
|
||||
- View Channels
|
||||
- Send Messages
|
||||
- Send Messages in Threads
|
||||
- Create Public Threads
|
||||
- Read Message History
|
||||
- Add Reactions
|
||||
- Embed Links
|
||||
8. Copy the generated URL, open it in a browser, and add the bot to your
|
||||
Discord server
|
||||
|
||||
### 2. Configure Turnstone
|
||||
|
||||
**Environment variables** (recommended for Docker):
|
||||
|
||||
```bash
|
||||
TURNSTONE_DISCORD_TOKEN=your-bot-token-here
|
||||
TURNSTONE_DISCORD_GUILD=123456789 # optional, restrict to one guild
|
||||
```
|
||||
|
||||
**CLI flags** (bare-metal):
|
||||
|
||||
```bash
|
||||
turnstone-channel \
|
||||
--discord-token "your-bot-token" \
|
||||
--discord-guild 123456789 \
|
||||
--redis-host localhost \
|
||||
--redis-port 6379
|
||||
```
|
||||
|
||||
**Docker Compose** (production profile):
|
||||
|
||||
```bash
|
||||
# In .env file:
|
||||
TURNSTONE_DISCORD_TOKEN=your-bot-token
|
||||
TURNSTONE_DISCORD_GUILD=123456789
|
||||
```
|
||||
|
||||
Then start the stack:
|
||||
|
||||
```bash
|
||||
docker compose --profile production up
|
||||
```
|
||||
|
||||
The `channel` service starts automatically when
|
||||
`TURNSTONE_DISCORD_TOKEN` is set.
|
||||
|
||||
### 3. Link User Accounts
|
||||
|
||||
Discord users must link their account to a turnstone user before they can
|
||||
interact with the bot. Unlinked users' messages are silently ignored.
|
||||
|
||||
1. The user must have a turnstone API token — created via the admin panel
|
||||
or `turnstone-admin create-token`
|
||||
2. In Discord, the user runs `/link`. A modal appears prompting for the
|
||||
API token (the token is never visible in Discord audit logs because it
|
||||
is submitted via modal, not as a slash command argument).
|
||||
3. The token is validated against the database. If valid, a
|
||||
`channel_users` mapping is created.
|
||||
4. The user can now @mention the bot or use slash commands.
|
||||
|
||||
An admin can also force-link or unlink users via the console admin panel
|
||||
(Admin > Channels tab).
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Conversations
|
||||
|
||||
- **@mention** the bot in any allowed channel to start a new conversation.
|
||||
The bot creates a Discord thread from the message and a turnstone
|
||||
workstream behind it.
|
||||
- All subsequent messages in the thread are routed to the same workstream.
|
||||
- The bot streams responses via message edits, updated approximately every
|
||||
1.5 seconds.
|
||||
- If the workstream is evicted for capacity, the next message in the
|
||||
thread auto-creates a new workstream and atomically resumes the
|
||||
previous workstream via the `resume_ws` field on
|
||||
`CreateWorkstreamMessage`. The server resumes the workstream during
|
||||
creation (same HTTP request), and the bridge emits a
|
||||
`WorkstreamResumedEvent` back to the channel. The thread receives a
|
||||
*"Resumed: {name} ({count} messages restored)"* confirmation.
|
||||
|
||||
### Slash Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/link` | Link Discord account to turnstone (opens modal for API token) |
|
||||
| `/unlink` | Unlink Discord account |
|
||||
| `/ask <message>` | Create a new thread and workstream with an initial message |
|
||||
| `/status` | Show workstream info for the current thread (ephemeral) |
|
||||
| `/close` | Close the workstream, delete the route, and archive the thread |
|
||||
|
||||
### Tool Approvals
|
||||
|
||||
When manual approval is enabled (the default), tool calls are displayed as
|
||||
an orange embed with:
|
||||
|
||||
- Tool name and argument preview
|
||||
- **Approve** (green), **Reject** (red), **Always Approve** (gray) buttons
|
||||
- Only linked users can interact with approval buttons
|
||||
- The approval decision is forwarded through MQ to the bridge, which
|
||||
relays it to the server
|
||||
|
||||
Buttons use static `custom_id` values so they survive bot restarts.
|
||||
Correlation data (`ws_id`, `correlation_id`) is stored in the embed footer.
|
||||
|
||||
**Auto-approval:** When `auto_approve` is true (via `--auto-approve`), or when
|
||||
all tools in the request match the `auto_approve_tools` list in the adapter
|
||||
config, the bot auto-responds with approval and posts a
|
||||
"*Tool auto-approved.*" notice to the thread instead of showing buttons. The
|
||||
`auto_approve_tools` list is set via the `ChannelConfig.auto_approve_tools`
|
||||
field (useful for allowing specific tools like `bash` or `read_file` while
|
||||
still requiring manual approval for others).
|
||||
|
||||
### Plan Reviews
|
||||
|
||||
Plan review requests are displayed as a blue embed with:
|
||||
|
||||
- **Approve Plan** (green) button — approves the plan with empty feedback
|
||||
- **Request Changes** (gray) button — opens a modal for feedback text
|
||||
(up to 2000 characters)
|
||||
- Feedback is forwarded through MQ as a `PlanFeedbackMessage`
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| CLI Flag | Env Var | Default | Description |
|
||||
|----------|---------|---------|-------------|
|
||||
| `--discord-token` | `TURNSTONE_DISCORD_TOKEN` | — | Bot token (required to enable Discord) |
|
||||
| `--discord-guild` | — | `0` (all guilds) | Restrict to a single Discord guild |
|
||||
| `--discord-channels` | — | empty (all) | Comma-separated channel IDs to allow |
|
||||
| `--redis-host` | `REDIS_HOST` | `localhost` | Redis host |
|
||||
| `--redis-port` | — | `6379` | Redis port |
|
||||
| `--redis-password` | `REDIS_PASSWORD` | — | Redis password |
|
||||
| `--redis-db` | — | `0` | Redis DB number |
|
||||
| `--model` | — | server default | Default model for new workstreams |
|
||||
| `--auto-approve` | — | `false` | Auto-approve ALL tool calls (skips approval buttons entirely) |
|
||||
| `--http-host` | — | `127.0.0.1` | HTTP server bind address for notify endpoint |
|
||||
| `--http-port` | `TURNSTONE_CHANNEL_PORT` | `8091` | HTTP server port |
|
||||
| `--auth-token` | `TURNSTONE_CHANNEL_AUTH_TOKEN` | — | Static auth token for `/v1/api/notify` (alternative to JWT) |
|
||||
| `--log-level` | `TURNSTONE_LOG_LEVEL` | `INFO` | Log level |
|
||||
| `--log-format` | `TURNSTONE_LOG_FORMAT` | `auto` | Log format (`auto`/`json`/`text`) |
|
||||
|
||||
---
|
||||
|
||||
## User Identity
|
||||
|
||||
- The `channel_users` table maps `(channel_type, channel_user_id)` to a
|
||||
turnstone `user_id`
|
||||
- Self-service linking via the `/link` slash command (modal input, not
|
||||
visible in Discord audit logs)
|
||||
- Admin can force-link or unlink via the console admin panel (Admin >
|
||||
Channels tab). Unlinking uses a styled confirmation modal.
|
||||
- Unlinked users' messages are silently dropped
|
||||
- A user can be linked across multiple platforms (e.g. Discord + Slack)
|
||||
|
||||
See [Security: Database Schema](security.md#database-schema) for the
|
||||
`channel_users` table definition.
|
||||
|
||||
---
|
||||
|
||||
## Workstream Lifecycle
|
||||
|
||||
1. **Creation** — @mention or `/ask` creates a Discord thread and a
|
||||
turnstone workstream. The `ChannelRouter` persists the mapping in the
|
||||
`channel_routes` table.
|
||||
2. **Active** — messages are routed bidirectionally. The bot streams
|
||||
responses via message edits (updated every ~1.5 seconds).
|
||||
3. **Eviction** — the server evicts an idle workstream for capacity. The
|
||||
route is preserved and the thread stays open.
|
||||
4. **Reactivation** — the next message in the thread detects the stale
|
||||
route (no MQ owner) and creates a new workstream with the old `ws_id`
|
||||
as `resume_ws` on the `CreateWorkstreamMessage`. The server resumes
|
||||
the workstream during creation (no separate command or reverse lookup
|
||||
needed). The bridge emits a `WorkstreamResumedEvent` to the channel, and
|
||||
the thread displays *"Resumed: {name} ({count} messages restored)"*.
|
||||
If the old workstream was pruned, a fresh one starts with no error.
|
||||
5. **Close** — `/close` command closes the workstream via MQ, deletes the
|
||||
route, unsubscribes from events, and archives the Discord thread.
|
||||
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
> See also: [Notification Flow diagram](diagrams/png/17-notify-flow.png)
|
||||
|
||||
The `notify` tool allows the LLM to proactively send notifications to
|
||||
users or channels on external platforms. This is useful for alerting
|
||||
people about task completion, errors, or important updates without
|
||||
waiting for them to check in.
|
||||
|
||||
### Targeting
|
||||
|
||||
Two modes:
|
||||
|
||||
- **Username** — provide a turnstone `username`. The gateway resolves
|
||||
it via the `channel_users` table and sends to all linked channels
|
||||
(e.g. Discord + future Slack).
|
||||
- **Direct** — provide `channel_type` + `channel_id` to target a
|
||||
specific platform channel or user DM.
|
||||
|
||||
### Delivery Flow
|
||||
|
||||
Notifications bypass MQ for lower latency. The server calls the channel
|
||||
gateway directly over HTTP:
|
||||
|
||||
1. The LLM calls the `notify` tool with a message and target
|
||||
2. `_exec_notify()` queries the `services` table for healthy channel
|
||||
gateways (heartbeat within the last 120 seconds)
|
||||
3. The server mints a service JWT (`aud: turnstone-channel`) via
|
||||
`ServiceTokenManager` and POSTs to the first healthy gateway
|
||||
4. The gateway validates the JWT, resolves the target, and calls
|
||||
`adapter.send()` on the appropriate platform adapter
|
||||
5. On failure, the server tries the next gateway. If all fail, it
|
||||
retries up to 2 more times (delays: 1s, 3s), re-querying the
|
||||
service registry on each attempt
|
||||
|
||||
### Service Registry
|
||||
|
||||
The channel gateway registers itself in the `services` database table
|
||||
on startup and sends a heartbeat every 30 seconds. On shutdown it
|
||||
deregisters. Services are considered stale after 120 seconds (4 missed
|
||||
heartbeats) and are excluded from `list_services()` queries.
|
||||
|
||||
The `services` table schema:
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| `service_type` | Service category (e.g. `"channel"`) |
|
||||
| `service_id` | Unique instance ID (`channel-<hostname>-<random>`) |
|
||||
| `url` | HTTP base URL for the service |
|
||||
| `last_heartbeat` | ISO 8601 timestamp of last heartbeat |
|
||||
| `created` | ISO 8601 timestamp of initial registration |
|
||||
|
||||
### Security
|
||||
|
||||
- **Authentication** — the gateway's `POST /v1/api/notify` endpoint
|
||||
requires authentication. Configure either `TURNSTONE_JWT_SECRET`
|
||||
(the server mints JWTs with `aud: turnstone-channel` automatically)
|
||||
or a static token via `--auth-token`. If neither is set, the
|
||||
gateway fails closed and rejects all requests with 401. Server JWTs
|
||||
(`aud: turnstone-server`) are rejected.
|
||||
- **Rate limit** — maximum 5 notifications per turn. The counter only
|
||||
increments on successful delivery, so failures don't consume the
|
||||
budget.
|
||||
- **SSRF protection** — only `http://` and `https://` service URLs
|
||||
are allowed. Other schemes are silently skipped.
|
||||
- **Mention sanitization** — `discord.utils.escape_mentions()` is
|
||||
applied before sending, preventing `@everyone` / `@here` abuse.
|
||||
- **Error redaction** — generic error messages are returned to the
|
||||
LLM. Internal details (service IDs, URLs, exception messages) are
|
||||
logged server-side only.
|
||||
|
||||
---
|
||||
|
||||
## Adding New Adapters
|
||||
|
||||
The `ChannelAdapter` protocol defines the interface any platform adapter
|
||||
must implement:
|
||||
|
||||
```python
|
||||
class ChannelAdapter(Protocol):
|
||||
channel_type: str
|
||||
|
||||
async def start(self) -> None: ...
|
||||
async def stop(self) -> None: ...
|
||||
async def send(self, channel_id: str, content: str) -> str: ...
|
||||
async def edit_message(self, channel_id: str, message_id: str, content: str) -> None: ...
|
||||
async def send_approval_request(self, channel_id: str, ws_id: str, correlation_id: str, items: list[dict]) -> None: ...
|
||||
async def send_plan_review(self, channel_id: str, ws_id: str, correlation_id: str, content: str) -> None: ...
|
||||
async def create_thread(self, parent_channel_id: str, name: str, message_id: str = "") -> str: ...
|
||||
```
|
||||
|
||||
To add a new platform:
|
||||
|
||||
1. Create `turnstone/channels/<platform>/` package
|
||||
2. Implement the `ChannelAdapter` protocol
|
||||
3. Add a `--<platform>-token` flag and detection logic in
|
||||
`turnstone/channels/cli.py`
|
||||
4. Add the optional dependency in `pyproject.toml` (e.g.
|
||||
`turnstone[slack]`)
|
||||
|
||||
See `turnstone/channels/discord/` as a reference implementation.
|
||||
+344
-6
@@ -61,6 +61,8 @@ The collector (`turnstone/console/collector.py`) maintains an in-memory snapshot
|
||||
|
||||
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.
|
||||
|
||||
A `get_snapshot()` method builds the full cluster state under a single lock acquisition — overview aggregates and per-node workstream lists in one atomic read. This is served both as a REST endpoint and as the initial SSE event on client connect.
|
||||
|
||||
### Thread Safety
|
||||
|
||||
All reads and writes to the node/workstream map are protected by a single `threading.Lock`. Query methods acquire the lock, copy data, and release before returning.
|
||||
@@ -146,9 +148,41 @@ Single node detail with all its workstreams.
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /v1/api/cluster/snapshot`
|
||||
|
||||
Full cluster state in a single response — all nodes with their workstreams plus overview aggregates. Built under a single lock for internal consistency. Used by the browser on initial load and SSE reconnect.
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": "db-west-04",
|
||||
"server_url": "http://10.0.3.4:8080",
|
||||
"max_ws": 10,
|
||||
"reachable": true,
|
||||
"version": "0.3.0",
|
||||
"health": {"status": "ok", "version": "0.3.0"},
|
||||
"aggregate": {"total_tokens": 48200, "total_tool_calls": 156},
|
||||
"workstreams": [
|
||||
{"id": "a1b2c3d4", "name": "perf-db-west", "state": "running", ...}
|
||||
]
|
||||
}
|
||||
],
|
||||
"overview": {
|
||||
"nodes": 847,
|
||||
"workstreams": 4219,
|
||||
"states": {"running": 1847, "thinking": 312, "attention": 89, "idle": 1940, "error": 31},
|
||||
"aggregate": {"total_tokens": 12400000, "total_tool_calls": 34200},
|
||||
"version_drift": false,
|
||||
"versions": ["0.3.0"]
|
||||
},
|
||||
"timestamp": 1709294400.0
|
||||
}
|
||||
```
|
||||
|
||||
### `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.
|
||||
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 `write` scope.
|
||||
|
||||
Request:
|
||||
|
||||
@@ -182,7 +216,7 @@ Creation is asynchronous — the response confirms the MQ message was dispatched
|
||||
|
||||
### `GET /v1/api/cluster/events`
|
||||
|
||||
Server-Sent Events stream for real-time cluster updates.
|
||||
Server-Sent Events stream for real-time cluster updates. The first event is always a `snapshot` containing the full cluster state (same shape as `GET /v1/api/cluster/snapshot` with an added `type: "snapshot"` field), followed by incremental events:
|
||||
|
||||
```
|
||||
data: {"type":"cluster_state","ws_id":"a1b2","node_id":"db-west-04","state":"running"}
|
||||
@@ -207,6 +241,108 @@ Keepalive comments (`: keepalive\n\n`) are sent every 5 seconds. Clients should
|
||||
}
|
||||
```
|
||||
|
||||
### Admin API
|
||||
|
||||
User and token management endpoints. All admin endpoints require `approve` scope, except for the setup endpoint which is public.
|
||||
|
||||
#### `POST /v1/api/auth/setup`
|
||||
|
||||
Creates the first admin user when no users exist. Public endpoint (no auth required). Returns a JWT and sets a session cookie. Returns `409` if users already exist. See [Security: First-time setup](security.md#first-time-setup) for full details.
|
||||
|
||||
#### `POST /v1/api/admin/users`
|
||||
|
||||
Create a new user.
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "alice",
|
||||
"password": "s3cret",
|
||||
"scopes": ["read", "write"]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /v1/api/admin/users`
|
||||
|
||||
List all users.
|
||||
|
||||
```json
|
||||
{
|
||||
"users": [
|
||||
{"user_id": "u_abc123", "username": "alice", "scopes": ["read", "write"], "created": "2026-03-01T12:00:00Z"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `DELETE /v1/api/admin/users/{user_id}`
|
||||
|
||||
Delete a user and revoke all their tokens.
|
||||
|
||||
#### `POST /v1/api/admin/users/{user_id}/tokens`
|
||||
|
||||
Create an API token for the given user. Returns a `ts_`-prefixed token string that can be used for Bearer auth or passed to `client.login(token="ts_xxx")`.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "CI pipeline",
|
||||
"scopes": ["read", "write"]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /v1/api/admin/users/{user_id}/tokens`
|
||||
|
||||
List active tokens for a user (token strings are not returned, only metadata).
|
||||
|
||||
#### `DELETE /v1/api/admin/tokens/{token_id}`
|
||||
|
||||
Revoke a specific API token.
|
||||
|
||||
### Channel links
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/v1/api/admin/users/{user_id}/channels` | List channel links for a user |
|
||||
| POST | `/v1/api/admin/users/{user_id}/channels` | Link a channel account (channel_type, channel_user_id) |
|
||||
| DELETE | `/v1/api/admin/channels/{channel_type}/{channel_user_id}` | Unlink a channel account |
|
||||
|
||||
These endpoints manage the `channel_users` table mappings that connect external platform identities (e.g. Discord user IDs) to turnstone users. See [Channel Integrations](channels.md) for details on the linking flow.
|
||||
|
||||
### Workstream Templates
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/v1/api/admin/ws-templates` | List all workstream templates |
|
||||
| POST | `/v1/api/admin/ws-templates` | Create a workstream template |
|
||||
| GET | `/v1/api/admin/ws-templates/{id}` | Get a single workstream template |
|
||||
| PUT | `/v1/api/admin/ws-templates/{id}` | Update (auto-versions, audit logged) |
|
||||
| DELETE | `/v1/api/admin/ws-templates/{id}` | Delete + cascade versions (audit logged) |
|
||||
| GET | `/v1/api/admin/ws-templates/{id}/versions` | Version history |
|
||||
| GET | `/v1/api/ws-templates` | Enabled templates summary (name, description, model) — requires write scope, not admin |
|
||||
|
||||
#### `GET /v1/api/auth/status`
|
||||
|
||||
Public endpoint for login UI state detection. Returns auth configuration, not
|
||||
current-user identity.
|
||||
|
||||
```json
|
||||
{
|
||||
"auth_enabled": true,
|
||||
"has_users": true,
|
||||
"setup_required": false
|
||||
}
|
||||
```
|
||||
|
||||
### Auth Scopes
|
||||
|
||||
The auth system uses three scopes instead of the earlier read/full role model:
|
||||
|
||||
| Scope | Grants |
|
||||
|-------|--------|
|
||||
| `read` | Read-only access: dashboards, workstream lists, SSE streams, health |
|
||||
| `write` | Send messages, create/close workstreams, approve tool calls |
|
||||
| `approve` | Admin operations: manage users and API tokens |
|
||||
|
||||
Scopes are cumulative — a user with `approve` scope can also perform `write` and `read` operations.
|
||||
|
||||
---
|
||||
|
||||
## Reverse Proxy
|
||||
@@ -236,17 +372,17 @@ The server UI uses root-relative URLs (`/v1/api/send`, `/static/app.js`, `/share
|
||||
|
||||
### 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.
|
||||
SSE streams (`/v1/api/events`, `/v1/api/events/global`) are proxied as raw byte passthrough — the console opens an `httpx.AsyncClient.stream()` to the upstream server (with `read=None` and `pool=None` timeouts since SSE connections are long-lived) and relays every byte via `StreamingResponse`. This preserves server-side ping comments, event framing, and keepalives verbatim without parsing or re-encoding.
|
||||
|
||||
### 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.
|
||||
The proxy forwards the user's JWT to upstream server nodes — it extracts the token from the incoming request's cookie (or `Authorization` header) and adds it as a `Bearer` header on the proxied request. Since all services share the same `TURNSTONE_JWT_SECRET`, the user's JWT is valid on every node without re-authentication. The console's own auth middleware also checks proxy routes — `POST` requests to proxy write endpoints (`/v1/api/send`, `/v1/api/approve`, etc.) require `write` scope, preventing read-only tokens from escalating via proxy. The static `--auth-token` / `proxy_auth_token` is used as a fallback when no user JWT is present.
|
||||
|
||||
---
|
||||
|
||||
## Browser Dashboard
|
||||
|
||||
The web UI has four views, toggled client-side:
|
||||
The web UI has five views, toggled client-side:
|
||||
|
||||
### 1. Cluster Overview (landing)
|
||||
|
||||
@@ -271,12 +407,214 @@ Breadcrumb: `Cluster > Running` or `Cluster > db-west-04`. Server-side paginated
|
||||
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).
|
||||
- **Profile** — optional dropdown listing enabled workstream templates. Applies the template's model, auto-approve policy, token budget, and other behavioral settings at creation time.
|
||||
- **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.
|
||||
All five views receive live updates via SSE — state cards update counts, node rows update metrics, workstream rows update state indicators.
|
||||
|
||||
The browser maintains a local `clusterState` object that mirrors the cluster snapshot. It is initialized from the SSE `snapshot` event on connect (or via `GET /v1/api/cluster/snapshot` on initial page load) and updated incrementally by SSE events. View navigation reads from local state — no API round-trips needed after the initial snapshot.
|
||||
|
||||
### 5. Admin Panel
|
||||
|
||||
Accessed via the "admin" button in the header (visible when authenticated
|
||||
with `approve` scope). Provides user, API token, channel link, and workstream
|
||||
template management with 13 tabs (see also [Governance](governance.md) for
|
||||
the Roles, Policies, Templates, WS Templates, Usage, and Audit tabs, and
|
||||
[Settings](settings.md) for the database-backed configuration editor):
|
||||
|
||||
**Users tab:**
|
||||
|
||||
- Grid table listing all users (username, display name, role, creation date)
|
||||
- "Create User" button opens a modal with fields for username, display name,
|
||||
and password (validated: username 1-64 ASCII, password min 8 characters)
|
||||
- Delete button on each row opens a styled confirmation modal before
|
||||
removing the user and cascading to revoke all their tokens
|
||||
|
||||
**Tokens tab:**
|
||||
|
||||
- User selector dropdown to pick which user's tokens to manage
|
||||
- Grid table listing tokens for the selected user (name, prefix, scopes,
|
||||
creation date)
|
||||
- Scope badges rendered as colored pills for visual clarity
|
||||
- "Create Token" button opens a modal with fields for token name and scope
|
||||
checkboxes
|
||||
- On creation, a "Token Created" modal displays the raw `ts_`-prefixed
|
||||
token with a copy button. The token is shown once and cannot be retrieved
|
||||
again.
|
||||
- Revoke button on each row opens a styled confirmation modal before
|
||||
deleting the token
|
||||
|
||||
**Channels tab:**
|
||||
|
||||
- User selector dropdown to pick which user's channel links to manage
|
||||
- Grid table listing linked channel accounts for the selected user
|
||||
(channel type, channel user ID, creation date)
|
||||
- "Link Channel" button opens a modal with fields for channel type
|
||||
(e.g. `discord`) and the platform user ID
|
||||
- Unlink button on each row opens a styled confirmation modal before
|
||||
removing the channel mapping
|
||||
- Admins can force-link users who have not self-linked via `/link` in
|
||||
Discord
|
||||
|
||||
**Accessibility:**
|
||||
|
||||
- Full keyboard navigation: focus traps in modals, Escape to close, arrow
|
||||
keys for tab switching
|
||||
- Responsive layout with column hiding at 700px breakpoint
|
||||
|
||||
**First-time setup:**
|
||||
|
||||
The console also exposes `POST /v1/api/auth/setup` for first-time
|
||||
bootstrap. When no users exist, the setup wizard calls this public endpoint
|
||||
to create the initial admin user and receive a JWT in one step. See
|
||||
[Security: First-time setup](security.md#first-time-setup) for details.
|
||||
|
||||
---
|
||||
|
||||
## Scheduled Tasks
|
||||
|
||||
The console includes a background **TaskScheduler** daemon that creates workstreams on a timed basis via the MQ broker. It supports cron-based recurring schedules and one-shot `at` schedules.
|
||||
|
||||
### Architecture
|
||||
|
||||
The scheduler runs as a daemon thread inside the console process. Every `check_interval` seconds (default 15) it:
|
||||
|
||||
1. Acquires a distributed lock via Redis `SET NX EX` (prevents duplicate dispatch in multi-console deployments)
|
||||
2. Queries the storage backend for tasks whose `next_run <= now` and `enabled = true`
|
||||
3. Dispatches each due task as one or more `CreateWorkstreamMessage` via MQ
|
||||
4. Updates `last_run` and computes the next `next_run` (or disables one-shot `at` tasks)
|
||||
5. Releases the lock via Lua script (safe conditional delete)
|
||||
|
||||
Run history is automatically pruned (runs older than 90 days) approximately once per hour.
|
||||
|
||||
### Schedule Types
|
||||
|
||||
| Type | Field | Behavior |
|
||||
|------|-------|----------|
|
||||
| `cron` | `cron_expr` | Recurring schedule using standard 5-field cron syntax. Requires `croniter`. |
|
||||
| `at` | `at_time` | One-shot: fires once at the given ISO 8601 timestamp (must include timezone), then auto-disables. |
|
||||
|
||||
### Target Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `auto` | Picks the reachable node with the most available capacity |
|
||||
| `pool` | Pushes to the shared inbound queue (any bridge picks it up) |
|
||||
| `all` | Fan-out to all reachable nodes (capped at `max_fan_out`, default 20) |
|
||||
| `<node_id>` | Targets a specific node by ID |
|
||||
|
||||
### Configuration
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `check_interval` | `15.0` | Seconds between scheduler ticks |
|
||||
| `lock_ttl` | `60` | Distributed lock TTL in seconds |
|
||||
| `max_fan_out` | `20` | Maximum nodes for `all` target mode |
|
||||
|
||||
Dependency: `croniter` (installed with turnstone).
|
||||
|
||||
### Schedule API
|
||||
|
||||
All schedule endpoints require `approve` scope. Maximum 200 schedules.
|
||||
|
||||
#### `GET /v1/api/admin/schedules`
|
||||
|
||||
List all scheduled tasks.
|
||||
|
||||
```json
|
||||
{
|
||||
"schedules": [
|
||||
{
|
||||
"task_id": "a1b2c3d4",
|
||||
"name": "nightly-checks",
|
||||
"description": "Run nightly health checks",
|
||||
"schedule_type": "cron",
|
||||
"cron_expr": "0 2 * * *",
|
||||
"at_time": "",
|
||||
"target_mode": "auto",
|
||||
"model": "",
|
||||
"initial_message": "Run the nightly health check suite.",
|
||||
"auto_approve": false,
|
||||
"auto_approve_tools": [],
|
||||
"enabled": true,
|
||||
"created_by": "u_admin",
|
||||
"last_run": "2026-03-05T02:00:00Z",
|
||||
"next_run": "2026-03-06T02:00:00Z",
|
||||
"created": "2026-03-01T12:00:00Z",
|
||||
"updated": "2026-03-05T02:00:01Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /v1/api/admin/schedules`
|
||||
|
||||
Create a scheduled task.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "nightly-checks",
|
||||
"description": "Run nightly health checks",
|
||||
"schedule_type": "cron",
|
||||
"cron_expr": "0 2 * * *",
|
||||
"target_mode": "auto",
|
||||
"initial_message": "Run the nightly health check suite.",
|
||||
"auto_approve": false,
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
Required fields: `name`, `schedule_type`, `initial_message`. For `cron` schedules provide `cron_expr`; for `at` schedules provide `at_time` (ISO 8601 with timezone, must be in the future).
|
||||
|
||||
Response: `ScheduleInfo` (same shape as list items above). Returns `400` for invalid cron syntax, naive timestamps, or past `at_time`. Returns `409` if the 200-schedule cap is reached.
|
||||
|
||||
#### `GET /v1/api/admin/schedules/{task_id}`
|
||||
|
||||
Get a single scheduled task. Returns `ScheduleInfo` or `404`.
|
||||
|
||||
#### `PUT /v1/api/admin/schedules/{task_id}`
|
||||
|
||||
Partial update — only include fields to change. If `schedule_type`, `cron_expr`, or `at_time` change, `next_run` is recomputed automatically.
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
Response: updated `ScheduleInfo`. Returns `400` for validation errors, `404` if not found.
|
||||
|
||||
#### `DELETE /v1/api/admin/schedules/{task_id}`
|
||||
|
||||
Delete a scheduled task and all its run history. Returns `{"status": "ok"}` or `404`.
|
||||
|
||||
#### `GET /v1/api/admin/schedules/{task_id}/runs?limit=50`
|
||||
|
||||
List execution history for a task (most recent first). `limit` defaults to 50, max 200.
|
||||
|
||||
```json
|
||||
{
|
||||
"runs": [
|
||||
{
|
||||
"run_id": "r_abc123",
|
||||
"task_id": "a1b2c3d4",
|
||||
"node_id": "db-west-04",
|
||||
"ws_id": "ws_xyz",
|
||||
"correlation_id": "corr_789",
|
||||
"started": "2026-03-05T02:00:00Z",
|
||||
"status": "dispatched",
|
||||
"error": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Status is `dispatched` on success or `failed` with an `error` message (e.g. no reachable nodes). Failed runs do not advance `next_run`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d5a2bd1c55ac8cf3b777a8decb6f3bb3d063c10c8f3a9e63457079830e48f456
|
||||
size 162310
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:09cee5819bb7820641a466ed29a53f1b479bc2ded83079fc798c7410bf741a62
|
||||
size 329703
|
||||
@@ -40,7 +40,8 @@ package "turnstone/core/" <<Rectangle>> {
|
||||
component [auth.py\nAuthentication] as auth <<core>>
|
||||
component [healthcheck.py\nBackendHealthMonitor] as healthcheck <<core>>
|
||||
component [ratelimit.py\nRateLimiter] as ratelimit <<core>>
|
||||
component [mcp_client.py\nMCPClientManager] as mcp <<core>>
|
||||
component [mcp_client.py\nMCPClientManager\n(push + periodic refresh)] as mcp <<core>>
|
||||
component [tool_search.py\nToolSearchManager, BM25] as toolsearch <<core>>
|
||||
component [model_registry.py\nModelRegistry] as registry <<core>>
|
||||
}
|
||||
|
||||
@@ -95,7 +96,7 @@ package "turnstone/sdk/" <<Rectangle>> {
|
||||
|
||||
' Tool schemas
|
||||
package "turnstone/tools/" <<Rectangle>> {
|
||||
component [*.json\n14 tool schemas] as schemas <<artifact>>
|
||||
component [*.json\n18 tool schemas] as schemas <<artifact>>
|
||||
}
|
||||
|
||||
' Entry point dependencies
|
||||
@@ -136,6 +137,7 @@ session --> edit
|
||||
session --> web
|
||||
session --> healthcheck
|
||||
session --> mcp : optional
|
||||
session --> toolsearch : optional
|
||||
session --> registry : optional
|
||||
registry --> providers
|
||||
healthcheck --> metrics
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b3f76042c8046560502fa3132351821d56e8526b13d38d7be8b839fcf3d5f648
|
||||
size 373463
|
||||
@@ -41,7 +41,7 @@ class "WorkstreamTerminalUI" as WsTermUI {
|
||||
}
|
||||
|
||||
class "WebUI" as WebUI {
|
||||
- _event_queue: Queue
|
||||
- _listeners: list[Queue]
|
||||
- _approval_event: Event
|
||||
- _plan_event: Event
|
||||
- _ws_prompt_tokens: int
|
||||
@@ -108,6 +108,8 @@ class "ModelCapabilities" as ModelCaps <<frozen>> {
|
||||
+ thinking_mode: str
|
||||
+ supports_effort: bool
|
||||
+ supports_web_search: bool
|
||||
+ supports_tool_search: bool
|
||||
+ supports_vision: bool
|
||||
}
|
||||
|
||||
' ChatSession
|
||||
@@ -118,8 +120,9 @@ class "ChatSession" as ChatSession {
|
||||
- ui: SessionUI
|
||||
- messages: list[dict]
|
||||
- _msg_tokens: list[int]
|
||||
- _session_id: str
|
||||
- _ws_id: str
|
||||
- _mcp_client: MCPClientManager | None
|
||||
- _tool_search: ToolSearchManager | None
|
||||
- _registry: ModelRegistry | None
|
||||
+ model_alias: str | None {property}
|
||||
- _tools: list[dict]
|
||||
@@ -130,7 +133,7 @@ class "ChatSession" as ChatSession {
|
||||
--
|
||||
+ send(user_input: str)
|
||||
+ handle_command(command: str)
|
||||
+ resume_session(session_id: str)
|
||||
+ resume(ws_id: str)
|
||||
- _save_config()
|
||||
- _stream_response(stream) → dict
|
||||
- _create_stream_with_retry(msgs) → Stream (+ fallback)
|
||||
@@ -139,6 +142,12 @@ class "ChatSession" as ChatSession {
|
||||
- _prepare_tool(tc) → item dict
|
||||
- _prepare_mcp_tool(call_id, name, args) → item dict
|
||||
- _exec_mcp_tool(item) → (call_id, output)
|
||||
- _get_active_tools() → list[dict]
|
||||
- _prepare_tool_search() → None
|
||||
- _exec_tool_search(item) → (call_id, output)
|
||||
- _on_mcp_tools_changed()
|
||||
- _rebuild_tool_search()
|
||||
+ close()
|
||||
- _run_agent(messages, tools, ...) → str
|
||||
- _compact_messages(auto: bool)
|
||||
- _full_messages() → list[dict]
|
||||
@@ -201,22 +210,58 @@ enum "WorkstreamState" as WsState {
|
||||
' MCPClientManager
|
||||
class "MCPClientManager" as MCPMgr {
|
||||
- _sessions: dict[str, ClientSession]
|
||||
- _per_server_tools: dict[str, list[dict]]
|
||||
- _per_server_resources: dict[str, list[dict]]
|
||||
- _per_server_prompts: dict[str, list[dict]]
|
||||
- _tools: list[dict]
|
||||
- _tool_map: dict[str, tuple]
|
||||
- _resource_map: dict[str, tuple]
|
||||
- _prompt_map: dict[str, tuple]
|
||||
- _supports_list_changed: dict[str, bool]
|
||||
- _listeners: list[Callable]
|
||||
--
|
||||
+ start()
|
||||
+ get_tools() → list[dict]
|
||||
+ get_resources() → list[dict]
|
||||
+ get_prompts() → list[dict]
|
||||
+ is_mcp_tool(name) → bool
|
||||
+ call_tool_sync(name, args) → str
|
||||
+ read_resource_sync(uri) → str
|
||||
+ get_prompt_sync(name, args?) → list[dict]
|
||||
+ refresh_sync(server?) → dict
|
||||
+ add_listener(callback)
|
||||
+ remove_listener(callback)
|
||||
+ server_names: list[str] {property}
|
||||
+ shutdown()
|
||||
--
|
||||
Background asyncio event loop
|
||||
bridges async MCP SDK to
|
||||
sync ChatSession dispatch.
|
||||
Push + periodic + manual refresh.
|
||||
Resources + prompts discovered
|
||||
alongside tools at startup.
|
||||
--
|
||||
core/mcp_client.py
|
||||
}
|
||||
|
||||
' ToolSearchManager
|
||||
class "ToolSearchManager" as ToolSearchMgr {
|
||||
- _all_tools: list[dict]
|
||||
- _always_on: list[dict]
|
||||
- _deferred: list[dict]
|
||||
- _expanded: dict[str, None]
|
||||
- _index: BM25Index
|
||||
--
|
||||
+ should_activate() → bool
|
||||
+ get_visible_tools() → list[dict]
|
||||
+ get_deferred_tools() → list[dict]
|
||||
+ get_expanded_names() → list[str]
|
||||
+ search(query, k) → list[dict]
|
||||
+ expand_visible(names) → list[dict]
|
||||
+ get_search_tool_definition() → dict
|
||||
+ format_search_results(tools) → str
|
||||
}
|
||||
|
||||
' ModelRegistry
|
||||
class "ModelRegistry" as ModelReg {
|
||||
- _models: dict[str, ModelConfig]
|
||||
@@ -317,6 +362,7 @@ LLMProvider <|.. AnthropicProv
|
||||
ChatSession --> SessionUI : uses
|
||||
ChatSession --> LLMProvider : delegates LLM calls
|
||||
ChatSession --> MCPMgr : optional
|
||||
ChatSession --o ToolSearchMgr : _tool_search
|
||||
ChatSession --> ModelReg : optional
|
||||
ChatSession <|-- HeadlessSession
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fca0957b54101ce5c2b2e06d639b04dc5fff733f2641af0d732c49ea86883882
|
||||
size 279397
|
||||
@@ -18,7 +18,7 @@ User -> CS : send(user_input)
|
||||
activate CS
|
||||
|
||||
CS -> CS : messages.append({role: "user", content: input})
|
||||
CS -> DB : save_message(session_id, "user", input)
|
||||
CS -> DB : save_message(ws_id, "user", input)
|
||||
|
||||
== LLM Call Loop ==
|
||||
|
||||
@@ -57,6 +57,14 @@ group loop [while tool_calls present]
|
||||
end
|
||||
end
|
||||
|
||||
note right of CS
|
||||
**Cancellation checkpoint:**
|
||||
_check_cancelled() runs per chunk.
|
||||
If cancel_event is set, raises
|
||||
GenerationCancelled — preserves
|
||||
partial content, emits idle state.
|
||||
end note
|
||||
|
||||
LLM --> CS : stream complete (usage stats)
|
||||
deactivate LLM
|
||||
|
||||
@@ -65,8 +73,8 @@ group loop [while tool_calls present]
|
||||
|
||||
CS -> CS : _update_token_table()\ncalibrate chars_per_token ratio
|
||||
CS -> CS : messages.append(assistant_msg)
|
||||
CS -> DB : save_message(session_id, "assistant", content)
|
||||
CS -> DB : save_message(session_id, "tool_call", ...) ×N
|
||||
CS -> DB : save_message(ws_id, "assistant", content)
|
||||
CS -> DB : save_message(ws_id, "tool_call", ...) ×N
|
||||
|
||||
== Tool Dispatch (if tool_calls) ==
|
||||
|
||||
@@ -112,14 +120,14 @@ group loop [while tool_calls present]
|
||||
note right of TP
|
||||
Parallel execution:
|
||||
bash → Popen + line-by-line streaming
|
||||
read_file → open().read()
|
||||
read_file → open().read() or base64 image
|
||||
search → grep subprocess
|
||||
edit_file → string replace
|
||||
task/plan → _run_agent() sub-loop
|
||||
math → sandboxed subprocess
|
||||
web_fetch → httpx + LLM summarize
|
||||
web_search → provider-native or Tavily fallback
|
||||
remember/recall/forget → SQLite
|
||||
memory/recall → SQLite
|
||||
end note
|
||||
|
||||
note right of TP
|
||||
@@ -136,7 +144,7 @@ group loop [while tool_calls present]
|
||||
|
||||
loop for each result
|
||||
CS -> CS : messages.append({role: "tool", ...})
|
||||
CS -> DB : save_message(session_id, "tool_result", ...)
|
||||
CS -> DB : save_message(ws_id, "tool_result", ...)
|
||||
end
|
||||
|
||||
opt user_feedback from approval
|
||||
@@ -144,6 +152,12 @@ group loop [while tool_calls present]
|
||||
end
|
||||
|
||||
note right of CS : Loop back for next LLM call
|
||||
|
||||
else GenerationCancelled
|
||||
CS -> CS : Preserve partial content\nor roll back incomplete tools
|
||||
CS -> UI : on_info("[Generation cancelled]")
|
||||
CS -> UI : on_state_change("idle")
|
||||
CS --> User : return (no re-raise)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:06fb9faa29c11395c6fc54ebddc79994b000dee78d56e0c13cb689fd6a82e37a
|
||||
size 237255
|
||||
@@ -24,27 +24,30 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
||||
:Dispatch to _prepare_{func_name}();
|
||||
|
||||
note right
|
||||
**Dispatch table (14 tools):**
|
||||
┌─────────────┬──────────────────┐
|
||||
│ Tool │ Needs Approval? │
|
||||
├─────────────┼──────────────────┤
|
||||
│ bash │ ✓ Yes │
|
||||
│ read_file │ ✗ Auto-approve │
|
||||
│ write_file │ ✓ Yes │
|
||||
│ edit_file │ ✓ Yes │
|
||||
│ search │ ✗ Auto-approve │
|
||||
│ math │ ✓ Yes │
|
||||
│ man │ ✗ Auto-approve │
|
||||
│ web_fetch │ ✓ Yes │
|
||||
│ web_search │ ✓ Yes │
|
||||
│ task │ ✓ Yes │
|
||||
│ plan │ ✓ Yes │
|
||||
│ remember │ ✗ Auto-approve │
|
||||
│ recall │ ✗ Auto-approve │
|
||||
│ forget │ ✗ Auto-approve │
|
||||
├─────────────┼──────────────────┤
|
||||
│ mcp__* │ ✓ Yes (external) │
|
||||
└─────────────┴──────────────────┘
|
||||
**Dispatch table (17 tools):**
|
||||
┌───────────────┬──────────────────┐
|
||||
│ Tool │ Needs Approval? │
|
||||
├───────────────┼──────────────────┤
|
||||
│ bash │ ✓ Yes │
|
||||
│ read_file │ ✗ Auto-approve │
|
||||
│ write_file │ ✓ Yes │
|
||||
│ edit_file │ ✓ Yes │
|
||||
│ search │ ✗ Auto-approve │
|
||||
│ math │ ✗ Auto-approve │
|
||||
│ man │ ✗ Auto-approve │
|
||||
│ web_fetch │ ✗ Auto-approve │
|
||||
│ web_search │ ✗ Auto-approve │
|
||||
│ tool_search │ ✗ Auto-approve │
|
||||
│ task │ ✓ Yes │
|
||||
│ plan │ ✓ Yes │
|
||||
│ memory │ ✗ Auto-approve │
|
||||
│ recall │ ✗ Auto-approve │
|
||||
│ notify │ ✗ Auto-approve │
|
||||
│ read_resource │ ✓ Yes │
|
||||
│ use_prompt │ ✓ Yes │
|
||||
├───────────────┼──────────────────┤
|
||||
│ mcp__* │ ✓ Yes (external) │
|
||||
└───────────────┴──────────────────┘
|
||||
end note
|
||||
|
||||
:Build item dict:
|
||||
@@ -86,6 +89,8 @@ partition "Phase 2: Approve" #FFF3E0 {
|
||||
}
|
||||
|
||||
partition "Phase 3: Execute" #E3F2FD {
|
||||
:_check_cancelled();
|
||||
note right: Cancellation checkpoint:\nraises GenerationCancelled if\ncancel event is set
|
||||
if (single tool call?) then (yes)
|
||||
:Execute sequentially:\nrun_one(items[0]);
|
||||
else (multiple)
|
||||
@@ -98,7 +103,7 @@ partition "Phase 3: Execute" #E3F2FD {
|
||||
if item.denied → return denial message
|
||||
else → item["execute"](item)
|
||||
├─ _exec_bash: subprocess.run(["bash", script.sh])
|
||||
├─ _exec_read_file: open().readlines()
|
||||
├─ _exec_read_file: open().readlines() or _exec_read_image (base64)
|
||||
├─ _exec_write_file: makedirs + write
|
||||
├─ _exec_edit_file: find_occurrences + replace
|
||||
├─ _exec_search: grep subprocess
|
||||
@@ -106,11 +111,14 @@ partition "Phase 3: Execute" #E3F2FD {
|
||||
├─ _exec_man: man/info subprocess
|
||||
├─ _exec_web_fetch: httpx.get + LLM summary
|
||||
├─ _exec_web_search: Tavily API POST (fallback for local models)
|
||||
├─ _exec_tool_search: BM25 search + expand_visible()
|
||||
├─ _exec_task: _run_agent(TASK_AGENT_TOOLS)
|
||||
├─ _exec_plan: _run_agent(AGENT_TOOLS, read-only)
|
||||
├─ _exec_remember: SQLite INSERT OR REPLACE
|
||||
├─ _exec_recall: SQLite FTS5/LIKE search
|
||||
├─ _exec_forget: SQLite DELETE
|
||||
├─ _exec_notify: HTTP POST to channel gateway
|
||||
├─ _exec_memory: structured memory save/search/delete/list
|
||||
├─ _exec_recall: conversation history FTS5 search
|
||||
├─ _exec_read_resource: MCPClientManager.read_resource_sync()
|
||||
├─ _exec_use_prompt: MCPClientManager.get_prompt_sync()
|
||||
└─ _exec_mcp_tool: MCPClientManager.call_tool_sync()
|
||||
end note
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:13b2da77312e5abb44c81aabb7f0addccab31d9bc7e8ef2f1c3563ef985ed503
|
||||
size 186941
|
||||
@@ -59,6 +59,8 @@ package "Inbound Messages (Client → Bridge)" #FFF3E0 {
|
||||
+ auto_approve_tools: list[str] = []
|
||||
+ target_node: str = ""
|
||||
+ initial_message: str = ""
|
||||
+ template: str = ""
|
||||
+ ws_template: str = ""
|
||||
}
|
||||
|
||||
class CloseWorkstreamMessage {
|
||||
@@ -79,6 +81,12 @@ package "Inbound Messages (Client → Bridge)" #FFF3E0 {
|
||||
type = "list_nodes"
|
||||
}
|
||||
|
||||
class CancelMessage {
|
||||
type = "cancel"
|
||||
--
|
||||
+ ws_id: str
|
||||
}
|
||||
|
||||
IM <|-- SendMessage
|
||||
IM <|-- ApproveMessage
|
||||
IM <|-- PlanFeedbackMessage
|
||||
@@ -88,6 +96,7 @@ package "Inbound Messages (Client → Bridge)" #FFF3E0 {
|
||||
IM <|-- ListWorkstreamsMessage
|
||||
IM <|-- HealthMessage
|
||||
IM <|-- ListNodesMessage
|
||||
IM <|-- CancelMessage
|
||||
}
|
||||
|
||||
package "Outbound Events (Bridge → Client)" #E3F2FD {
|
||||
|
||||
@@ -79,7 +79,7 @@ note right of BridgeA
|
||||
1. _ws_auto_approve[ws_id]? → auto
|
||||
2. All tools in safe set? → auto
|
||||
(read_file, search, man,
|
||||
remember, recall, forget)
|
||||
memory, recall)
|
||||
3. Otherwise → manual approval
|
||||
end note
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ running --> error : Exception during\ntool execution
|
||||
|
||||
error --> thinking : New send() call\n_emit_state("thinking")
|
||||
|
||||
thinking --> idle : cancel() called\n_emit_state("idle")
|
||||
|
||||
running --> idle : cancel() called\n_emit_state("idle")
|
||||
|
||||
attention --> idle : cancel() unblocks\napproval/plan wait\n_emit_state("idle")
|
||||
|
||||
note right of thinking
|
||||
**Emitted via:**
|
||||
session._emit_state(state)
|
||||
|
||||
@@ -78,7 +78,19 @@ activate NodeA
|
||||
NodeA --> CC : {status:"ok", version:"0.3.0",\nmodel:"...", workstreams:{...}}
|
||||
deactivate NodeA
|
||||
|
||||
CC -> CC : Diff old vs new workstream IDs
|
||||
CC -> CC : Replace NodeSnapshot["nodeA"]\n.workstreams, .health, .aggregate
|
||||
CC -> CC : _fanout(ws_created) for\nnewly appeared workstreams
|
||||
CC -> CC : _fanout(ws_closed) for\nremoved workstreams
|
||||
|
||||
note right of CC
|
||||
Poll-diff fanout ensures
|
||||
browser SSE clients learn
|
||||
about workstreams that
|
||||
appeared without a real-time
|
||||
cluster event (e.g. bridge
|
||||
startup recovery).
|
||||
end note
|
||||
|
||||
CC -x NodeB : (SKIPPED: sim:// URL)
|
||||
|
||||
@@ -89,10 +101,15 @@ deactivate CC
|
||||
Browser -> Server : GET /v1/api/cluster/events
|
||||
activate Server
|
||||
|
||||
Server -> CC : get_snapshot()
|
||||
CC --> Server : ClusterSnapshot\n(full current state)
|
||||
|
||||
Server -> CC : register_listener(queue)
|
||||
note right : Per-client queue.Queue(maxsize=500)\nSSE via EventSourceResponse + run_in_executor()
|
||||
|
||||
loop continuous
|
||||
Server -> Browser : data: {"type":"snapshot",...}\n(full state as first SSE event)
|
||||
|
||||
loop continuous (incremental updates)
|
||||
CC -> Server : event via listener queue\n(from any of the 3 threads)
|
||||
Server -> Browser : data: {"type":"cluster_state",...}\n\n
|
||||
end
|
||||
@@ -105,6 +122,13 @@ Browser -> Server : connection closed
|
||||
Server -> CC : unregister_listener(queue)
|
||||
deactivate Server
|
||||
|
||||
== Browser REST: Snapshot ==
|
||||
|
||||
Browser -> Server : GET /v1/api/cluster/snapshot
|
||||
Server -> CC : get_snapshot()
|
||||
CC --> Server : ClusterSnapshot\n(full current state)
|
||||
Server --> Browser : JSON response
|
||||
|
||||
== Browser REST Requests ==
|
||||
|
||||
Browser -> Server : GET /v1/api/cluster/overview
|
||||
|
||||
@@ -32,10 +32,11 @@ package "turnstone/sdk/ (Python)" {
|
||||
+ approve()
|
||||
+ plan_feedback()
|
||||
+ command()
|
||||
+ cancel(ws_id)
|
||||
+ stream_events(ws_id)
|
||||
+ stream_global_events()
|
||||
+ send_and_wait()
|
||||
+ list_sessions()
|
||||
+ list_saved_workstreams()
|
||||
+ login() / logout()
|
||||
+ health()
|
||||
}
|
||||
@@ -45,6 +46,7 @@ package "turnstone/sdk/ (Python)" {
|
||||
+ nodes()
|
||||
+ workstreams()
|
||||
+ node_detail()
|
||||
+ snapshot()
|
||||
+ create_workstream()
|
||||
+ stream_cluster_events()
|
||||
+ login() / logout()
|
||||
@@ -129,6 +131,7 @@ package "sdk/typescript/ (TypeScript)" {
|
||||
class "TurnstoneConsole" as TSConsole <<ts>> {
|
||||
+ overview()
|
||||
+ nodes()
|
||||
+ snapshot()
|
||||
+ clusterEvents()
|
||||
...
|
||||
}
|
||||
|
||||
@@ -13,18 +13,19 @@ skinparam class {
|
||||
|
||||
' -- 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)
|
||||
+save_message(ws_id, role, content, ...)
|
||||
+load_messages(ws_id) → list[dict]
|
||||
+register_workstream(ws_id, node_id, name, state)
|
||||
+update_workstream_state(ws_id, state)
|
||||
+update_workstream_name(ws_id, name)
|
||||
+set_workstream_alias(ws_id, alias) → bool
|
||||
+update_workstream_title(ws_id, title)
|
||||
+resolve_workstream(alias_or_id) → str | None
|
||||
+delete_workstream(ws_id) → bool
|
||||
+prune_workstreams(retention_days) → (int, int)
|
||||
+list_workstreams(node_id, limit) → list
|
||||
+save_workstream_config(ws_id, config)
|
||||
+load_workstream_config(ws_id) → dict
|
||||
+kv_get(key) → str | None
|
||||
+kv_set(key, value) → str | None
|
||||
+kv_delete(key) → bool
|
||||
@@ -32,6 +33,11 @@ interface "StorageBackend" as SB <<protocol>> {
|
||||
+kv_search(query) → list[(str, str)]
|
||||
+search_history(query, limit) → list
|
||||
+search_history_recent(limit) → list
|
||||
+create_user(user_id, username, display_name, pw_hash)
|
||||
+get_user(user_id) / get_user_by_username(username)
|
||||
+list_users() / delete_user(user_id)
|
||||
+create_api_token(...) / get_api_token_by_hash(hash)
|
||||
+list_api_tokens(user_id) / delete_api_token(id)
|
||||
+close()
|
||||
}
|
||||
|
||||
@@ -58,8 +64,14 @@ class "_schema.py" as Schema <<schema>> {
|
||||
+metadata: MetaData
|
||||
+memories: Table
|
||||
+conversations: Table
|
||||
+sessions: Table
|
||||
+session_config: Table
|
||||
+workstreams: Table (node_id, alias, title,\n state, ws_template_id, ws_template_version)
|
||||
+workstream_config: Table
|
||||
+users: Table (username, password_hash)
|
||||
+api_tokens: Table (token_hash, scopes)
|
||||
+channel_users: Table (channel_type)
|
||||
+workstream_templates: Table (name, model,\n system_prompt, token_budget, version)
|
||||
+workstream_template_versions: Table\n (template_id, version, snapshot)
|
||||
+scheduled_tasks: Table (..., ws_template)
|
||||
--
|
||||
SQLAlchemy Core
|
||||
Single source of truth
|
||||
@@ -76,6 +88,7 @@ class "_migrate.py" as Migrate <<migration>> {
|
||||
|
||||
class "migrations/" as Versions <<migration>> {
|
||||
001_initial_schema.py
|
||||
002_user_identity.py
|
||||
}
|
||||
|
||||
' -- Registry --
|
||||
@@ -91,12 +104,14 @@ class "_registry.py" as Registry {
|
||||
|
||||
' -- Facade --
|
||||
class "memory.py" as Facade <<facade>> {
|
||||
+register_session()
|
||||
+save_message()
|
||||
+load_session_messages()
|
||||
+load_messages()
|
||||
+register_workstream()
|
||||
+update_workstream_state()
|
||||
+save_workstream_config()
|
||||
+save_memory() / delete_memory()
|
||||
+search_memories()
|
||||
+... (all 18 functions)
|
||||
+... (all delegated functions)
|
||||
--
|
||||
Thin delegation to
|
||||
get_storage()
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Authentication Architecture
|
||||
|
||||
skinparam class {
|
||||
BackgroundColor<<core>> #E8EAF6
|
||||
BackgroundColor<<jwt>> #C8E6C9
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<endpoint>> #FFE0B2
|
||||
BackgroundColor<<scope>> #F3E5F5
|
||||
}
|
||||
|
||||
' -- Core Auth --
|
||||
class "AuthConfig" as AC <<core>> {
|
||||
+enabled: bool
|
||||
+tokens: dict[str, str]
|
||||
+check(token) → role | None
|
||||
--
|
||||
Static config-file tokens
|
||||
hmac.compare_digest
|
||||
}
|
||||
|
||||
class "AuthResult" as AR <<core>> {
|
||||
+user_id: str
|
||||
+scopes: frozenset[str]
|
||||
+token_source: str
|
||||
+has_scope(scope) → bool
|
||||
}
|
||||
|
||||
class "check_request()" as CR <<core>> {
|
||||
auth_config, method, path,
|
||||
auth_header, cookie_header,
|
||||
jwt_secret, storage
|
||||
→ (allowed, status, msg, AuthResult)
|
||||
--
|
||||
1. Auth disabled → allow
|
||||
2. Public path → allow
|
||||
3. Extract Bearer / cookie
|
||||
4. Detect token type
|
||||
5. Validate → AuthResult
|
||||
6. Check scope vs path
|
||||
}
|
||||
|
||||
' -- Token Types --
|
||||
class "JWT (HS256)" as JWT <<jwt>> {
|
||||
sub: user_id
|
||||
scopes: "read,write,approve"
|
||||
src: "password" | "database"
|
||||
iat, exp (24h default)
|
||||
--
|
||||
Detected by: contains "."
|
||||
Validated locally
|
||||
No DB call
|
||||
}
|
||||
|
||||
class "API Token" as AT <<jwt>> {
|
||||
Format: ts_ + 64 hex
|
||||
Stored: SHA-256 hash
|
||||
--
|
||||
Detected by: starts with "ts_"
|
||||
Lookup by hash in DB
|
||||
Expiry check
|
||||
}
|
||||
|
||||
class "Config Token" as CT <<core>> {
|
||||
Raw value in memory
|
||||
Role: "read" | "full"
|
||||
--
|
||||
Detected by: fallback
|
||||
hmac.compare_digest
|
||||
No DB needed
|
||||
}
|
||||
|
||||
' -- Scopes --
|
||||
class "Scope Hierarchy" as SH <<scope>> {
|
||||
read: {read}
|
||||
write: {read, write}
|
||||
approve: {read, write, approve}
|
||||
--
|
||||
GET → read
|
||||
POST write paths → write
|
||||
POST /api/approve → approve
|
||||
/api/admin/* → approve
|
||||
}
|
||||
|
||||
' -- Storage --
|
||||
class "users" as UT <<storage>> {
|
||||
user_id (PK)
|
||||
username (unique)
|
||||
display_name
|
||||
password_hash (bcrypt)
|
||||
created
|
||||
}
|
||||
|
||||
class "api_tokens" as TT <<storage>> {
|
||||
token_id (PK)
|
||||
token_hash (SHA-256, unique)
|
||||
token_prefix
|
||||
user_id → users
|
||||
name, scopes
|
||||
created, expires
|
||||
}
|
||||
|
||||
' -- Endpoints --
|
||||
class "POST /api/auth/login" as Login <<endpoint>> {
|
||||
{username, password}
|
||||
OR {token: "ts_xxx"}
|
||||
→ {jwt, role, scopes, user_id}
|
||||
--
|
||||
Sets HttpOnly cookie
|
||||
}
|
||||
|
||||
class "GET /api/auth/status" as Status <<endpoint>> {
|
||||
→ {auth_enabled, has_users,
|
||||
setup_required}
|
||||
--
|
||||
Public (no auth)
|
||||
Drives UI setup wizard
|
||||
}
|
||||
|
||||
class "POST /api/auth/setup" as Setup <<endpoint>> {
|
||||
{username, display_name, password}
|
||||
→ {jwt, user_id, scopes}
|
||||
--
|
||||
Public (no auth)
|
||||
Only when zero users exist
|
||||
Returns 409 if already set up
|
||||
}
|
||||
|
||||
class "Admin API (Console)" as Admin <<endpoint>> {
|
||||
POST/GET/DELETE users
|
||||
POST/GET tokens
|
||||
DELETE tokens/{id}
|
||||
--
|
||||
Requires approve scope
|
||||
}
|
||||
|
||||
' -- Relationships --
|
||||
CR --> AC : config tokens
|
||||
CR --> JWT : validate
|
||||
CR --> AT : hash lookup
|
||||
CR --> CT : hmac check
|
||||
CR --> AR : returns
|
||||
CR --> SH : checks
|
||||
|
||||
Login --> JWT : issues
|
||||
Login --> UT : verify password
|
||||
Login --> TT : verify API token
|
||||
|
||||
Setup --> UT : create first user
|
||||
Setup --> JWT : issues
|
||||
|
||||
AT --> TT : lookup by hash
|
||||
Admin --> UT : CRUD
|
||||
Admin --> TT : CRUD
|
||||
|
||||
AR --> SH : scopes from
|
||||
|
||||
JWT ..> AR : produces
|
||||
AT ..> AR : produces
|
||||
CT ..> AR : produces
|
||||
|
||||
note right of CR
|
||||
**Middleware Flow**
|
||||
AuthMiddleware on every request:
|
||||
1. Extract token from header/cookie
|
||||
2. Detect type (JWT / ts_ / config)
|
||||
3. Validate → AuthResult
|
||||
4. Set ctx_user_id for logging
|
||||
5. Store auth_result in scope state
|
||||
end note
|
||||
|
||||
note bottom of SH
|
||||
**Console** owns admin endpoints
|
||||
**Server** validates JWT + config only
|
||||
Both share JWT signing secret
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,250 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Channel Integration Architecture
|
||||
|
||||
skinparam class {
|
||||
BackgroundColor<<platform>> #E1BEE7
|
||||
BackgroundColor<<service>> #E8EAF6
|
||||
BackgroundColor<<mq>> #FFCDD2
|
||||
BackgroundColor<<bridge>> #C8E6C9
|
||||
BackgroundColor<<server>> #FFE0B2
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
}
|
||||
|
||||
' -- External Platforms --
|
||||
class "Discord" as Discord <<platform>> {
|
||||
Gateway WebSocket (v10)
|
||||
Message events
|
||||
Interaction callbacks (buttons)
|
||||
Thread-per-workstream
|
||||
--
|
||||
discord.py 2.x
|
||||
asyncio event loop
|
||||
}
|
||||
|
||||
class "Slack (future)" as Slack <<platform>> {
|
||||
Socket Mode / Events API
|
||||
Block Kit messages
|
||||
--
|
||||
Planned integration
|
||||
}
|
||||
|
||||
class "Teams (future)" as Teams <<platform>> {
|
||||
Bot Framework
|
||||
Adaptive Cards
|
||||
--
|
||||
Planned integration
|
||||
}
|
||||
|
||||
' -- Channel Service --
|
||||
class "turnstone-channel" as ChannelService <<service>> {
|
||||
entry point: turnstone-channel
|
||||
--
|
||||
One process per platform
|
||||
asyncio event loop
|
||||
Structured logging (structlog)
|
||||
--log-level, --log-format
|
||||
--
|
||||
POST /v1/api/notify (HTTP)
|
||||
GET /health
|
||||
}
|
||||
|
||||
class "DiscordBot" as Bot <<service>> {
|
||||
+on_message(msg)
|
||||
+on_interaction(interaction)
|
||||
+send(channel_id, content)
|
||||
+run(token)
|
||||
--
|
||||
discord.py Client
|
||||
Receives message events
|
||||
Sends replies + embeds
|
||||
Creates threads for workstreams
|
||||
Renders approval buttons
|
||||
escape_mentions() on send
|
||||
}
|
||||
|
||||
class "ChannelRouter" as Router <<service>> {
|
||||
+resolve_route(platform, channel_id)
|
||||
→ ws_id | None
|
||||
+register_route(channel_id, ws_id)
|
||||
+resolve_identity(platform, platform_user_id)
|
||||
→ user_id | None
|
||||
--
|
||||
Maps channels → workstreams
|
||||
Maps platform users → turnstone users
|
||||
Caches routes in memory
|
||||
}
|
||||
|
||||
class "AsyncRedisBroker" as Broker <<service>> {
|
||||
+push_inbound(msg)
|
||||
+subscribe(ws_id) → AsyncIterator
|
||||
+subscribe_global() → AsyncIterator
|
||||
+push_response(correlation_id, msg)
|
||||
--
|
||||
redis.asyncio client
|
||||
Pub/sub + queue operations
|
||||
}
|
||||
|
||||
' -- Redis MQ --
|
||||
class "Redis MQ" as Redis <<mq>> {
|
||||
turnstone:inbound (LIST)
|
||||
turnstone:events:{ws_id} (PUBSUB)
|
||||
turnstone:events:global (PUBSUB)
|
||||
turnstone:resp:{corr_id} (LIST)
|
||||
--
|
||||
Shared message bus
|
||||
Same queues as bridge protocol
|
||||
}
|
||||
|
||||
' -- Bridge + Server --
|
||||
class "turnstone-bridge" as Bridge <<bridge>> {
|
||||
BLPOP turnstone:inbound
|
||||
Drive server via HTTP
|
||||
Relay SSE → Redis pub/sub
|
||||
--
|
||||
Owns workstream lifecycle
|
||||
Auto-approve / manual approve
|
||||
}
|
||||
|
||||
class "turnstone-server" as Server <<server>> {
|
||||
POST /v1/api/send
|
||||
POST /v1/api/approve
|
||||
POST /v1/api/workstreams/new
|
||||
GET /v1/api/events?ws_id=
|
||||
--
|
||||
LLM execution + tool use
|
||||
SSE event stream
|
||||
--
|
||||
notify tool: _exec_notify()
|
||||
ServiceTokenManager (JWT)
|
||||
}
|
||||
|
||||
' -- Storage --
|
||||
class "channel_users" as CU <<storage>> {
|
||||
channel_user_id (PK)
|
||||
platform: "discord" | "slack"
|
||||
platform_user_id
|
||||
user_id → users
|
||||
linked_at
|
||||
--
|
||||
/link command creates row
|
||||
Resolved on each inbound message
|
||||
}
|
||||
|
||||
class "channel_routes" as CR <<storage>> {
|
||||
channel_type (PK)
|
||||
channel_id (PK)
|
||||
ws_id
|
||||
node_id
|
||||
created
|
||||
--
|
||||
Maps platform channels
|
||||
to turnstone workstreams
|
||||
}
|
||||
|
||||
class "services" as SVC <<storage>> {
|
||||
service_type (PK)
|
||||
service_id (PK)
|
||||
url
|
||||
last_heartbeat
|
||||
created
|
||||
--
|
||||
Heartbeat every 30s
|
||||
Stale after 120s
|
||||
ON CONFLICT DO UPDATE
|
||||
}
|
||||
|
||||
' -- Relationships --
|
||||
Discord --> Bot : gateway\nevents
|
||||
Bot --> Router : on_message\non_interaction
|
||||
Router --> Broker : SendMessage\nApproveMessage
|
||||
Router --> CU : resolve identity
|
||||
Router --> CR : resolve / register route
|
||||
Broker --> Redis : RPUSH inbound\nRPUSH resp:{id}
|
||||
|
||||
Redis --> Bridge : BLPOP inbound
|
||||
Bridge --> Server : HTTP API
|
||||
Server --> Bridge : SSE events
|
||||
Bridge --> Redis : PUBLISH events:{ws_id}\nPUBLISH events:global
|
||||
|
||||
Redis --> Broker : SUBSCRIBE events:{ws_id}
|
||||
Broker --> Bot : event stream
|
||||
Bot --> Discord : reply / embed\nbutton callback
|
||||
|
||||
Slack .[hidden]. Discord
|
||||
Teams .[hidden]. Slack
|
||||
|
||||
ChannelService --> Bot : creates + runs
|
||||
ChannelService --> Router : creates
|
||||
ChannelService --> Broker : creates
|
||||
ChannelService --> SVC : register / heartbeat /\nderegister
|
||||
|
||||
' -- Notification path (direct HTTP, bypasses MQ) --
|
||||
Server --> ChannelService : POST /v1/api/notify\n(JWT: aud=turnstone-channel)
|
||||
Server --> SVC : list_services("channel",\nmax_age_seconds=120)
|
||||
|
||||
' -- Notes --
|
||||
note right of Bot
|
||||
**Inbound Flow**
|
||||
1. Discord message arrives via gateway
|
||||
2. Bot.on_message() fires
|
||||
3. ChannelRouter resolves channel → ws_id
|
||||
(or creates new workstream)
|
||||
4. ChannelRouter resolves platform user → user_id
|
||||
via channel_users table
|
||||
5. Broker.push_inbound(SendMessage)
|
||||
6. Bridge pops from Redis, drives server
|
||||
|
||||
**Workstream Resume (evicted workstreams)**
|
||||
1. Stale route detected (no MQ owner)
|
||||
2. Existing ws_id reused directly from route
|
||||
3. CreateWorkstreamMessage sent with
|
||||
resume_ws=<ws_id>
|
||||
4. Server resumes atomically during creation
|
||||
5. Bridge emits WorkstreamResumedEvent → thread
|
||||
end note
|
||||
|
||||
note right of Broker
|
||||
**Outbound Flow**
|
||||
1. Server emits SSE events
|
||||
2. Bridge relays to Redis events:{ws_id}
|
||||
3. Broker.subscribe(ws_id) yields events
|
||||
4. Bot formats and sends to Discord thread
|
||||
end note
|
||||
|
||||
note bottom of CR
|
||||
**Approval Flow**
|
||||
1. ApprovalRequestEvent arrives via events:{ws_id}
|
||||
2. Bot renders Discord buttons (Approve / Deny)
|
||||
3. User clicks button → on_interaction()
|
||||
4. Router builds ApproveMessage
|
||||
5. Broker.push_response(correlation_id, msg)
|
||||
6. Bridge pops from resp:{id}, calls POST /api/approve
|
||||
end note
|
||||
|
||||
note bottom of CU
|
||||
**Identity Linking**
|
||||
1. User runs /link in Discord
|
||||
2. Bot opens modal requesting API token
|
||||
3. User submits ts_... API token
|
||||
4. Bot validates token against storage
|
||||
5. On success, inserts channel_users row
|
||||
6. Subsequent messages carry user_id
|
||||
7. AuthResult scopes applied by server
|
||||
end note
|
||||
|
||||
note bottom of SVC
|
||||
**Notification Flow** (direct HTTP, bypasses MQ)
|
||||
1. LLM calls notify tool → _prepare_notify()
|
||||
2. _exec_notify() checks rate limit (5/turn)
|
||||
3. Queries services table for healthy gateways
|
||||
4. Mints JWT (aud: turnstone-channel) via
|
||||
ServiceTokenManager
|
||||
5. POSTs to first healthy gateway
|
||||
6. Gateway validates JWT, resolves target
|
||||
7. adapter.send() → Discord API
|
||||
8. On failure: retry up to 3× (1s, 3s backoff)
|
||||
9. SSRF: only http(s) URLs allowed
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,116 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Notification Delivery Flow
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<server>> #FFE0B2
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<service>> #E8EAF6
|
||||
BackgroundColor<<platform>> #E1BEE7
|
||||
}
|
||||
|
||||
participant "ChatSession\n(turnstone-server)" as Session <<server>>
|
||||
participant "StorageBackend" as Storage <<storage>>
|
||||
participant "ServiceTokenManager" as STM <<server>>
|
||||
participant "Channel Gateway\n(_http.py)" as Gateway <<service>>
|
||||
participant "ChannelAdapter\n(Discord bot)" as Adapter <<service>>
|
||||
participant "Discord API" as Discord <<platform>>
|
||||
|
||||
== Prepare Phase ==
|
||||
|
||||
Session -> Session : _prepare_notify(call_id, args)
|
||||
note right
|
||||
Validates:
|
||||
- message (required, ≤2000 chars)
|
||||
- target: username OR channel_type+channel_id
|
||||
- no ambiguous targeting (both set)
|
||||
- partial targeting errors
|
||||
end note
|
||||
|
||||
== Execute Phase ==
|
||||
|
||||
Session -> Session : _exec_notify(item)
|
||||
Session -> Session : check rate limit\n(≥5 per turn?)
|
||||
|
||||
alt rate limit exceeded
|
||||
Session --> Session : "Error: rate limit exceeded"
|
||||
end
|
||||
|
||||
loop up to 3 attempts (retry delays: 1s, 3s)
|
||||
|
||||
Session -> Storage : list_services("channel",\nmax_age_seconds=120)
|
||||
Storage --> Session : services[] (sorted by\nlast_heartbeat DESC)
|
||||
|
||||
alt no healthy services
|
||||
Session -> Session : log.warning("notify.no_services")
|
||||
Session -> Session : sleep(delay)
|
||||
else services available
|
||||
|
||||
Session -> STM : bearer_header
|
||||
note right
|
||||
Lazy-init ServiceTokenManager
|
||||
aud: turnstone-channel
|
||||
scope: write
|
||||
Auto-rotates 1h JWTs
|
||||
end note
|
||||
STM --> Session : Authorization: Bearer <jwt>
|
||||
|
||||
loop for each gateway (first-healthy)
|
||||
Session -> Session : SSRF check:\nurl.startswith("http://"|"https://")
|
||||
|
||||
Session -> Gateway : POST /v1/api/notify\n+ Authorization header
|
||||
Gateway -> Gateway : _check_auth()\nvalidate JWT (aud=turnstone-channel)\nor static token
|
||||
|
||||
alt auth failed
|
||||
Gateway --> Session : 401 Unauthorized
|
||||
else auth ok
|
||||
|
||||
alt username target
|
||||
Gateway -> Storage : get_user_by_username()
|
||||
Storage --> Gateway : user
|
||||
Gateway -> Storage : list_channel_users_by_user()
|
||||
Storage --> Gateway : linked channels
|
||||
else direct target
|
||||
Gateway -> Gateway : use channel_type + channel_id
|
||||
end
|
||||
|
||||
Gateway -> Adapter : send(channel_id, content)
|
||||
note right
|
||||
escape_mentions() applied
|
||||
Chunked for 2000-char limit
|
||||
end note
|
||||
Adapter -> Discord : POST message
|
||||
Discord --> Adapter : message_id
|
||||
Adapter --> Gateway : message_id
|
||||
Gateway --> Session : 200 {results: [{status: "sent"}]}
|
||||
|
||||
Session -> Session : _notify_count += 1
|
||||
Session --> Session : "Notification sent successfully"
|
||||
note right : Return — no further\ngateways tried
|
||||
end
|
||||
end
|
||||
|
||||
alt all gateways failed
|
||||
Session -> Session : log.warning(\n"notify.all_gateways_failed")
|
||||
Session -> Session : sleep(delay)
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
alt all retries exhausted
|
||||
Session -> Session : log.warning("notify.delivery_failed")
|
||||
Session --> Session : "Error: notification delivery failed"
|
||||
end
|
||||
|
||||
== Service Registry (Background) ==
|
||||
|
||||
note over Gateway, Storage
|
||||
**Heartbeat Lifecycle**
|
||||
1. Gateway startup: register_service("channel", id, url)
|
||||
2. Every 30s: heartbeat_service("channel", id)
|
||||
3. Shutdown: deregister_service("channel", id)
|
||||
4. Stale after 120s (4 missed heartbeats)
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,166 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Watch Tool Architecture
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<server>> #FFE0B2
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<session>> #C8E6C9
|
||||
BackgroundColor<<ui>> #E8EAF6
|
||||
}
|
||||
|
||||
participant "ChatSession\n(session.py)" as Session <<session>>
|
||||
participant "WatchRunner\n(watch.py)" as Runner <<server>>
|
||||
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
|
||||
participant "WebUI / SSE\n(server.py)" as UI <<ui>>
|
||||
|
||||
== Create Phase ==
|
||||
|
||||
Session -> Session : _prepare_watch(action="create")
|
||||
note right
|
||||
Validates:
|
||||
- command via is_command_blocked()
|
||||
- poll_every → parse_duration()
|
||||
- stop_on → validate_condition()
|
||||
- max watches limit (5)
|
||||
- duplicate name check
|
||||
needs_approval = True
|
||||
end note
|
||||
|
||||
Session -> Storage : create_watch(watch_id, ws_id,\nnode_id, command, interval,\nstop_on, max_polls, next_poll)
|
||||
|
||||
Session --> UI : tool_result:\n"Watch 'pr-review' created"
|
||||
|
||||
== Poll Phase (WatchRunner daemon, every 15s) ==
|
||||
|
||||
Runner -> Storage : list_due_watches(now)
|
||||
Storage --> Runner : due_watches[]
|
||||
note right
|
||||
Filters:
|
||||
active=1 AND
|
||||
next_poll <= now AND
|
||||
node_id matches
|
||||
end note
|
||||
|
||||
loop for each due watch
|
||||
|
||||
Runner -> Runner : is_command_blocked()?
|
||||
alt blocked
|
||||
Runner -> Storage : update_watch(active=False)
|
||||
else safe
|
||||
|
||||
Runner -> Runner : subprocess.run(command)
|
||||
note right
|
||||
timeout = tool_timeout
|
||||
start_new_session = True
|
||||
output truncated at 64KB
|
||||
end note
|
||||
|
||||
Runner -> Runner : evaluate_condition(\nstop_on, output,\nexit_code, prev_output)
|
||||
note right
|
||||
**Variables:**
|
||||
output, data, exit_code,
|
||||
prev_output, changed
|
||||
|
||||
**Safe builtins only:**
|
||||
len, str, int, sorted, ...
|
||||
No import/open/exec/eval
|
||||
|
||||
**stop_on=None:**
|
||||
fires on change (skip 1st poll)
|
||||
end note
|
||||
|
||||
alt condition fired OR max_polls reached
|
||||
Runner -> Storage : update_watch(\npoll_count++,\nlast_output, active=False)
|
||||
Runner -> Runner : format_watch_message()
|
||||
Runner -> Runner : _dispatch_result(ws_id, msg)
|
||||
else not fired
|
||||
Runner -> Storage : update_watch(\npoll_count++,\nlast_output, next_poll)
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
== Dispatch Phase ==
|
||||
|
||||
note over Runner, Session
|
||||
**Three dispatch paths:**
|
||||
end note
|
||||
|
||||
alt Path A: workstream active + idle
|
||||
Runner -> Session : dispatch_fn(message)\n→ _watch_pending.put()
|
||||
Session -> Session : _dispatch_pending_watch()\n→ self.send(message)
|
||||
Session -> UI : SSE: thinking, content,\ntool calls...
|
||||
note right
|
||||
Watch result appears as
|
||||
synthetic user message.
|
||||
Model sees it and responds.
|
||||
Depth guard: max 5 chains.
|
||||
end note
|
||||
|
||||
else Path B: workstream active + busy
|
||||
Runner -> Session : dispatch_fn(message)\n→ _watch_pending.put()
|
||||
note right
|
||||
Queued. Dispatched when
|
||||
current send() reaches IDLE.
|
||||
end note
|
||||
|
||||
else Path C: workstream evicted
|
||||
Runner -> Runner : restore_fn(ws_id)
|
||||
note right
|
||||
1. mgr.create() — may evict
|
||||
another idle workstream
|
||||
2. session.resume(ws_id)
|
||||
3. set_watch_runner()
|
||||
4. register new dispatch_fn
|
||||
end note
|
||||
Runner -> Session : restored dispatch_fn(message)
|
||||
end
|
||||
|
||||
== Cancel / List ==
|
||||
|
||||
Session -> Storage : list_watches_for_ws(ws_id)
|
||||
note right : action="list" (auto-approve)
|
||||
|
||||
Session -> Storage : update_watch(active=False)
|
||||
note right : action="cancel" (auto-approve)
|
||||
|
||||
== Server Lifecycle ==
|
||||
|
||||
note over Runner, Storage
|
||||
**Startup:**
|
||||
1. WatchRunner created in main() with storage + node_id
|
||||
2. restore_fn closure captures WorkstreamManager
|
||||
3. Initial workstream: session.set_watch_runner(runner)
|
||||
4. _lifespan(): runner.start() — daemon thread begins
|
||||
|
||||
**New workstream:**
|
||||
session.set_watch_runner(runner) in create_workstream()
|
||||
→ registers dispatch_fn for ws_id
|
||||
|
||||
**Eviction / close:**
|
||||
session.close() → runner.remove_dispatch_fn(ws_id)
|
||||
Watches remain active in DB — WatchRunner uses restore_fn
|
||||
|
||||
**Restart recovery:**
|
||||
Overdue watches fire ONE immediate poll
|
||||
next_poll updated to now + interval
|
||||
Normal cadence resumes
|
||||
|
||||
**Shutdown:**
|
||||
_lifespan(): runner.stop() — joins thread
|
||||
end note
|
||||
|
||||
== REST API ==
|
||||
|
||||
note over UI, Storage
|
||||
**GET /v1/api/watches[?ws_id=X]**
|
||||
List active watches (for node or workstream)
|
||||
|
||||
**POST /v1/api/watches/{watch_id}/cancel**
|
||||
Cancel a watch (sets active=False)
|
||||
|
||||
Both require write scope
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,98 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName "IBM Plex Mono"
|
||||
skinparam componentStyle rectangle
|
||||
|
||||
title Turnstone Governance Architecture
|
||||
|
||||
package "Auth Flow" {
|
||||
[Login/Token Auth] as auth
|
||||
[_load_user_permissions()] as perms
|
||||
[_permissions_to_scopes()] as scopes
|
||||
[create_jwt()] as jwt
|
||||
}
|
||||
|
||||
package "Middleware" {
|
||||
[AuthMiddleware\n(scope check)] as mw
|
||||
[require_permission()\n(granular check)] as rp
|
||||
}
|
||||
|
||||
package "Governance Storage" {
|
||||
database "roles" as roles_db
|
||||
database "user_roles" as ur_db
|
||||
database "orgs" as orgs_db
|
||||
database "tool_policies" as tp_db
|
||||
database "prompt_templates" as pt_db
|
||||
database "usage_events" as ue_db
|
||||
database "audit_events" as ae_db
|
||||
database "workstream_templates" as wt_db
|
||||
database "workstream_template_versions" as wtv_db
|
||||
}
|
||||
|
||||
package "Runtime Enforcement" {
|
||||
[evaluate_tool_policies_batch()] as eval
|
||||
[WebUI.approve_tools()] as approve
|
||||
[record_usage_event()] as usage
|
||||
[record_audit()] as audit
|
||||
}
|
||||
|
||||
package "Template Runtime" {
|
||||
[_load_templates()] as tload
|
||||
[_render_template()\n{{model}}, {{ws_id}}, {{node_id}}] as trender
|
||||
[_init_system_messages()] as tsys
|
||||
[set_template() / /template] as tset
|
||||
}
|
||||
|
||||
package "WS Template Runtime" {
|
||||
[resolve_ws_template()] as wtr
|
||||
[apply settings\n(model, budget, prompt)] as wta
|
||||
[drift detection\n(prompt_template_hash)] as wtd
|
||||
[budget gate\n(session.send)] as wtb
|
||||
}
|
||||
|
||||
package "Console UI" {
|
||||
[Admin Panel\n10 tabs] as ui
|
||||
[governance.js] as govjs
|
||||
[sessionStorage\npermissions] as ss
|
||||
}
|
||||
|
||||
auth --> perms : user_id
|
||||
perms --> roles_db : JOIN user_roles + roles
|
||||
perms --> scopes : permission set
|
||||
scopes --> jwt : scopes + permissions
|
||||
|
||||
jwt --> mw : JWT in cookie/header
|
||||
mw --> rp : scope OK → check permission
|
||||
|
||||
rp --> ui : 403 or allow
|
||||
|
||||
eval --> tp_db : list_tool_policies()
|
||||
approve --> eval : tool names
|
||||
approve --> ae_db : (via audit)
|
||||
|
||||
usage --> ue_db : on_status()
|
||||
audit --> ae_db : admin handlers
|
||||
|
||||
govjs --> roles_db : /v1/api/admin/roles
|
||||
govjs --> tp_db : /v1/api/admin/policies
|
||||
govjs --> pt_db : /v1/api/admin/templates
|
||||
govjs --> ue_db : /v1/api/admin/usage
|
||||
govjs --> ae_db : /v1/api/admin/audit
|
||||
|
||||
tload --> pt_db : list_default_templates()\nor get_by_name()
|
||||
tload --> trender : template content
|
||||
trender --> tsys : rendered content
|
||||
tset --> tload : name or None
|
||||
|
||||
govjs --> wt_db : /v1/api/admin/ws-templates
|
||||
wtr --> wt_db : get_ws_template_by_name()
|
||||
wtr --> wta : template settings
|
||||
wta --> pt_db : prompt_template lookup
|
||||
wtd --> wt_db : compare hash
|
||||
wtb --> approve : __budget_override__
|
||||
wtv_db <.. wt_db : version snapshots
|
||||
|
||||
auth -[hidden]-> mw
|
||||
mw -[hidden]-> approve
|
||||
@enduml
|
||||
@@ -0,0 +1,177 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — MCP Architecture (Resources, Prompts, Tools)
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<mcp>> #E1BEE7
|
||||
BackgroundColor<<session>> #C8E6C9
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<server>> #FFE0B2
|
||||
BackgroundColor<<ui>> #E8EAF6
|
||||
}
|
||||
|
||||
participant "MCP Server\n(external)" as MCPSrv <<mcp>>
|
||||
participant "MCPClientManager\n(mcp_client.py)" as MCPMgr <<mcp>>
|
||||
participant "ChatSession\n(session.py)" as Session <<session>>
|
||||
participant "StorageBackend\n(governance)" as Storage <<storage>>
|
||||
participant "Server / Console\n(health + UI)" as UI <<server>>
|
||||
|
||||
participant "Console Admin UI\n(admin panel)" as Admin <<ui>>
|
||||
participant "Database\n(mcp_servers table)" as DB <<storage>>
|
||||
|
||||
== Admin-Driven Configuration ==
|
||||
|
||||
Admin -> DB : CRUD MCP server definitions\n(POST/PUT/DELETE /v1/api/admin/mcp-servers)
|
||||
|
||||
Admin -> UI : POST /v1/api/admin/mcp-servers/reload
|
||||
UI -> MCPMgr : POST /_internal/mcp-reload\n(forwarded to each node)
|
||||
MCPMgr -> MCPMgr : reconcile_sync()
|
||||
note right
|
||||
Diffs running servers against DB:
|
||||
- New entries → connect
|
||||
- Removed entries → disconnect
|
||||
- Changed entries → reconnect
|
||||
end note
|
||||
|
||||
== Startup: Connection & Discovery ==
|
||||
|
||||
MCPMgr -> DB : load_mcp_config(storage=)\n(merge config file + DB)
|
||||
|
||||
MCPMgr -> MCPSrv : initialize (stdio or HTTP)
|
||||
MCPSrv --> MCPMgr : capabilities\n(tools, resources, prompts)
|
||||
|
||||
MCPMgr -> MCPSrv : tools/list
|
||||
MCPSrv --> MCPMgr : Tool[]
|
||||
|
||||
opt resources capability
|
||||
MCPMgr -> MCPSrv : resources/list
|
||||
MCPSrv --> MCPMgr : Resource[]
|
||||
MCPMgr -> MCPSrv : resources/templates/list
|
||||
MCPSrv --> MCPMgr : ResourceTemplate[]
|
||||
end
|
||||
|
||||
opt prompts capability
|
||||
MCPMgr -> MCPSrv : prompts/list
|
||||
MCPSrv --> MCPMgr : Prompt[]
|
||||
end
|
||||
|
||||
note over MCPMgr
|
||||
Per-server storage:
|
||||
_per_server_tools, _per_server_resources, _per_server_prompts
|
||||
Copy-on-write rebuild into _tools, _resources, _prompts
|
||||
Prefix: mcp__{server}__{name}
|
||||
end note
|
||||
|
||||
MCPMgr -> Session : notify tool listeners
|
||||
MCPMgr -> Session : notify resource listeners
|
||||
|
||||
== Governance Sync (on connect & refresh) ==
|
||||
|
||||
MCPMgr -> Storage : sync_prompts_to_storage()
|
||||
note right
|
||||
For each MCP prompt:
|
||||
- Manual template exists? → skip
|
||||
- MCP template exists? → update
|
||||
(reset is_default=False)
|
||||
- New? → create (origin="mcp",
|
||||
readonly=True, is_default=False)
|
||||
Removed prompts → delete
|
||||
Protected by _sync_lock
|
||||
end note
|
||||
|
||||
== set_storage() from entry point ==
|
||||
|
||||
UI -> MCPMgr : set_storage(backend)
|
||||
note right
|
||||
If servers already connected,
|
||||
triggers immediate sync
|
||||
end note
|
||||
|
||||
== Runtime: Tool Execution ==
|
||||
|
||||
Session -> Session : _prepare_mcp_tool(func_name, args)
|
||||
note right
|
||||
approval_label = func_name
|
||||
(e.g. mcp__github__search)
|
||||
needs_approval = True
|
||||
end note
|
||||
Session -> MCPMgr : call_tool_sync(name, args)
|
||||
MCPMgr -> MCPSrv : tools/call
|
||||
MCPSrv --> MCPMgr : ToolResult
|
||||
MCPMgr --> Session : output (text)
|
||||
|
||||
== Runtime: Resource Read ==
|
||||
|
||||
Session -> Session : _prepare_read_resource(uri)
|
||||
note right
|
||||
approval_label = mcp_resource__{normalized_uri}
|
||||
URI normalized (.. resolved)
|
||||
needs_approval = True
|
||||
end note
|
||||
Session -> MCPMgr : read_resource_sync(uri)
|
||||
MCPMgr -> MCPSrv : resources/read
|
||||
MCPSrv --> MCPMgr : ReadResourceResult
|
||||
MCPMgr --> Session : content (text/blob)
|
||||
|
||||
== Runtime: Prompt Invocation ==
|
||||
|
||||
Session -> Session : _prepare_use_prompt(name, arguments)
|
||||
note right
|
||||
approval_label = mcp__srv__prompt
|
||||
Validated via is_mcp_prompt()
|
||||
needs_approval = True
|
||||
end note
|
||||
Session -> MCPMgr : get_prompt_sync(name, args)
|
||||
MCPMgr -> MCPSrv : prompts/get
|
||||
MCPSrv --> MCPMgr : GetPromptResult
|
||||
MCPMgr --> Session : messages [{role, content}]
|
||||
|
||||
== Three-Tier Refresh ==
|
||||
|
||||
group Push Notifications
|
||||
MCPSrv -> MCPMgr : ToolListChangedNotification
|
||||
MCPMgr -> MCPMgr : _refresh_server_tools()
|
||||
|
||||
MCPSrv -> MCPMgr : ResourceListChangedNotification
|
||||
MCPMgr -> MCPMgr : _refresh_server_resources()
|
||||
|
||||
MCPSrv -> MCPMgr : PromptListChangedNotification
|
||||
MCPMgr -> MCPMgr : _refresh_server_prompts()
|
||||
MCPMgr -> Storage : sync_prompts_to_storage()
|
||||
end
|
||||
|
||||
group Periodic Polling (default 4h)
|
||||
MCPMgr -> MCPMgr : _periodic_refresh()
|
||||
note right
|
||||
Only polls capabilities
|
||||
without push support.
|
||||
Staggered per-server.
|
||||
end note
|
||||
end
|
||||
|
||||
group Manual Refresh
|
||||
Session -> MCPMgr : refresh_sync()
|
||||
note right: /mcp refresh [server]
|
||||
end
|
||||
|
||||
== Policy Evaluation ==
|
||||
|
||||
note over Session
|
||||
Tool policies use fnmatch on approval_label:
|
||||
- mcp__github__* → allow (all GitHub tools/prompts)
|
||||
- mcp_resource__file:///docs/* → allow
|
||||
- mcp_resource__* → deny (block all resource reads)
|
||||
- mcp__untrusted__* → ask
|
||||
end note
|
||||
|
||||
== UI Visibility ==
|
||||
|
||||
UI -> MCPMgr : server_count, get_resources(), get_prompts()
|
||||
note over UI
|
||||
/health → mcp.servers, mcp.resources, mcp.prompts
|
||||
Server UI: magenta status badge
|
||||
Console: cluster status bar + node detail
|
||||
System message: <mcp-resources> + <mcp-prompts> catalogs
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,161 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Workstream Template Architecture
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<admin>> #E8EAF6
|
||||
BackgroundColor<<server>> #FFE0B2
|
||||
BackgroundColor<<session>> #C8E6C9
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<integration>> #F3E5F5
|
||||
}
|
||||
|
||||
participant "Admin / Console UI\n(governance.js)" as Admin <<admin>>
|
||||
participant "Server\n(server.py)" as Server <<server>>
|
||||
participant "ChatSession\n(session.py)" as Session <<session>>
|
||||
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
|
||||
participant "Integration Points\n(scheduler, channel,\nbridge, MQ)" as Integrations <<integration>>
|
||||
|
||||
== Admin CRUD ==
|
||||
|
||||
Admin -> Server : POST /v1/api/admin/ws-templates
|
||||
note right
|
||||
**Payload:**
|
||||
name, model, system_prompt,
|
||||
temperature, reasoning_effort,
|
||||
max_tokens, agent_max_turns,
|
||||
auto_approve, auto_approve_tools,
|
||||
token_budget, prompt_template,
|
||||
prompt_template_hash, notify_on_complete
|
||||
end note
|
||||
|
||||
Server -> Storage : create_ws_template()
|
||||
Storage --> Server : ws_template_id
|
||||
|
||||
Admin -> Server : PUT /v1/api/admin/ws-templates/{id}
|
||||
Server -> Storage : get_ws_template(id)\n(snapshot pre-update state)
|
||||
Storage --> Server : existing template
|
||||
Server -> Storage : create_ws_template_version()\n(version snapshot)
|
||||
Server -> Storage : update_ws_template(id, ...)
|
||||
note right
|
||||
**Versioning:**
|
||||
Each update snapshots
|
||||
pre-update state into
|
||||
workstream_template_versions.
|
||||
version counter increments.
|
||||
end note
|
||||
|
||||
Admin -> Server : GET /v1/api/admin/ws-templates
|
||||
Server -> Storage : list_ws_templates()
|
||||
|
||||
Admin -> Server : DELETE /v1/api/admin/ws-templates/{id}
|
||||
Server -> Storage : delete_ws_template(id)
|
||||
|
||||
== Workstream Creation Flow ==
|
||||
|
||||
Integrations -> Server : CreateWorkstreamMessage\n(ws_template="production-agent")
|
||||
note right
|
||||
**Sources:**
|
||||
- Console UI (Profile dropdown)
|
||||
- Scheduler (ws_template field)
|
||||
- Channel Router (ws_template)
|
||||
- Bridge (ws_template forwarding)
|
||||
- MQ Client (ws_template)
|
||||
end note
|
||||
|
||||
Server -> Storage : get_ws_template_by_name("production-agent")
|
||||
Storage --> Server : template dict
|
||||
|
||||
Server -> Server : resolve_ws_template()\napply model override
|
||||
note right
|
||||
**Settings applied:**
|
||||
- model (overrides default)
|
||||
- system_prompt
|
||||
- temperature
|
||||
- reasoning_effort
|
||||
- max_tokens
|
||||
- agent_max_turns
|
||||
- auto_approve / auto_approve_tools
|
||||
- token_budget
|
||||
- tool_search config
|
||||
end note
|
||||
|
||||
Server -> Session : mgr.create(model=template.model, ...)
|
||||
Session -> Session : _init_system_messages()
|
||||
|
||||
alt template has prompt_template
|
||||
Session -> Storage : get_prompt_template_by_name()
|
||||
Session -> Session : _render_template()\n{{model}}, {{ws_id}}, {{node_id}}
|
||||
end
|
||||
|
||||
Session -> Storage : _save_config()\n+ ws_template_id, ws_template_version
|
||||
|
||||
== Drift Detection ==
|
||||
|
||||
Server -> Server : compute prompt_template_hash\n(at creation time)
|
||||
note right
|
||||
**Hash stored:**
|
||||
SHA-256 of prompt_template
|
||||
content at ws creation time.
|
||||
Compared at next creation
|
||||
to detect upstream changes.
|
||||
end note
|
||||
|
||||
Server -> Storage : update_workstream()\n(store prompt_template_hash)
|
||||
|
||||
... later, new workstream created ...
|
||||
|
||||
Server -> Storage : get_ws_template()
|
||||
Server -> Server : compare hash vs\ncurrent prompt_template content
|
||||
alt hash mismatch
|
||||
Server -> Server : log.warning(\n"prompt template drift detected")
|
||||
end
|
||||
|
||||
== Token Budget Enforcement ==
|
||||
|
||||
Session -> Session : send(message)
|
||||
Session -> Session : _check_budget_gate()
|
||||
note right
|
||||
**Budget gate:**
|
||||
if token_budget set:
|
||||
total = prompt_tokens + completion_tokens
|
||||
if total >= token_budget:
|
||||
block further sends
|
||||
end note
|
||||
|
||||
alt budget exceeded
|
||||
Session -> Session : approve_tools(\n__budget_override__)
|
||||
note right
|
||||
Model can request
|
||||
budget override via
|
||||
special approval label.
|
||||
User must approve.
|
||||
end note
|
||||
else within budget
|
||||
Session -> Session : continue normal flow
|
||||
end
|
||||
|
||||
== Storage Schema ==
|
||||
|
||||
note over Storage
|
||||
**workstream_templates**
|
||||
id, name (unique), model, system_prompt,
|
||||
temperature, reasoning_effort, max_tokens,
|
||||
agent_max_turns, auto_approve, auto_approve_tools,
|
||||
token_budget, prompt_template, prompt_template_hash,
|
||||
tool_search, tool_search_threshold, tool_search_max_results,
|
||||
version, created_at, updated_at
|
||||
|
||||
**workstream_template_versions**
|
||||
id, template_id (FK), version, snapshot (JSON),
|
||||
created_at
|
||||
|
||||
**workstreams** (updated columns)
|
||||
+ ws_template_id: str | None
|
||||
+ ws_template_version: int | None
|
||||
|
||||
**scheduled_tasks** (updated column)
|
||||
+ ws_template: str | None
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,160 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Intent Validation (Judge) Architecture
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<session>> #C8E6C9
|
||||
BackgroundColor<<judge>> #FFE0B2
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<ui>> #E8EAF6
|
||||
BackgroundColor<<fs>> #F5F5F5
|
||||
}
|
||||
|
||||
participant "ChatSession\n(session.py)" as Session <<session>>
|
||||
participant "IntentJudge\n(judge.py)" as Judge <<judge>>
|
||||
participant "LLM Provider\n(provider)" as LLM <<judge>>
|
||||
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
|
||||
participant "WebUI / SSE\n(server.py)" as UI <<ui>>
|
||||
participant "Filesystem" as FS <<fs>>
|
||||
|
||||
== Tool Call Requires Approval ==
|
||||
|
||||
Session -> Session : _prepare_tool_calls()
|
||||
note right
|
||||
Tool calls parsed from
|
||||
LLM response. Auto-approved
|
||||
tools dispatched immediately.
|
||||
Remaining items need approval.
|
||||
end note
|
||||
|
||||
Session -> Session : _evaluate_intent(pending_items)
|
||||
|
||||
== Tier 1: Heuristic (synchronous, sub-ms) ==
|
||||
|
||||
Session -> Judge : evaluate(items, messages, callback)
|
||||
|
||||
Judge -> Judge : evaluate_heuristic()\nfor each item
|
||||
note right
|
||||
**Rule table (first match wins):**
|
||||
Critical (0.90, deny): rm /, mkfs,
|
||||
dd, pipe-to-shell, chmod 777 /,
|
||||
write/edit /etc/ .ssh/
|
||||
High (0.80, review): sudo, kill -9,
|
||||
destructive git, DROP TABLE,
|
||||
secrets, HTTP mutations, ssh/scp
|
||||
Medium (0.70, review): pip/npm install,
|
||||
write_file, MCP tools, docker ops
|
||||
Low (0.85, approve): read_file,
|
||||
list_directory, search, recall,
|
||||
read-only bash (ls, cat, grep...)
|
||||
Default: medium, 0.50, review
|
||||
end note
|
||||
|
||||
Judge --> Session : heuristic_verdicts[]
|
||||
|
||||
Session -> Session : attach _heuristic_verdict\nto each pending item
|
||||
|
||||
Session -> UI : SSE: approve_request\n{items: [{verdict: ...}],\n judge_pending: true}
|
||||
note right
|
||||
Heuristic verdict displayed
|
||||
immediately as risk badge.
|
||||
Spinner shown while LLM
|
||||
judge evaluates.
|
||||
end note
|
||||
|
||||
Session -> Storage : create_intent_verdict()\nfor each heuristic verdict
|
||||
|
||||
== Tier 2: LLM Judge (daemon thread, async) ==
|
||||
|
||||
Judge -> Judge : spawn daemon thread\n"intent-judge"
|
||||
|
||||
note over Judge, LLM
|
||||
**Context preparation:**
|
||||
1. FIFO-truncate conversation history
|
||||
to max_context_ratio of context window
|
||||
2. Append tool call details as user message
|
||||
3. System prompt defines judge role + JSON schema
|
||||
end note
|
||||
|
||||
loop up to 3 turns (timeout budget)
|
||||
|
||||
Judge -> LLM : create_completion(\nmodel, judge_messages,\ntools=[read_file, list_directory])
|
||||
LLM --> Judge : CompletionResult
|
||||
|
||||
alt tool_calls present (turn < 3)
|
||||
Judge -> Judge : _exec_read_only_tool()
|
||||
note right
|
||||
**Security hardening:**
|
||||
Blocked: /etc/, /root/,
|
||||
/proc/, /sys/, /dev/,
|
||||
.ssh, .gnupg, .aws,
|
||||
*.pem, *.key, *.p12
|
||||
File cap: 32KB
|
||||
Dir cap: 200 entries
|
||||
end note
|
||||
Judge -> FS : read_file / list_directory
|
||||
FS --> Judge : file contents
|
||||
Judge -> Judge : append tool result\nto judge_messages
|
||||
else text response (final verdict)
|
||||
Judge -> Judge : _parse_verdict()
|
||||
note right
|
||||
**4-stage JSON parsing:**
|
||||
1. Direct JSON.loads
|
||||
2. Markdown code block
|
||||
3. Brace-counting
|
||||
4. Regex field extraction
|
||||
end note
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
== Tier 3: Arbitration ==
|
||||
|
||||
Judge -> Judge : compare confidence:\nLLM vs heuristic
|
||||
note right
|
||||
Only deliver LLM verdict
|
||||
if confidence > heuristic.
|
||||
Otherwise heuristic stands.
|
||||
end note
|
||||
|
||||
alt LLM confidence > heuristic confidence
|
||||
Judge -> Session : callback(llm_verdict)
|
||||
Session -> UI : SSE: intent_verdict\n{tier: "llm", ...}
|
||||
note right
|
||||
UI replaces heuristic badge
|
||||
with LLM verdict. Spinner
|
||||
resolves to final assessment.
|
||||
end note
|
||||
Session -> Storage : create_intent_verdict()\nfor LLM verdict
|
||||
end
|
||||
|
||||
== User Decision ==
|
||||
|
||||
UI -> Session : resolve_approval(\napproved, feedback)
|
||||
|
||||
Session -> Storage : update_intent_verdict(\nverdict_id, user_decision)
|
||||
note right
|
||||
All tracked verdicts
|
||||
(heuristic + LLM) updated
|
||||
with "approved" or "denied".
|
||||
Swap-and-clear avoids racing
|
||||
with daemon judge thread.
|
||||
end note
|
||||
|
||||
== Lifecycle ==
|
||||
|
||||
note over Session, Judge
|
||||
**Lazy initialization:**
|
||||
IntentJudge created on first approval if judge_config.enabled.
|
||||
Re-uses session's provider/client by default (self-consistency).
|
||||
Cross-model: separate provider/client from [judge] config.
|
||||
|
||||
**Sub-agent exemption:**
|
||||
Plan agent and task agent skip intent validation entirely.
|
||||
|
||||
**Storage:**
|
||||
intent_verdicts table (migration 012). Verdicts queryable via
|
||||
GET /v1/api/admin/verdicts (requires admin.judge permission).
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,159 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Structured Memory Architecture
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<session>> #C8E6C9
|
||||
BackgroundColor<<facade>> #FFE0B2
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<api>> #E8EAF6
|
||||
BackgroundColor<<sdk>> #F5F5F5
|
||||
}
|
||||
|
||||
participant "ChatSession\n(session.py)" as Session <<session>>
|
||||
participant "MemoryFacade\n(memory.py)" as Facade <<facade>>
|
||||
participant "MemoryRelevance\n(memory_relevance.py)" as Relevance <<facade>>
|
||||
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
|
||||
participant "Server API\n(server.py)" as API <<api>>
|
||||
participant "Console Admin\n(console/server.py)" as Admin <<api>>
|
||||
participant "SDK Client\n(sdk/)" as SDK <<sdk>>
|
||||
|
||||
== Phase 1: Tool Path (session.send) ==
|
||||
|
||||
Session -> Session : _prepare_tool_calls()\nparse memory(action=...)
|
||||
note right
|
||||
Tool schema: 4 actions
|
||||
save, search, delete, list
|
||||
Auto-approved (no approval needed)
|
||||
end note
|
||||
|
||||
Session -> Session : _exec_memory(item)
|
||||
|
||||
alt action = save
|
||||
Session -> Facade : save_structured_memory(\nname, content, description,\nmem_type, scope, scope_id)
|
||||
Facade -> Facade : normalize_key(name)
|
||||
Facade -> Storage : create_structured_memory()
|
||||
alt unique constraint violation
|
||||
Storage --> Facade : IntegrityError
|
||||
Facade -> Storage : get_structured_memory_by_name()
|
||||
Storage --> Facade : existing row
|
||||
Facade -> Storage : update_structured_memory()
|
||||
end
|
||||
Storage --> Facade : memory_id
|
||||
Facade --> Session : (memory_id, old_content)
|
||||
Session -> Session : _init_system_messages()\nrefresh BM25 context
|
||||
end
|
||||
|
||||
alt action = search
|
||||
Session -> Facade : search_structured_memories(\nquery, mem_type, scope,\nscope_id, limit)
|
||||
Facade -> Storage : search_structured_memories()
|
||||
Storage --> Session : matched rows
|
||||
end
|
||||
|
||||
alt action = delete
|
||||
Session -> Facade : delete_structured_memory(\nname, scope, scope_id)
|
||||
Facade -> Storage : delete_structured_memory()
|
||||
Storage --> Session : bool (existed)
|
||||
Session -> Session : _init_system_messages()\nrefresh BM25 context
|
||||
end
|
||||
|
||||
== Phase 2: BM25 Relevance Injection ==
|
||||
|
||||
Session -> Session : _init_system_messages()\nevery conversation turn
|
||||
|
||||
Session -> Session : _get_visible_memories(\nlimit=fetch_limit)
|
||||
note right
|
||||
**Scope resolution:**
|
||||
1. global scope (always)
|
||||
2. workstream scope (ws_id)
|
||||
3. user scope (user_id, if auth)
|
||||
Combined and deduplicated.
|
||||
end note
|
||||
|
||||
Session -> Facade : list_structured_memories()\nper scope
|
||||
Facade -> Storage : list_structured_memories()
|
||||
Storage --> Session : up to fetch_limit rows
|
||||
|
||||
Session -> Relevance : extract_recent_context(\nmessages, max_messages=3)
|
||||
Relevance --> Session : user text context
|
||||
|
||||
Session -> Relevance : score_memories(\nmemories, context,\nk=relevance_k)
|
||||
note right
|
||||
**BM25 scoring:**
|
||||
Index over name + description
|
||||
+ content[:200] for each memory.
|
||||
Returns top-k by relevance.
|
||||
Empty query returns most recent k.
|
||||
end note
|
||||
Relevance --> Session : top-k memories
|
||||
|
||||
Session -> Relevance : build_memory_context(\nrelevant_memories)
|
||||
note right
|
||||
Formats as XML block:
|
||||
<memories>
|
||||
<memory name="..." type="..."
|
||||
scope="..." description="...">
|
||||
content (max 500 chars)
|
||||
</memory>
|
||||
</memories>
|
||||
end note
|
||||
Relevance --> Session : XML string
|
||||
|
||||
Session -> Session : inject into\nsystem message
|
||||
|
||||
== Phase 3: Server API Path ==
|
||||
|
||||
SDK -> API : GET /v1/api/memories\n?type=project&limit=20
|
||||
API -> Facade : list_structured_memories()
|
||||
Facade -> Storage : list_structured_memories()
|
||||
Storage --> API : rows
|
||||
API --> SDK : {"memories": [...], "total": N}
|
||||
|
||||
SDK -> API : POST /v1/api/memories\n{name, content, ...}
|
||||
API -> API : validate type, scope,\nname length, content length
|
||||
API -> Facade : save_structured_memory()
|
||||
Facade -> Storage : create / update
|
||||
Storage --> API : memory row
|
||||
API --> SDK : 201 (created) / 200 (updated)
|
||||
|
||||
SDK -> API : POST /v1/api/memories/search\n{query, type, ...}
|
||||
API -> Facade : search_structured_memories()
|
||||
Facade -> Storage : search_structured_memories()
|
||||
Storage --> API : matched rows
|
||||
API --> SDK : {"memories": [...], "total": N}
|
||||
|
||||
SDK -> API : DELETE /v1/api/memories/{name}\n?scope=global
|
||||
API -> Facade : delete_structured_memory()
|
||||
Facade -> Storage : delete row
|
||||
API --> SDK : {"status": "ok"}
|
||||
|
||||
== Phase 4: Console Admin Path ==
|
||||
|
||||
SDK -> Admin : GET /v1/api/admin/memories\n?type=&scope=&limit=
|
||||
Admin -> Admin : require_permission(\n"admin.memories")
|
||||
Admin -> Storage : list_structured_memories()
|
||||
Storage --> Admin : rows
|
||||
Admin --> SDK : {"memories": [...], "total": N}
|
||||
|
||||
SDK -> Admin : GET /v1/api/admin/memories/{id}
|
||||
Admin -> Storage : get_structured_memory(id)
|
||||
Storage --> Admin : memory row
|
||||
Admin --> SDK : memory JSON
|
||||
|
||||
SDK -> Admin : DELETE /v1/api/admin/memories/{id}
|
||||
Admin -> Storage : delete_structured_memory_by_id()
|
||||
Admin -> Admin : record_audit(\n"memory.delete")
|
||||
Admin --> SDK : {"status": "ok"}
|
||||
|
||||
== Configuration ==
|
||||
|
||||
note over Session, Relevance
|
||||
**MemoryConfig** (from [memory] in config.toml):
|
||||
relevance_k = 5 -- top-k memories per turn
|
||||
fetch_limit = 50 -- max memories fetched for scoring
|
||||
max_content = 32768 -- max content length per memory
|
||||
nudge_cooldown = 300 -- seconds between metacognitive nudges
|
||||
nudges = true -- enable/disable memory nudges
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,151 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Settings Architecture
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<session>> #C8E6C9
|
||||
BackgroundColor<<config>> #FFE0B2
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<api>> #E8EAF6
|
||||
BackgroundColor<<sdk>> #F5F5F5
|
||||
}
|
||||
|
||||
participant "Server\n(main)" as Server <<session>>
|
||||
participant "ConfigStore\n(config_store.py)" as Store <<config>>
|
||||
participant "SettingsRegistry\n(settings_registry.py)" as Registry <<config>>
|
||||
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
|
||||
participant "Console Admin\n(console/server.py)" as Admin <<api>>
|
||||
participant "SDK Client\n(sdk/)" as SDK <<sdk>>
|
||||
participant "ChatSession\n(session.py)" as Session <<session>>
|
||||
|
||||
== Phase 1: Server Startup ==
|
||||
|
||||
Server -> Server : parse_args()\nCLI flags override defaults
|
||||
Server -> Server : init_storage()\nSQLite / PostgreSQL
|
||||
|
||||
Server -> Store ** : ConfigStore(storage, node_id)
|
||||
Store -> Storage : get_system_settings_bulk(node_id)
|
||||
note right
|
||||
1. Load global settings (node_id="")
|
||||
2. Overlay per-node settings
|
||||
Returns {key: json_value} dict
|
||||
end note
|
||||
Storage --> Store : raw settings
|
||||
Store -> Registry : deserialize_value(key, json)\nper entry
|
||||
Registry --> Store : typed values
|
||||
Store -> Store : swap _cache atomically\nincrement _version
|
||||
|
||||
Server -> Server : warn_migrated_settings()
|
||||
note right
|
||||
Scans config.toml for keys
|
||||
now managed by ConfigStore.
|
||||
Logs warning for each overlap.
|
||||
end note
|
||||
|
||||
Server -> Server : session_factory captures\nConfigStore reference
|
||||
|
||||
== Phase 2: Settings Read (session creation) ==
|
||||
|
||||
Server -> Session : session_factory(ws_id)
|
||||
Session -> Store : get("model.temperature")
|
||||
Store -> Store : cache[key] lookup\n(lock-free)
|
||||
alt key in cache
|
||||
Store --> Session : stored value
|
||||
else key not in cache
|
||||
Store -> Registry : SETTINGS[key].default
|
||||
Registry --> Store : default value
|
||||
Store --> Session : default value
|
||||
end
|
||||
note right of Session
|
||||
Settings are captured once
|
||||
at workstream creation.
|
||||
Not re-read on every turn.
|
||||
end note
|
||||
|
||||
== Phase 3: Admin API — List / Schema ==
|
||||
|
||||
SDK -> Admin : GET /v1/api/admin/settings
|
||||
Admin -> Admin : require_permission(\n"admin.settings")
|
||||
Admin -> Store : all_effective()
|
||||
Store -> Store : merge cache with\nregistry defaults
|
||||
Store --> Admin : {key: effective_value}
|
||||
Admin -> Registry : SETTINGS (metadata)
|
||||
note right
|
||||
Annotates each setting with:
|
||||
type, default, description,
|
||||
is_stored, is_secret, constraints,
|
||||
changed_by, updated
|
||||
end note
|
||||
Admin --> SDK : {"settings": [...], "total": N}
|
||||
|
||||
SDK -> Admin : GET /v1/api/admin/settings/schema
|
||||
Admin -> Admin : require_permission(\n"admin.settings")
|
||||
Admin -> Registry : SETTINGS catalog
|
||||
Admin --> SDK : {"settings": [...], "total": N}
|
||||
|
||||
== Phase 4: Admin API — Update ==
|
||||
|
||||
SDK -> Admin : PUT /v1/api/admin/settings/\nmodel.temperature\n{"value": 0.7}
|
||||
Admin -> Admin : require_permission(\n"admin.settings")
|
||||
Admin -> Registry : validate_key("model.temperature")
|
||||
Registry --> Admin : SettingDef
|
||||
alt is_secret == true
|
||||
Admin --> SDK : 403 Forbidden
|
||||
else
|
||||
Admin -> Registry : validate_value(key, 0.7)
|
||||
note right
|
||||
Type coercion: float(0.7)
|
||||
Range check: 0.0 <= 0.7 <= 2.0
|
||||
Choices check: (none for this key)
|
||||
end note
|
||||
Registry --> Admin : typed value
|
||||
Admin -> Store : set(key, 0.7, changed_by="admin")
|
||||
Store -> Registry : serialize_value(0.7)\n=> "0.7"
|
||||
Store -> Storage : upsert_system_setting(\nkey, "0.7", node_id, ...)
|
||||
Storage --> Store : ok
|
||||
Store -> Store : swap _cache atomically
|
||||
Admin -> Admin : record_audit(\n"setting.update")
|
||||
Admin --> SDK : {"key": "...", "value": 0.7,\n"previous": 0.5}
|
||||
end
|
||||
|
||||
== Phase 5: Admin API — Delete (reset to default) ==
|
||||
|
||||
SDK -> Admin : DELETE /v1/api/admin/settings/\nmodel.temperature
|
||||
Admin -> Admin : require_permission(\n"admin.settings")
|
||||
Admin -> Store : delete("model.temperature")
|
||||
Store -> Registry : validate_key(key)
|
||||
Store -> Storage : delete_system_setting(key, node_id)
|
||||
Storage --> Store : bool (existed)
|
||||
Store -> Store : remove from cache,\nswap atomically
|
||||
Admin -> Admin : record_audit(\n"setting.delete")
|
||||
Admin --> SDK : {"status": "ok",\n"key": "...", "default": 0.5}
|
||||
|
||||
== Phase 6: Hot Reload ==
|
||||
|
||||
SDK -> Admin : POST /v1/api/_internal/\nconfig-reload
|
||||
Admin -> Store : reload()
|
||||
Store -> Storage : get_system_settings_bulk(node_id)
|
||||
Storage --> Store : all settings
|
||||
Store -> Store : rebuild cache,\nswap atomically,\nincrement _version
|
||||
note right
|
||||
Existing sessions: unchanged
|
||||
(frozen at creation time).
|
||||
New sessions: pick up
|
||||
updated values immediately.
|
||||
end note
|
||||
Admin --> SDK : {"status": "ok"}
|
||||
|
||||
== Precedence Summary ==
|
||||
|
||||
note over Server, Registry
|
||||
**Server entry point:**
|
||||
CLI flag > ConfigStore (database) > registry default
|
||||
|
||||
**CLI entry point:**
|
||||
CLI flag > config.toml > argparse default
|
||||
|
||||
**Bootstrap settings** (database, Redis, auth, server bind):
|
||||
Always from config.toml / env vars — never in ConfigStore.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,286 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 540" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif">
|
||||
<defs>
|
||||
<!-- Arrowhead markers -->
|
||||
<marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-auto">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#484f58"/>
|
||||
</marker>
|
||||
<marker id="arrow-blue" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-auto">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#58a6ff"/>
|
||||
</marker>
|
||||
<marker id="arrow-green" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-auto">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#3fb950"/>
|
||||
</marker>
|
||||
<marker id="arrow-orange" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-auto">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#f0883e"/>
|
||||
</marker>
|
||||
<marker id="arrow-coral" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-auto">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#f47067"/>
|
||||
</marker>
|
||||
<marker id="arrow-muted" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-auto">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#8b949e"/>
|
||||
</marker>
|
||||
|
||||
<!-- Card shadow filter -->
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="112%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000" flood-opacity="0.4"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="540" rx="8" fill="#0d1117"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="600" y="36" text-anchor="middle" fill="#e6edf3" font-size="15" font-weight="700" letter-spacing="3">TURNSTONE</text>
|
||||
<text x="600" y="54" text-anchor="middle" fill="#8b949e" font-size="11" letter-spacing="1">SYSTEM ARCHITECTURE</text>
|
||||
|
||||
<!-- ==================== COLUMN HEADERS ==================== -->
|
||||
<text x="90" y="86" text-anchor="middle" fill="#58a6ff" font-size="9" font-weight="600" letter-spacing="2">CLIENTS</text>
|
||||
<text x="276" y="86" text-anchor="middle" fill="#3fb950" font-size="9" font-weight="600" letter-spacing="2">GATEWAYS</text>
|
||||
<text x="480" y="86" text-anchor="middle" fill="#f0883e" font-size="9" font-weight="600" letter-spacing="2">MESSAGE QUEUE</text>
|
||||
<text x="700" y="86" text-anchor="middle" fill="#f47067" font-size="9" font-weight="600" letter-spacing="2">CLUSTER NODES</text>
|
||||
<text x="940" y="86" text-anchor="middle" fill="#f778ba" font-size="9" font-weight="600" letter-spacing="2">LLM PROVIDERS</text>
|
||||
|
||||
<!-- ==================== CLIENT BOXES ==================== -->
|
||||
<!-- CLI -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="30" y="108" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="30" y="108" width="120" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="108" width="120" height="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="111" width="120" height="2" fill="#161b22"/>
|
||||
<text x="90" y="130" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">CLI</text>
|
||||
<text x="90" y="145" text-anchor="middle" fill="#8b949e" font-size="9">terminal REPL</text>
|
||||
</g>
|
||||
|
||||
<!-- Browser UI -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="30" y="174" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="30" y="174" width="120" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="174" width="120" height="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="177" width="120" height="2" fill="#161b22"/>
|
||||
<text x="90" y="196" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Browser UI</text>
|
||||
<text x="90" y="211" text-anchor="middle" fill="#8b949e" font-size="9">HTTP + SSE</text>
|
||||
</g>
|
||||
|
||||
<!-- SDK / API -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="30" y="244" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="30" y="244" width="120" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="244" width="120" height="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="247" width="120" height="2" fill="#161b22"/>
|
||||
<text x="90" y="266" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">SDK / API</text>
|
||||
<text x="90" y="281" text-anchor="middle" fill="#8b949e" font-size="9">programmatic</text>
|
||||
</g>
|
||||
|
||||
<!-- Discord / Slack -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="30" y="314" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="30" y="314" width="120" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="314" width="120" height="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="317" width="120" height="2" fill="#161b22"/>
|
||||
<text x="90" y="336" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Discord / Slack</text>
|
||||
<text x="90" y="351" text-anchor="middle" fill="#8b949e" font-size="9">chat platforms</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== GATEWAY BOXES ==================== -->
|
||||
<!-- Console -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="216" y="118" width="120" height="58" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="216" y="118" width="120" height="5" rx="5" fill="#3fb950"/>
|
||||
<rect x="216" y="118" width="120" height="5" fill="#3fb950"/>
|
||||
<rect x="216" y="121" width="120" height="2" fill="#161b22"/>
|
||||
<text x="276" y="142" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Console</text>
|
||||
<text x="276" y="157" text-anchor="middle" fill="#8b949e" font-size="9">dashboard + proxy</text>
|
||||
<text x="276" y="169" text-anchor="middle" fill="#8b949e" font-size="9">cluster management</text>
|
||||
</g>
|
||||
|
||||
<!-- Channel Gateway -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="216" y="292" width="120" height="58" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="216" y="292" width="120" height="5" rx="5" fill="#3fb950"/>
|
||||
<rect x="216" y="292" width="120" height="5" fill="#3fb950"/>
|
||||
<rect x="216" y="295" width="120" height="2" fill="#161b22"/>
|
||||
<text x="276" y="316" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Channel Gateway</text>
|
||||
<text x="276" y="331" text-anchor="middle" fill="#8b949e" font-size="9">platform adapter</text>
|
||||
<text x="276" y="343" text-anchor="middle" fill="#8b949e" font-size="9">Discord, Slack, ...</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== REDIS MQ ==================== -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="420" y="168" width="120" height="132" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="420" y="168" width="120" height="5" rx="5" fill="#f0883e"/>
|
||||
<rect x="420" y="168" width="120" height="5" fill="#f0883e"/>
|
||||
<rect x="420" y="171" width="120" height="2" fill="#161b22"/>
|
||||
<text x="480" y="198" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Redis MQ</text>
|
||||
<line x1="438" y1="210" x2="522" y2="210" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="480" y="228" text-anchor="middle" fill="#8b949e" font-size="9">inbound queues</text>
|
||||
<text x="480" y="243" text-anchor="middle" fill="#8b949e" font-size="9">event pub/sub</text>
|
||||
<text x="480" y="258" text-anchor="middle" fill="#8b949e" font-size="9">node heartbeats</text>
|
||||
<text x="480" y="273" text-anchor="middle" fill="#8b949e" font-size="9">workstream routing</text>
|
||||
<text x="480" y="288" text-anchor="middle" fill="#8b949e" font-size="9">cluster state</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== CLUSTER NODES ==================== -->
|
||||
<!-- Cluster outline -->
|
||||
<rect x="598" y="100" width="204" height="310" rx="8" fill="none" stroke="#30363d" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<text x="700" y="422" text-anchor="middle" fill="#30363d" font-size="9" letter-spacing="1">CLUSTER</text>
|
||||
|
||||
<!-- Node A -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="614" y="118" width="170" height="100" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="614" y="118" width="170" height="5" rx="5" fill="#f47067"/>
|
||||
<rect x="614" y="118" width="170" height="5" fill="#f47067"/>
|
||||
<rect x="614" y="121" width="170" height="2" fill="#161b22"/>
|
||||
<text x="699" y="142" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Node A</text>
|
||||
<line x1="632" y1="152" x2="766" y2="152" stroke="#30363d" stroke-width="1"/>
|
||||
<!-- Bridge -->
|
||||
<rect x="626" y="162" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="661" y="180" text-anchor="middle" fill="#8b949e" font-size="9">bridge</text>
|
||||
<!-- Server -->
|
||||
<rect x="704" y="162" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="739" y="180" text-anchor="middle" fill="#8b949e" font-size="9">server</text>
|
||||
<!-- Arrow bridge to server -->
|
||||
<line x1="696" y1="176" x2="702" y2="176" stroke="#484f58" stroke-width="1" marker-end="url(#arrow)"/>
|
||||
<!-- Tools label -->
|
||||
<text x="699" y="206" text-anchor="middle" fill="#484f58" font-size="8">14 tools + MCP</text>
|
||||
</g>
|
||||
|
||||
<!-- Node B -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="614" y="238" width="170" height="100" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="614" y="238" width="170" height="5" rx="5" fill="#f47067"/>
|
||||
<rect x="614" y="238" width="170" height="5" fill="#f47067"/>
|
||||
<rect x="614" y="241" width="170" height="2" fill="#161b22"/>
|
||||
<text x="699" y="262" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Node B</text>
|
||||
<line x1="632" y1="272" x2="766" y2="272" stroke="#30363d" stroke-width="1"/>
|
||||
<!-- Bridge -->
|
||||
<rect x="626" y="282" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="661" y="300" text-anchor="middle" fill="#8b949e" font-size="9">bridge</text>
|
||||
<!-- Server -->
|
||||
<rect x="704" y="282" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="739" y="300" text-anchor="middle" fill="#8b949e" font-size="9">server</text>
|
||||
<!-- Arrow bridge to server -->
|
||||
<line x1="696" y1="296" x2="702" y2="296" stroke="#484f58" stroke-width="1" marker-end="url(#arrow)"/>
|
||||
<!-- Tools label -->
|
||||
<text x="699" y="326" text-anchor="middle" fill="#484f58" font-size="8">14 tools + MCP</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== LLM PROVIDERS ==================== -->
|
||||
<!-- OpenAI -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="870" y="130" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="870" y="130" width="140" height="5" rx="5" fill="#f778ba"/>
|
||||
<rect x="870" y="130" width="140" height="5" fill="#f778ba"/>
|
||||
<rect x="870" y="133" width="140" height="2" fill="#161b22"/>
|
||||
<text x="940" y="153" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">OpenAI</text>
|
||||
<text x="940" y="167" text-anchor="middle" fill="#8b949e" font-size="9">GPT-5, o-series</text>
|
||||
</g>
|
||||
|
||||
<!-- Anthropic -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="870" y="196" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="870" y="196" width="140" height="5" rx="5" fill="#f778ba"/>
|
||||
<rect x="870" y="196" width="140" height="5" fill="#f778ba"/>
|
||||
<rect x="870" y="199" width="140" height="2" fill="#161b22"/>
|
||||
<text x="940" y="219" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Anthropic</text>
|
||||
<text x="940" y="233" text-anchor="middle" fill="#8b949e" font-size="9">Claude 4.5 / 4.6</text>
|
||||
</g>
|
||||
|
||||
<!-- Local / vLLM -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="870" y="262" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="870" y="262" width="140" height="5" rx="5" fill="#f778ba"/>
|
||||
<rect x="870" y="262" width="140" height="5" fill="#f778ba"/>
|
||||
<rect x="870" y="265" width="140" height="2" fill="#161b22"/>
|
||||
<text x="940" y="285" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Local / vLLM</text>
|
||||
<text x="940" y="299" text-anchor="middle" fill="#8b949e" font-size="9">llama.cpp, NIM</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== STORAGE ==================== -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="614" y="450" width="170" height="52" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="614" y="450" width="170" height="5" rx="5" fill="#bc8cff"/>
|
||||
<rect x="614" y="450" width="170" height="5" fill="#bc8cff"/>
|
||||
<rect x="614" y="453" width="170" height="2" fill="#161b22"/>
|
||||
<text x="699" y="476" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">PostgreSQL / SQLite</text>
|
||||
<text x="699" y="492" text-anchor="middle" fill="#8b949e" font-size="9">conversations, memory, auth</text>
|
||||
</g>
|
||||
<text x="699" y="444" text-anchor="middle" fill="#bc8cff" font-size="9" font-weight="600" letter-spacing="2">STORAGE</text>
|
||||
|
||||
<!-- ==================== CONNECTION LINES ==================== -->
|
||||
|
||||
<!-- CLIENT -> GATEWAY connections -->
|
||||
<!-- Browser -> Console -->
|
||||
<line x1="150" y1="197" x2="214" y2="155" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
|
||||
<!-- Discord -> Channel -->
|
||||
<line x1="150" y1="337" x2="214" y2="325" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
|
||||
|
||||
<!-- CLI -> direct to Node A server (top path, curved) -->
|
||||
<path d="M 150 131 C 200 131, 200 100, 400 100 L 400 100 C 500 100, 570 140, 612 168" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.5" fill="none" stroke-dasharray="6,3" marker-end="url(#arrow-blue)"/>
|
||||
<text x="370" y="96" fill="#484f58" font-size="8" text-anchor="middle">direct</text>
|
||||
|
||||
<!-- SDK -> Redis (direct push) -->
|
||||
<line x1="150" y1="267" x2="418" y2="240" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
|
||||
|
||||
<!-- GATEWAY -> REDIS connections -->
|
||||
<!-- Console -> Redis -->
|
||||
<line x1="336" y1="160" x2="418" y2="200" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-green)"/>
|
||||
<!-- Channel -> Redis -->
|
||||
<line x1="336" y1="318" x2="418" y2="272" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-green)"/>
|
||||
|
||||
<!-- REDIS -> NODE connections -->
|
||||
<!-- Redis -> Node A bridge -->
|
||||
<line x1="540" y1="210" x2="624" y2="176" stroke="#f0883e" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-orange)"/>
|
||||
<!-- Redis -> Node B bridge -->
|
||||
<line x1="540" y1="260" x2="624" y2="296" stroke="#f0883e" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-orange)"/>
|
||||
|
||||
<!-- Console -> Node (proxy, dashed) -->
|
||||
<path d="M 336 147 C 380 130, 500 108, 612 145" stroke="#3fb950" stroke-width="1" stroke-opacity="0.4" fill="none" stroke-dasharray="4,3" marker-end="url(#arrow-green)"/>
|
||||
<text x="468" y="120" fill="#484f58" font-size="8" text-anchor="middle">proxy</text>
|
||||
|
||||
<!-- NODE -> LLM connections -->
|
||||
<!-- Node A -> LLM providers -->
|
||||
<line x1="784" y1="168" x2="868" y2="155" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.5" marker-end="url(#arrow-coral)"/>
|
||||
<line x1="784" y1="176" x2="868" y2="219" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.3"/>
|
||||
<line x1="784" y1="180" x2="868" y2="282" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.2"/>
|
||||
|
||||
<!-- Node B -> LLM providers -->
|
||||
<line x1="784" y1="288" x2="868" y2="163" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.2"/>
|
||||
<line x1="784" y1="296" x2="868" y2="222" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.3"/>
|
||||
<line x1="784" y1="300" x2="868" y2="288" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.5" marker-end="url(#arrow-coral)"/>
|
||||
|
||||
<!-- NODE -> STORAGE connections -->
|
||||
<line x1="680" y1="338" x2="680" y2="448" stroke="#bc8cff" stroke-width="1.2" stroke-opacity="0.4" stroke-dasharray="4,3" marker-end="url(#arrow-muted)"/>
|
||||
<line x1="718" y1="218" x2="718" y2="236" stroke="#484f58" stroke-width="1" stroke-opacity="0.3" stroke-dasharray="2,2"/>
|
||||
|
||||
<!-- Extensibility hint -->
|
||||
<text x="699" y="392" text-anchor="middle" fill="#30363d" font-size="10">...</text>
|
||||
|
||||
<!-- Event flow: Bridges -> Redis (dashed, bidirectional feel) -->
|
||||
<line x1="624" y1="186" x2="542" y2="220" stroke="#f0883e" stroke-width="1" stroke-opacity="0.3" stroke-dasharray="3,3"/>
|
||||
<line x1="624" y1="286" x2="542" y2="250" stroke="#f0883e" stroke-width="1" stroke-opacity="0.3" stroke-dasharray="3,3"/>
|
||||
<text x="574" y="242" fill="#484f58" font-size="7" text-anchor="middle">events</text>
|
||||
|
||||
<!-- ==================== FLOW LABELS ==================== -->
|
||||
<!-- Interactive flow label -->
|
||||
<rect x="30" y="395" width="10" height="10" rx="2" fill="none" stroke="#58a6ff" stroke-width="1.5" stroke-dasharray="3,2"/>
|
||||
<text x="46" y="404" fill="#8b949e" font-size="9">interactive (direct)</text>
|
||||
|
||||
<!-- Queue flow label -->
|
||||
<rect x="160" y="395" width="10" height="10" rx="2" fill="none" stroke="#f0883e" stroke-width="1.5"/>
|
||||
<text x="176" y="404" fill="#8b949e" font-size="9">queue-driven</text>
|
||||
|
||||
<!-- Proxy/event label -->
|
||||
<rect x="275" y="395" width="10" height="10" rx="2" fill="none" stroke="#3fb950" stroke-width="1.5" stroke-dasharray="3,2"/>
|
||||
<text x="291" y="404" fill="#8b949e" font-size="9">proxy / events</text>
|
||||
|
||||
<!-- ==================== BOTTOM DETAILS ==================== -->
|
||||
<line x1="30" y1="430" x2="1170" y2="430" stroke="#21262d" stroke-width="1"/>
|
||||
|
||||
<!-- Routing rules at bottom, left-aligned -->
|
||||
<text x="44" y="456" fill="#30363d" font-size="9" font-weight="600" letter-spacing="1">ROUTING</text>
|
||||
<circle cx="44" cy="474" r="3" fill="#f47067" opacity="0.6"/>
|
||||
<text x="54" y="477" fill="#484f58" font-size="9">target_node set → route to specific node queue</text>
|
||||
<circle cx="44" cy="494" r="3" fill="#f0883e" opacity="0.6"/>
|
||||
<text x="54" y="497" fill="#484f58" font-size="9">ws_id set → route to owning node</text>
|
||||
<circle cx="44" cy="514" r="3" fill="#58a6ff" opacity="0.6"/>
|
||||
<text x="54" y="517" fill="#484f58" font-size="9">neither → shared queue, any node picks up</text></svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9a1b0361c466327d0011a488847ea3c0365983713537d4a7c27cd7f5538ba33c
|
||||
size 164829
|
||||
oid sha256:d8ce6d2a43a991655c3f64a20b6e810fdb2f78eb767acc3d3d1b8d2c9f443181
|
||||
size 165011
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7d75da92a657525bcbb7a425dc6c8d3cafe074c3ac3bff3cf4b1d44aea607b50
|
||||
size 330156
|
||||
oid sha256:c9daca81971ba7a8ed6736d23d5373c69435158fa6240b9880d14fc4759ab580
|
||||
size 329673
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:637458e0d78df82752746e519cd7300a830c8ce211f21625694ad0c162ca316d
|
||||
size 481637
|
||||
oid sha256:01fbb3338df6426cefc2811541a865f268673b4febf32f524c264d120bc068fa
|
||||
size 589546
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dc3b64c9e48153641af62ed43fbc1d89a31d1a8a61e7e71cfc550c805000310d
|
||||
size 288290
|
||||
oid sha256:da9d32000e3d92d92ce621661ced60f276f9b5be652f5ed6123b400505415f4a
|
||||
size 319702
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b842683d238664a3e35d04358fecfc56cefd013f7dca5f13357b0376f881e1b3
|
||||
size 245043
|
||||
oid sha256:6dd3c923d1e1c49b5f91d8d342fb4b0d49a46d432460379ad146a9e3b075a05a
|
||||
size 277234
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b22d5980fe5cc4b8466ba0797113dc8fa83fab8df24b5dacceaf97e62e2e25b0
|
||||
size 187649
|
||||
oid sha256:2229801220548e4794baa67e27a0a39dc7968c826a28fa8144763e678c8ed733
|
||||
size 192556
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:90e4f74be795b530e711faa87bc6eb2b3bf6abb68d8fac8ebff7aaf30c6fbe53
|
||||
oid sha256:09535722ba975e47cf0557a40b6c481f125ff2022c396f79715c3bba9f715871
|
||||
size 222032
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d33b9b3affcdb07086b5aebca8a3b9c2b009cdfc6f360950a0e72e65fbcb8f17
|
||||
size 201602
|
||||
oid sha256:ed457b10b534b5fc2a5e190b281d7ded4dd1615da2229d67a373cf5dddccd059
|
||||
size 201601
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:adac93a0bb062d7199b819a600a0983ff011a75d16928fb80322cbb41f9284ea
|
||||
size 158866
|
||||
oid sha256:7896c6e041b6dbb89d034468fa980c8fe645df5eb969d45ef966ccc6399edac2
|
||||
size 200083
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:69f201cff948cb0a19810b7c4ad26d346f869ee2dd3141eba4f353332efa2e21
|
||||
size 373649
|
||||
oid sha256:35cf3a6942f62dabcbbe012ac2f9e6f155332c894692981b076de5a25c1f3330
|
||||
size 374055
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:97e7210cd8f1ad195f4d5e25e778d82df3c08c5c6e0f09722e84a7a453714867
|
||||
size 411664
|
||||
oid sha256:a74b4b8b5dbfb1a51a01100b731477968942b01218bad9451a3d5a9cb3003294
|
||||
size 411665
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4c3214ef416c1dfe4fa17834c2b6f4071a8093cfdb2b862848ca79938f726a13
|
||||
oid sha256:84524f4bc900708ac8adf081591d336f862830188eb8505e71a0f071b339d923
|
||||
size 252599
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c9823a41e09611c5c0530d9fc12ad4139cfcc3ae238dc665b2888ec94d7d6781
|
||||
size 195708
|
||||
oid sha256:e7c3e40c10425d721f833390ae3531c09af501157fd3142531ba4eba86ff719d
|
||||
size 197112
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0c615984373b4893b6cc5755604f137541e9d391862122746a4fcbae63543563
|
||||
size 201041
|
||||
oid sha256:fb5e7c221f6b1ee1082b37da32e65c45b5e468014cf6881a210e5b4d8a8dca8b
|
||||
size 255736
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:af5ab3126bf685afe68e24bc4b0ed97371d0ebdb77bf4d76c0331ab120580cc0
|
||||
size 248809
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1380065cbb5f95b5ea7dc6b2a00986c455b82888af60784980dffbd936460dcf
|
||||
size 431129
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f0f6097840fccdbfe16cd5e4c9f5d063b2c36942460a944df68b8ec947e63ea3
|
||||
size 221452
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:96176a09e65e90dadc32d5e9ed778423842be89204d2cf382225f53a90cfaf01
|
||||
size 258547
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f4dac4948d928b4705936d73b4d159aa1e89315ec0397616ca914bbf19e7a1ce
|
||||
size 206479
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e4593873599342b2830fedd5d783e9a28eab0bb0d6589798ef6ef2649eeee80f
|
||||
size 324518
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c06d7086d7965eb9fe333396f027133d42507cf120bfe8dc851c009a8768ec48
|
||||
size 284926
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:feb31b9d05ea56544053ad00457c389acba977c07ecc08870960e6e0ca64aa11
|
||||
size 279971
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c89628ed917dfd576c1af75c68fe5fed9beadaaee9dcea7aa7a1643867c4f1b9
|
||||
size 344323
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:83c0e6aad3eb19f6bc475a30a77215e801da3da5930f0462417fe7eb6eda6be2
|
||||
size 347144
|
||||
+48
-6
@@ -27,6 +27,9 @@ Console dashboard: http://localhost:8090
|
||||
| `server` | 8080 | default | Web UI + chat workstreams + LLM |
|
||||
| `bridge` | — | default | Redis-to-HTTP bridge (multi-node routing) |
|
||||
| `console` | 8090 | default | Cluster dashboard |
|
||||
| `channel` | — | production | Channel gateway (Discord, Slack, etc.) |
|
||||
| `server-1`…`server-10` | — | cluster | 10-node server fleet (PostgreSQL required) |
|
||||
| `bridge-1`…`bridge-10` | — | cluster | Matching bridge fleet |
|
||||
| `sim` | — | sim | Multi-node cluster simulator |
|
||||
|
||||
## Profiles
|
||||
@@ -37,6 +40,18 @@ Console dashboard: http://localhost:8090
|
||||
docker compose up
|
||||
```
|
||||
|
||||
**Production** — adds PostgreSQL and the channel gateway. Requires `POSTGRES_PASSWORD` and (for Discord) `TURNSTONE_DISCORD_TOKEN`:
|
||||
|
||||
```bash
|
||||
docker compose --profile production up
|
||||
```
|
||||
|
||||
**Cluster** — 10-node server/bridge fleet sharing PostgreSQL and Redis. Access all nodes via the console at `:8090`. Requires `POSTGRES_PASSWORD`:
|
||||
|
||||
```bash
|
||||
docker compose --profile cluster up
|
||||
```
|
||||
|
||||
**Sim** — adds the simulator. Can run alongside the full stack or standalone with just Redis and the console:
|
||||
|
||||
```bash
|
||||
@@ -84,8 +99,35 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_AUTH_ENABLED` | — | Set to `1` to require Bearer token auth |
|
||||
| `TURNSTONE_AUTH_TOKEN` | — | Shared auth token for server/bridge/console |
|
||||
| `TURNSTONE_AUTH_ENABLED` | — | Set to `1` to require authentication |
|
||||
| `TURNSTONE_AUTH_TOKEN` | — | Config-file token for server/bridge/console (backward compat, works alongside JWT) |
|
||||
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required when using user identity / JWT auth) |
|
||||
|
||||
### Database
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_DB_BACKEND` | `sqlite` | Storage backend: `sqlite` or `postgresql` |
|
||||
| `TURNSTONE_DB_URL` | — | Database URL (e.g. `postgresql://user:pass@db:5432/turnstone`). For SQLite, defaults to `/data/.turnstone.db` |
|
||||
|
||||
The database stores workstream history, user accounts, and API tokens. When using JWT auth, a database backend is required for user storage.
|
||||
|
||||
> **First-time setup:** After deploying with auth enabled, create an initial admin user by running `turnstone-admin create-user` inside the container:
|
||||
>
|
||||
> ```bash
|
||||
> docker compose exec server turnstone-admin create-user --username admin --name "Admin"
|
||||
> ```
|
||||
>
|
||||
> You will be prompted to set a password. Use it to log in via the UI or SDK, then create additional users through the admin API. Pass `--token --scopes read,write,approve` to also generate an initial API token.
|
||||
|
||||
### Channel Gateway
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_DISCORD_TOKEN` | — | Discord bot token (required to enable Discord adapter) |
|
||||
| `TURNSTONE_DISCORD_GUILD` | `0` | Restrict to a single Discord guild (0 = all guilds) |
|
||||
|
||||
The channel service runs in the `production` profile. When `TURNSTONE_DISCORD_TOKEN` is set, the Discord adapter connects to the Discord Gateway and routes messages through Redis MQ to the bridge and server. See [Channel Integrations](channels.md) for full setup instructions including Discord application creation and user account linking.
|
||||
|
||||
### Simulator
|
||||
|
||||
@@ -101,13 +143,13 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
|
||||
|
||||
## Scaling
|
||||
|
||||
Scale to multiple server/bridge pairs:
|
||||
For multi-node testing, use the `cluster` profile which provides 10 dedicated server+bridge pairs with unique node IDs (`node-1` through `node-10`), resource limits, and shared PostgreSQL:
|
||||
|
||||
```bash
|
||||
docker compose up --scale server=3 --scale bridge=3
|
||||
POSTGRES_PASSWORD=secret docker compose --profile cluster up
|
||||
```
|
||||
|
||||
Each bridge auto-generates a unique node ID from its container hostname. When scaling `server`, remove the host port mapping (or use a reverse proxy) to avoid port conflicts.
|
||||
The default `server` and `bridge` also run alongside the cluster nodes (11 total). All nodes are accessible via the console dashboard at `:8090`.
|
||||
|
||||
## Volumes
|
||||
|
||||
@@ -128,7 +170,7 @@ docker compose build
|
||||
docker compose build --no-cache
|
||||
```
|
||||
|
||||
All five entry points are installed in a single image: `turnstone-server`, `turnstone-bridge`, `turnstone-console`, `turnstone-sim`, `turnstone-eval`.
|
||||
All entry points are installed in a single image: `turnstone-server`, `turnstone-bridge`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`, `turnstone-sim`, `turnstone-eval`.
|
||||
|
||||
## Cleanup
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# Governance
|
||||
|
||||
Turnstone governance provides role-based access control (RBAC), tool execution
|
||||
policies, prompt templates, usage tracking, and audit logging for the admin
|
||||
console.
|
||||
|
||||
## Architecture
|
||||
|
||||
See [diagram: 19-governance-architecture.puml](diagrams/19-governance-architecture.puml).
|
||||
|
||||
### RBAC (Roles & Permissions)
|
||||
|
||||
The permission model has two layers:
|
||||
|
||||
1. **Scopes** (legacy) — `read`, `write`, `approve`. Checked by `AuthMiddleware`
|
||||
on every request based on URL path classification.
|
||||
2. **Permissions** (granular) — 15 permission strings checked per-endpoint by
|
||||
`require_permission()`.
|
||||
|
||||
**Built-in roles** (seeded by migration 008):
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| admin | read, write, approve, admin.users, admin.roles, admin.orgs, admin.policies, admin.templates, admin.audit, admin.usage, admin.schedules, admin.watches, tools.approve, workstreams.create, workstreams.close |
|
||||
| operator | read, write, workstreams.create, workstreams.close |
|
||||
| viewer | read |
|
||||
|
||||
Custom roles can be created with any subset of the 15 valid permissions.
|
||||
|
||||
**Auth flow:**
|
||||
1. User logs in (password or API token) → `_load_user_permissions()` aggregates
|
||||
permissions from all assigned roles
|
||||
2. `_permissions_to_scopes()` derives legacy scopes (any `admin.*` → `approve`)
|
||||
3. JWT created with both `scopes` and `permissions` claims
|
||||
4. Middleware checks scope → handler checks permission via `require_permission()`
|
||||
|
||||
### Tool Policies
|
||||
|
||||
Admin-defined rules that control tool execution:
|
||||
|
||||
- **Pattern matching**: Glob syntax via `fnmatch` (e.g., `bash*`, `file_write`, `*`)
|
||||
- **Actions**: `allow` (auto-approve), `deny` (block), `ask` (normal approval flow)
|
||||
- **Priority**: Higher priority evaluated first, first match wins
|
||||
- **Enforcement**: `evaluate_tool_policies_batch()` called in `WebUI.approve_tools()`
|
||||
before the `auto_approve` check
|
||||
- **MCP granular policies**: MCP resources and prompts are evaluated using their
|
||||
`approval_label` for fine-grained control:
|
||||
- Resource reads: `mcp_resource__{uri}` (e.g., `mcp_resource__file:///docs/*` to allow,
|
||||
`mcp_resource__*` to deny all)
|
||||
- Prompt invocations: `mcp__{server}__{prompt}` (e.g., `mcp__trusted__*` to allow,
|
||||
`mcp__*` to require approval for all)
|
||||
- Built-in tools continue to use `func_name` for backward compatibility
|
||||
|
||||
### Prompt Templates
|
||||
|
||||
Admin-curated system message templates injected at workstream startup:
|
||||
|
||||
- **Runtime behavior**: Templates are loaded once at session creation and injected
|
||||
into the system message *before* user `instructions`. Templates set the baseline;
|
||||
instructions customize per-workstream behavior.
|
||||
- **Default templates**: All `is_default=true` templates auto-apply to new
|
||||
workstreams, concatenated in alphabetical order by name. Use name prefixes
|
||||
(e.g. `01-safety`, `02-style`) to control ordering.
|
||||
- **Explicit selection**: `--template <name>` CLI flag, `template` field on
|
||||
`POST /v1/api/workstreams/new`, console creation modal dropdown, scheduled task
|
||||
config, and channel adapter config. An explicit template *replaces* defaults.
|
||||
- **Variables**: Three built-in placeholders resolved at load time:
|
||||
`{{model}}` (active model name), `{{ws_id}}` (workstream ID),
|
||||
`{{node_id}}` (server node ID). Unrecognized placeholders are kept as-is.
|
||||
- **Runtime switching**: `/template <name>` to switch, `/template clear` to revert
|
||||
to defaults, `/template` to show current. Persisted across resume.
|
||||
- **Categories**: general, engineering, support, custom, mcp
|
||||
- **Content limit**: 32 KB per template (enforced on create/update)
|
||||
- **Storage**: `prompt_templates` table with JSON `variables` array. Migration 010
|
||||
adds `template` column to `scheduled_tasks`.
|
||||
- **MCP sync**: MCP server prompts auto-sync into prompt_templates with
|
||||
`origin="mcp"`, `mcp_server` set, and `readonly=True`. Manual templates take
|
||||
precedence on name collision. MCP-synced content updates reset `is_default` to
|
||||
prevent compromised servers from injecting defaults. Admin UI shows origin badge
|
||||
and disables edit/delete for MCP-sourced templates.
|
||||
|
||||
### Workstream Templates
|
||||
|
||||
Workstream templates are behavioral profiles applied at workstream creation — the next level beyond prompt templates. While prompt templates inject system message text, workstream templates define the complete workstream configuration.
|
||||
|
||||
**What they define:**
|
||||
- System prompt (inline text OR reference to a prompt template by name)
|
||||
- Model override (empty = server default)
|
||||
- Temperature, reasoning effort, max tokens, agent max turns
|
||||
- Auto-approve policy (blanket and/or per-tool list)
|
||||
- Token budget (0 = unlimited; warns at 80%, requires approval at 100%)
|
||||
- Completion notification config (stored for v2 dispatch)
|
||||
|
||||
**Storage:** `workstream_templates` table (migration 011) with auto-versioning. Edits snapshot the pre-update state into `workstream_template_versions`. Workstreams record which template and version spawned them via `ws_template_id` + `ws_template_version` columns.
|
||||
|
||||
**Applied once at creation:** Template settings are snapshot-applied to the workstream's config. Not a live binding — template updates don't affect running workstreams.
|
||||
|
||||
**Prompt template drift detection:** When a workstream template references a prompt template, a SHA-256 hash of the prompt content is stored at ws_template create/update time. At workstream creation, the server compares the stored hash against current content and logs a warning on mismatch.
|
||||
|
||||
**Admin API:** 7 endpoints under `/v1/api/admin/ws-templates` (list, create, get, update, delete, version history) plus a read-only summary at `/v1/api/ws-templates`. Permission: `admin.ws_templates`.
|
||||
|
||||
**Console UI:** "WS Templates" tab with CRUD table, create/edit modals (name, description, system prompt source toggle, model, auto-approve, per-tool auto-approve, temperature, reasoning effort, max tokens, agent max turns, token budget, enabled), and version history modal. "Profile" dropdown on workstream creation modal. "WS Template" dropdown on scheduler create/edit modals.
|
||||
|
||||
**Token budget enforcement:** Tracked in `session.send()`. At 80% consumption, emits an info message. At 100%, the next turn requires explicit approval via the `__budget_override__` synthetic tool name (reuses existing approval UI — inline in browser, Discord buttons, bridge auto-approve). The synthetic name can be targeted by tool policies (e.g. `__budget_override__` → `allow` for admins).
|
||||
|
||||
**SDK:** Python (`list_ws_templates`, `create_ws_template`, `get_ws_template`, `update_ws_template`, `delete_ws_template`, `list_ws_template_versions`) and TypeScript (`listWsTemplates`, `createWsTemplate`, etc.) on both sync and async console clients. `ws_template` parameter on `create_workstream()` for both server and console SDKs.
|
||||
|
||||
### Usage Tracking
|
||||
|
||||
Per-LLM-request token and tool call metrics:
|
||||
|
||||
- **Recording**: `on_status()` in `WebUI` records a `usage_event` after each
|
||||
LLM response with prompt/completion tokens, tool call count, model, ws_id
|
||||
- **Querying**: `GET /v1/api/admin/usage` with `group_by` (day/hour/model/user)
|
||||
and time range filtering
|
||||
- **Pruning**: `prune_usage_events(retention_days=90)` and
|
||||
`prune_audit_events(retention_days=365)` run automatically via the
|
||||
console scheduler's periodic cleanup cycle
|
||||
|
||||
### Audit Logging
|
||||
|
||||
Append-only trail of admin actions:
|
||||
|
||||
- **Recording**: `record_audit()` helper called from all admin mutation handlers
|
||||
- **Events captured**: user.create, user.delete, token.create, token.revoke,
|
||||
channel.link, channel.unlink, role.create, role.update, role.delete,
|
||||
role.assign, role.unassign, policy.create, policy.update, policy.delete,
|
||||
template.create, template.update, template.delete,
|
||||
ws_template.create, ws_template.update, ws_template.delete, org.update
|
||||
- **Querying**: `GET /v1/api/admin/audit` with action/user/time filters + pagination
|
||||
|
||||
## Database Schema
|
||||
|
||||
Migration 008 adds 7 tables:
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `orgs` | Organizations (single default org for now) |
|
||||
| `roles` | Named permission bundles (3 builtin + custom) |
|
||||
| `user_roles` | User-to-role assignments (composite PK) |
|
||||
| `tool_policies` | Per-tool approve/deny/ask rules |
|
||||
| `prompt_templates` | Reusable system message templates |
|
||||
| `usage_events` | Per-request token/tool metrics |
|
||||
| `audit_events` | Admin action log |
|
||||
|
||||
Also adds `org_id` column to `users` table.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All under `/v1/api/admin/` (requires `approve` scope + granular permission).
|
||||
|
||||
| Group | Endpoints | Permission |
|
||||
|-------|-----------|------------|
|
||||
| Users / Tokens / Channels | 9 (CRUD) | `admin.users` |
|
||||
| Roles | 7 (CRUD + assignment) | `admin.roles` / `admin.users` |
|
||||
| Orgs | 3 (list, get, update) | `admin.orgs` |
|
||||
| Tool Policies | 4 (CRUD) | `admin.policies` |
|
||||
| Prompt Templates | 4 (CRUD) | `admin.templates` |
|
||||
| Schedules | 6 (CRUD + runs) | `admin.schedules` |
|
||||
| WS Templates | 7 (CRUD + versions + summary) | `admin.ws_templates` |
|
||||
| Watches | 3 (list, create, cancel) | `admin.watches` |
|
||||
| Usage | 1 (aggregated query) | `admin.usage` |
|
||||
| Audit | 1 (paginated, filtered) | `admin.audit` |
|
||||
|
||||
Full OpenAPI spec at `/openapi.json` and Swagger UI at `/docs`.
|
||||
|
||||
## Admin Console UI
|
||||
|
||||
6 new tabs added to the admin panel (11 total):
|
||||
|
||||
- **Roles** — CRUD roles, permission checkbox grid, user role assignment modal
|
||||
- **Policies** — CRUD tool policies with colored action badges (green/red/amber)
|
||||
- **Templates** — CRUD prompt templates with wide modal, textarea editor
|
||||
- **WS Templates** — CRUD workstream templates with create/edit modals, version history
|
||||
- **Usage** — Summary readouts + CSS bar chart, time range + group-by selectors
|
||||
- **Audit** — Filterable log with relative timestamps, load-more pagination
|
||||
|
||||
Tabs are permission-gated: hidden if the user lacks the required permission.
|
||||
|
||||
## SDK
|
||||
|
||||
Both Python and TypeScript console SDKs expose governance methods:
|
||||
|
||||
**Python** (`TurnstoneConsole` / `AsyncTurnstoneConsole`):
|
||||
- `list_roles()`, `create_role()`, `update_role()`, `delete_role()`
|
||||
- `list_user_roles()`, `assign_role()`, `unassign_role()`
|
||||
- `list_orgs()`, `get_org()`, `update_org()`
|
||||
- `list_policies()`, `create_policy()`, `update_policy()`, `delete_policy()`
|
||||
- `list_templates()`, `create_template()`, `update_template()`, `delete_template()`
|
||||
- `list_ws_templates()`, `create_ws_template()`, `get_ws_template()`, `update_ws_template()`, `delete_ws_template()`, `list_ws_template_versions()`
|
||||
- `get_usage(since, group_by=...)`, `get_audit(action=..., limit=...)`
|
||||
|
||||
**TypeScript** (`TurnstoneConsole`):
|
||||
- Same methods with camelCase naming and typed interfaces
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Privilege escalation prevented**: `admin_assign_role` blocks self-assignment
|
||||
and requires caller to hold a superset of the target role's permissions
|
||||
- **Permission validation**: Role create/update validates permissions against
|
||||
a 15-item allowlist (`_VALID_PERMISSIONS`)
|
||||
- **Self-deletion blocked**: `admin_delete_user` rejects attempts to delete
|
||||
your own account (matching the self-assignment guard on role endpoints)
|
||||
- **Field allowlists**: Storage `update_*` methods filter fields against
|
||||
allowlists (`_ROLE_MUTABLE`, `_POLICY_MUTABLE`, etc.) — handler bugs
|
||||
cannot overwrite `role_id`, `builtin`, `created`, or other protected columns
|
||||
- **Bootstrap safety**: `handle_auth_setup` fails and rolls back if admin role
|
||||
assignment fails, preventing locked-out first user
|
||||
- **API token RBAC**: `_authenticate_api_token` loads permissions from user's
|
||||
roles, ensuring API tokens are subject to RBAC enforcement
|
||||
- **Policy evaluation is fail-open**: If storage is unavailable, tool policies
|
||||
degrade to the existing approval flow (not auto-approve)
|
||||
- **Audit IP resolution**: `_audit_context()` prefers `X-Forwarded-For` for
|
||||
client IP when behind a reverse proxy, falling back to `request.client.host`
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
# Intent Validation (Judge)
|
||||
|
||||
> See also: [Judge Architecture diagram](diagrams/png/22-judge-architecture.png)
|
||||
|
||||
Intent validation provides advisory risk assessments for tool calls that require
|
||||
human approval. An LLM judge evaluates each tool call and presents a structured
|
||||
verdict alongside the approval prompt, helping users make informed decisions.
|
||||
|
||||
## Overview
|
||||
|
||||
When a tool call requires approval, the intent validation system runs a two-tier
|
||||
evaluation:
|
||||
|
||||
1. **Heuristic tier** (instant) -- Pattern-based risk classification using a
|
||||
rule table. Zero cost, sub-millisecond latency.
|
||||
2. **LLM judge tier** (async) -- Semantic evaluation using an LLM with
|
||||
read-only tool access. Runs on a daemon thread and delivers its verdict
|
||||
progressively.
|
||||
|
||||
The verdict is purely advisory -- the user always makes the final decision.
|
||||
|
||||
The heuristic verdict is attached to the `approve_request` SSE event immediately.
|
||||
The LLM verdict arrives later via an `intent_verdict` SSE event, allowing the
|
||||
UI to show a spinner that resolves into a richer assessment. Both verdicts are
|
||||
persisted to the `intent_verdicts` table for audit and future calibration.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### config.toml
|
||||
|
||||
```toml
|
||||
[judge]
|
||||
enabled = true
|
||||
model = "" # empty = same as session model
|
||||
provider = "" # empty = same as session provider
|
||||
base_url = ""
|
||||
api_key = ""
|
||||
confidence_threshold = 0.7 # reserved for v2 smart approvals (not used in v1)
|
||||
max_context_ratio = 0.5 # max % of judge context window for history
|
||||
timeout = 60.0 # seconds (generous for local models)
|
||||
read_only_tools = true # judge can use read_file/list_directory
|
||||
```
|
||||
|
||||
All fields are optional. The judge is enabled by default; use `enabled = false`
|
||||
(or `--no-judge` on the command line) to disable it.
|
||||
|
||||
### CLI flags
|
||||
|
||||
```
|
||||
--judge / --no-judge Enable/disable (default: enabled)
|
||||
--judge-model MODEL Model for judge
|
||||
--judge-provider PROVIDER Provider for judge
|
||||
--judge-timeout SECONDS LLM judge timeout (default: 60)
|
||||
--judge-confidence FLOAT Confidence threshold (default: 0.7)
|
||||
```
|
||||
|
||||
CLI flags override `config.toml` values.
|
||||
|
||||
---
|
||||
|
||||
## Judge Model Selection
|
||||
|
||||
- **Default (self-consistency)**: When `model` is empty, the session model
|
||||
evaluates its own tool calls. Research shows self-consistency achieves
|
||||
comparable accuracy to multi-agent debate at a fraction of the cost.
|
||||
- **Cross-model**: Use a different model for the judge (e.g. local model for
|
||||
the session, commercial model for the judge). Set `model` and `provider`
|
||||
in the `[judge]` config section, or use `--judge-model` / `--judge-provider`
|
||||
CLI flags.
|
||||
- **Cross-provider**: When both `model` and `provider` are set, the judge
|
||||
creates its own LLM client. You can optionally specify `base_url` and
|
||||
`api_key` for non-default endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Heuristic Rules
|
||||
|
||||
The heuristic tier scans a priority-ordered rule table (critical first, low
|
||||
last) and returns the first matching rule. Each rule has:
|
||||
|
||||
- **Tool pattern**: fnmatch glob matched against `func_name` and `approval_label`
|
||||
- **Argument patterns**: Regex patterns matched against the tool's primary
|
||||
argument text (command string for bash, path for file tools, JSON for others)
|
||||
- **Risk level, confidence, and recommendation**: Pre-assigned per rule
|
||||
|
||||
### Rule tiers
|
||||
|
||||
| Tier | Confidence | Recommendation | Examples |
|
||||
|----------|-----------|----------------|----------|
|
||||
| Critical | 0.90 | deny | `rm -rf /`, `mkfs`, `dd if=`, pipe-to-shell, chmod 777 on root, write/edit to `/etc/`, `.ssh/` |
|
||||
| High | 0.80 | review | `sudo`, `kill -9`, destructive git (`reset --hard`, `push --force`, `clean -f`), DROP TABLE, write/edit secrets (`.env`, `.pem`, `.key`), HTTP mutations, `ssh`/`scp` |
|
||||
| Medium | 0.70 | review | Package installs (`pip`, `npm`, `apt`, `brew`, `cargo`), `write_file` (default), MCP tool calls, Docker operations |
|
||||
| Low | 0.85 | approve | `read_file`, `list_directory`, `search`, `recall`, `man`, `use_prompt`, read-only bash commands (`ls`, `cat`, `head`, `grep`, `find`, etc.) |
|
||||
|
||||
When no rule matches, the heuristic returns a default verdict: medium risk,
|
||||
0.50 confidence, "review" recommendation.
|
||||
|
||||
The bash "read-only" rule handles simple pipelines and command chains by
|
||||
splitting on `|`, `&&`, `||`, and `;`, then checking each segment individually.
|
||||
|
||||
---
|
||||
|
||||
## LLM Judge
|
||||
|
||||
The LLM judge runs on a daemon thread and performs a multi-turn evaluation:
|
||||
|
||||
1. **Context preparation**: Recent conversation history is FIFO-truncated to
|
||||
fit within `max_context_ratio` of the judge's context window. The tool call
|
||||
details (name, approval label, full arguments) are appended as a user message.
|
||||
2. **Multi-turn loop** (up to 5 turns): The judge can use `read_file` and
|
||||
`list_directory` to gather evidence before rendering its verdict. Each tool
|
||||
result is appended to the conversation and the judge is called again. On
|
||||
the final turn, tools are stripped and a forcing message instructs the
|
||||
judge to render its verdict immediately.
|
||||
3. **Verdict parsing**: The judge's final text response is parsed as JSON using
|
||||
a four-stage strategy: direct parse, markdown code block extraction,
|
||||
brace-counting, and regex field extraction as a last resort.
|
||||
4. **Arbitration**: If the LLM verdict has higher confidence than the heuristic,
|
||||
it replaces the heuristic via the `intent_verdict` SSE event.
|
||||
|
||||
### Read-only tools
|
||||
|
||||
When `read_only_tools` is enabled (default), the judge can use two tools:
|
||||
|
||||
- **`read_file`**: Read file contents (capped at 32 KB)
|
||||
- **`list_directory`**: List directory entries (capped at 200 entries)
|
||||
|
||||
Security hardening blocks access to sensitive paths:
|
||||
|
||||
| Category | Blocked patterns |
|
||||
|----------|-----------------|
|
||||
| System directories | `/etc/`, `/root/`, `/proc/`, `/sys/`, `/dev/` |
|
||||
| Credential directories | `.ssh`, `.gnupg`, `.aws`, `.config` |
|
||||
| Key files | `*.pem`, `*.key`, `*.p12`, `*.pfx` |
|
||||
|
||||
### Timeout
|
||||
|
||||
The `timeout` setting (default 60 seconds) is a total budget across all judge
|
||||
turns. Time is decremented after each LLM call. If the budget expires mid-turn,
|
||||
the judge attempts to parse whatever partial response is available.
|
||||
|
||||
---
|
||||
|
||||
## Verdict Structure
|
||||
|
||||
Each verdict (heuristic or LLM) is an `IntentVerdict` with these fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------------|------------|-------------|
|
||||
| `verdict_id` | string | Unique identifier (UUID prefix) |
|
||||
| `call_id` | string | Correlates with the tool call's `call_id` |
|
||||
| `func_name` | string | Tool function name |
|
||||
| `intent_summary` | string | One-sentence description of what the tool call does |
|
||||
| `risk_level` | string | `"low"`, `"medium"`, `"high"`, or `"critical"` |
|
||||
| `confidence` | float | 0.0--1.0, how certain the assessment is |
|
||||
| `recommendation` | string | `"approve"`, `"review"`, or `"deny"` |
|
||||
| `reasoning` | string | Explanation of the assessment |
|
||||
| `evidence` | list[str] | Supporting evidence (rule name or file excerpts) |
|
||||
| `tier` | string | `"heuristic"` or `"llm"` |
|
||||
| `judge_model` | string | Model used (empty for heuristic tier) |
|
||||
| `latency_ms` | int | Evaluation time in milliseconds |
|
||||
|
||||
---
|
||||
|
||||
## Session Integration
|
||||
|
||||
The judge is lazy-initialized on first use. When `ChatSession` prepares tool
|
||||
calls for approval, it calls `_evaluate_intent()` which:
|
||||
|
||||
1. Instantiates `IntentJudge` if not already created
|
||||
2. Extracts `func_name`, `func_args`, and `approval_label` from each pending item
|
||||
3. Calls `judge.evaluate()` which returns heuristic verdicts immediately
|
||||
4. Attaches each heuristic verdict to its item as `_heuristic_verdict`
|
||||
5. The daemon thread runs the LLM judge and delivers results via `ui.on_intent_verdict()`
|
||||
|
||||
Sub-agents (plan agent, task agent) are exempt from intent validation -- they
|
||||
always get full tool visibility without judge evaluation.
|
||||
|
||||
---
|
||||
|
||||
## Storage and Audit
|
||||
|
||||
All verdicts are persisted to the `intent_verdicts` table (migration 012):
|
||||
|
||||
- Heuristic verdicts are stored when the `approve_request` event is emitted
|
||||
- LLM verdicts are stored when the `intent_verdict` event is delivered
|
||||
- The `user_decision` column is updated when the user approves or denies
|
||||
|
||||
The console admin panel exposes verdict history via:
|
||||
|
||||
```
|
||||
GET /v1/api/admin/verdicts?ws_id=&since=&until=&risk_level=&limit=100&offset=0
|
||||
```
|
||||
|
||||
This endpoint requires the `admin.judge` permission.
|
||||
|
||||
---
|
||||
|
||||
## SSE Events
|
||||
|
||||
### `approve_request` (extended)
|
||||
|
||||
When the judge is active, `approve_request` items include a `verdict` field
|
||||
with the heuristic verdict, and the event includes a `judge_pending` flag
|
||||
indicating that an LLM verdict is in flight:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "approve_request",
|
||||
"judge_pending": true,
|
||||
"items": [
|
||||
{
|
||||
"call_id": "call_abc123",
|
||||
"header": "bash: npm install express",
|
||||
"preview": "",
|
||||
"func_name": "bash",
|
||||
"approval_label": "bash",
|
||||
"needs_approval": true,
|
||||
"error": null,
|
||||
"verdict": {
|
||||
"verdict_id": "a1b2c3d4e5f6",
|
||||
"call_id": "call_abc123",
|
||||
"func_name": "bash",
|
||||
"intent_summary": "Package installation: npm install express",
|
||||
"risk_level": "medium",
|
||||
"confidence": 0.70,
|
||||
"recommendation": "review",
|
||||
"reasoning": "Command installs a software package which may modify the environment.",
|
||||
"evidence": ["Matched rule: package-install"],
|
||||
"tier": "heuristic",
|
||||
"judge_model": "",
|
||||
"latency_ms": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `intent_verdict`
|
||||
|
||||
Delivered asynchronously when the LLM judge completes. The UI replaces the
|
||||
heuristic verdict badge with the LLM verdict:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "intent_verdict",
|
||||
"verdict_id": "f7e8d9c0b1a2",
|
||||
"call_id": "call_abc123",
|
||||
"func_name": "bash",
|
||||
"intent_summary": "Install Express.js web framework via npm",
|
||||
"risk_level": "medium",
|
||||
"confidence": 0.85,
|
||||
"recommendation": "review",
|
||||
"reasoning": "The command installs express from npm. This is a well-known package but will modify node_modules and package.json.",
|
||||
"evidence": ["Checked package.json — express is not currently a dependency"],
|
||||
"tier": "llm",
|
||||
"judge_model": "gpt-5",
|
||||
"latency_ms": 2340
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v2 Calibration Path
|
||||
|
||||
Run v1 with all tools requiring manual approval to build a local verdict
|
||||
dataset. The `intent_verdicts` table accumulates `(tool_call, verdict,
|
||||
user_decision)` triples over time. In v2, calibration tooling will analyze
|
||||
this dataset to:
|
||||
|
||||
- Identify tools that are always approved (candidates for auto-approve policies)
|
||||
- Detect false positives in heuristic rules
|
||||
- Measure LLM judge accuracy against human decisions
|
||||
- Recommend policy changes to reduce approval fatigue
|
||||
|
||||
This data-driven approach means v1 is both useful on its own and a foundation
|
||||
for automated policy tuning.
|
||||
+569
@@ -0,0 +1,569 @@
|
||||
# Structured Memory
|
||||
|
||||
> See also: [Memory Architecture diagram](diagrams/png/23-memory-architecture.png)
|
||||
|
||||
The structured memory system gives the AI persistent, typed, scoped memories
|
||||
that survive across sessions and workstreams. Memories are automatically
|
||||
surfaced in the system message via BM25 relevance scoring, so the model has
|
||||
contextual recall without explicit search.
|
||||
|
||||
## Overview
|
||||
|
||||
Each memory has three dimensions:
|
||||
|
||||
- **Type** -- categorizes the memory's purpose
|
||||
- **Scope** -- controls visibility boundaries
|
||||
- **Name** -- unique identifier within a scope (snake_case, normalized)
|
||||
|
||||
### Memory types
|
||||
|
||||
| Type | Purpose |
|
||||
|-------------|------------------------------------------------------------|
|
||||
| `user` | User preferences, conventions, working style |
|
||||
| `project` | Project-specific knowledge, architecture, patterns |
|
||||
| `feedback` | Corrections, lessons learned, things to avoid |
|
||||
| `reference` | Reference material, documentation, specifications |
|
||||
|
||||
### Memory scopes
|
||||
|
||||
| Scope | Visibility |
|
||||
|--------------|-----------------------------------------------------------|
|
||||
| `global` | Visible to all workstreams and users |
|
||||
| `workstream` | Visible only within the originating workstream |
|
||||
| `user` | Follows the authenticated user across workstreams |
|
||||
|
||||
A memory's identity is the tuple `(name, scope, scope_id)`. Saving a memory
|
||||
with the same identity upserts -- updating content while preserving the ID.
|
||||
|
||||
### BM25 relevance injection
|
||||
|
||||
On every conversation turn, the system:
|
||||
|
||||
1. Fetches up to `fetch_limit` memories visible in the current scope
|
||||
2. Extracts context from the last 3 user messages
|
||||
3. Scores memories against that context using a BM25 index
|
||||
4. Injects the top `relevance_k` memories into the system message as
|
||||
`<memories>` XML tags
|
||||
5. Appends a hint telling the model how many memories are in scope
|
||||
|
||||
This means the model always has its most relevant memories available without
|
||||
explicit recall -- but can still use `memory(action='search')` for deeper
|
||||
lookup.
|
||||
|
||||
### Nudges
|
||||
|
||||
The metacognition layer can nudge the model to save memories at appropriate
|
||||
moments (e.g., after a correction or when resuming a workstream). Nudges are
|
||||
rate-limited by `nudge_cooldown` and can be disabled entirely.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### config.toml
|
||||
|
||||
```toml
|
||||
[memory]
|
||||
relevance_k = 5 # top-k memories injected per turn
|
||||
fetch_limit = 50 # max memories fetched from storage for scoring
|
||||
max_content = 32768 # max content length per memory (characters)
|
||||
nudge_cooldown = 300 # minimum seconds between memory nudges
|
||||
nudges = true # enable/disable metacognitive nudges
|
||||
```
|
||||
|
||||
All fields are optional. Defaults are shown above.
|
||||
|
||||
---
|
||||
|
||||
## Tool Usage
|
||||
|
||||
The `memory` tool supports four actions:
|
||||
|
||||
### save
|
||||
|
||||
Store or update a memory.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "save",
|
||||
"name": "project_architecture",
|
||||
"content": "The project uses a hexagonal architecture with...",
|
||||
"description": "Core architecture patterns",
|
||||
"type": "project",
|
||||
"scope": "global"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
|---------------|----------|-------------|------------------------------------------|
|
||||
| `name` | yes | -- | Snake_case identifier (max 256 chars) |
|
||||
| `content` | yes | -- | Memory content (max `max_content` chars) |
|
||||
| `description` | no | `""` | Short description for relevance matching |
|
||||
| `type` | no | `"project"` | One of: user, project, feedback, reference |
|
||||
| `scope` | no | `"global"` | One of: global, workstream, user |
|
||||
|
||||
### search
|
||||
|
||||
Find memories by query (BM25 full-text search).
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "search",
|
||||
"query": "authentication patterns",
|
||||
"type": "project",
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
|-----------|----------|---------|--------------------------------------|
|
||||
| `query` | yes | -- | Search query |
|
||||
| `type` | no | `""` | Filter by type |
|
||||
| `scope` | no | `""` | Filter by scope |
|
||||
| `limit` | no | `20` | Max results (capped at 50) |
|
||||
|
||||
### delete
|
||||
|
||||
Remove a memory by name.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "delete",
|
||||
"name": "outdated_pattern",
|
||||
"scope": "global"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
|------------|----------|------------|--------------------------|
|
||||
| `name` | yes | -- | Memory name to delete |
|
||||
| `scope` | no | `"global"` | Scope of the memory |
|
||||
|
||||
### list
|
||||
|
||||
List all memories with optional filters.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list",
|
||||
"type": "feedback",
|
||||
"limit": 50
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
|-----------|----------|---------|----------------------------|
|
||||
| `type` | no | `""` | Filter by type |
|
||||
| `scope` | no | `""` | Filter by scope |
|
||||
| `limit` | no | `20` | Max results (capped at 50) |
|
||||
|
||||
---
|
||||
|
||||
## Server API
|
||||
|
||||
Four endpoints on the server for programmatic memory access.
|
||||
|
||||
### `GET /v1/api/memories`
|
||||
|
||||
List memories with optional filters.
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|------------|--------|----------|---------|------------------------------|
|
||||
| `type` | string | no | `""` | Filter by memory type |
|
||||
| `scope` | string | no | `""` | Filter by scope |
|
||||
| `scope_id` | string | no | `""` | Filter by scope ID |
|
||||
| `limit` | int | no | `100` | Max results (capped at 200) |
|
||||
|
||||
When `scope=user` and `scope_id` is omitted, the authenticated user's ID is
|
||||
used automatically.
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"memory_id": "a1b2c3d4-e5f6-...",
|
||||
"name": "project_architecture",
|
||||
"description": "Core architecture patterns",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "The project uses a hexagonal architecture...",
|
||||
"created": "2026-03-10T10:00:00",
|
||||
"updated": "2026-03-12T14:30:00"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/memories`
|
||||
|
||||
Save or upsert a structured memory.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "deployment_process",
|
||||
"content": "Deploy via GitHub Actions. Staging auto-deploys on push to main.",
|
||||
"description": "CI/CD deployment workflow",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": ""
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|--------------|--------|----------|-------------|--------------------------------------|
|
||||
| `name` | string | yes | -- | Memory name (max 256 chars) |
|
||||
| `content` | string | yes | -- | Memory content (max 65536 chars) |
|
||||
| `description`| string | no | `""` | Short description for search ranking |
|
||||
| `type` | string | no | `"project"` | One of: user, project, feedback, reference |
|
||||
| `scope` | string | no | `"global"` | One of: global, workstream, user |
|
||||
| `scope_id` | string | no | `""` | Scope qualifier (auto-resolved for user scope) |
|
||||
|
||||
**Response (created):** `201`
|
||||
|
||||
```json
|
||||
{
|
||||
"memory_id": "a1b2c3d4-e5f6-...",
|
||||
"name": "deployment_process",
|
||||
"description": "CI/CD deployment workflow",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "Deploy via GitHub Actions...",
|
||||
"created": "2026-03-14T10:00:00",
|
||||
"updated": "2026-03-14T10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (updated):** `200` -- same schema, returned when a memory with the
|
||||
same `(name, scope, scope_id)` already existed.
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Condition |
|
||||
|--------|------------------------------------|
|
||||
| 400 | Missing name, empty content, invalid type/scope, content too long |
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/memories/search`
|
||||
|
||||
Search memories by query. Uses POST for the request body but is non-mutating
|
||||
(requires only `read` scope).
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "authentication",
|
||||
"type": "project",
|
||||
"scope": "",
|
||||
"scope_id": "",
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|------------|--------|----------|---------|--------------------------------|
|
||||
| `query` | string | yes | -- | Search query |
|
||||
| `type` | string | no | `""` | Filter by type |
|
||||
| `scope` | string | no | `""` | Filter by scope |
|
||||
| `scope_id` | string | no | `""` | Filter by scope ID |
|
||||
| `limit` | int | no | `20` | Max results (capped at 50) |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"memory_id": "a1b2c3d4-e5f6-...",
|
||||
"name": "auth_patterns",
|
||||
"description": "Authentication architecture",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "JWT tokens with HS256...",
|
||||
"created": "2026-03-10T10:00:00",
|
||||
"updated": "2026-03-12T14:30:00"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /v1/api/memories/{name}`
|
||||
|
||||
Delete a memory by name and scope.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------|----------------------|
|
||||
| `name` | string | Memory name |
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|------------|--------|----------|------------|---------------------|
|
||||
| `scope` | string | no | `"global"` | Scope of the memory |
|
||||
| `scope_id` | string | no | `""` | Scope qualifier |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{"status": "ok", "name": "deployment_process"}
|
||||
```
|
||||
|
||||
**Response (not found):** `404`
|
||||
|
||||
```json
|
||||
{"error": "Memory 'deployment_process' not found"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Console Admin API
|
||||
|
||||
Four admin endpoints for cross-workstream memory management. All require the
|
||||
`admin.memories` permission.
|
||||
|
||||
### `GET /v1/api/admin/memories`
|
||||
|
||||
List memories across all scopes (no automatic scope resolution).
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|------------|--------|----------|---------|------------------------------|
|
||||
| `type` | string | no | `""` | Filter by type |
|
||||
| `scope` | string | no | `""` | Filter by scope |
|
||||
| `scope_id` | string | no | `""` | Filter by scope ID |
|
||||
| `limit` | int | no | `100` | Max results (capped at 200) |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"memory_id": "a1b2c3d4-e5f6-...",
|
||||
"name": "project_architecture",
|
||||
"description": "Core architecture patterns",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "The project uses...",
|
||||
"created": "2026-03-10T10:00:00",
|
||||
"updated": "2026-03-12T14:30:00"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/admin/memories/search`
|
||||
|
||||
Search memories by query (uses query parameters, not POST body).
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|------------|--------|----------|---------|-------------------------------|
|
||||
| `q` | string | yes | -- | Search query |
|
||||
| `type` | string | no | `""` | Filter by type |
|
||||
| `scope` | string | no | `""` | Filter by scope |
|
||||
| `scope_id` | string | no | `""` | Filter by scope ID |
|
||||
| `limit` | int | no | `20` | Max results (capped at 50) |
|
||||
|
||||
**Response:** `200` -- same schema as `GET /v1/api/admin/memories`.
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/admin/memories/{memory_id}`
|
||||
|
||||
Get a single memory by ID.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-------------|--------|------------------------|
|
||||
| `memory_id` | string | Memory UUID |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"memory_id": "a1b2c3d4-e5f6-...",
|
||||
"name": "project_architecture",
|
||||
"description": "Core architecture patterns",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "The project uses...",
|
||||
"created": "2026-03-10T10:00:00",
|
||||
"updated": "2026-03-12T14:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (not found):** `404`
|
||||
|
||||
```json
|
||||
{"error": "Memory not found"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /v1/api/admin/memories/{memory_id}`
|
||||
|
||||
Delete a memory by ID. Records an audit event (`memory.delete`).
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-------------|--------|------------------------|
|
||||
| `memory_id` | string | Memory UUID |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
**Response (not found):** `404`
|
||||
|
||||
```json
|
||||
{"error": "Memory not found"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SDK
|
||||
|
||||
### Python
|
||||
|
||||
The server SDK uses `mem_type` (not `type`) to avoid shadowing the Python
|
||||
builtin.
|
||||
|
||||
```python
|
||||
from turnstone.sdk import TurnstoneServer
|
||||
|
||||
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
# Save a memory
|
||||
mem = client.save_memory(
|
||||
"api_conventions",
|
||||
"All endpoints use /v1/ prefix. JSON responses.",
|
||||
description="API design patterns",
|
||||
mem_type="project",
|
||||
scope="global",
|
||||
)
|
||||
print(mem.memory_id)
|
||||
|
||||
# Search memories
|
||||
results = client.search_memories("authentication", mem_type="project", limit=10)
|
||||
for m in results.memories:
|
||||
print(f"{m['name']}: {m['description']}")
|
||||
|
||||
# List memories
|
||||
all_mems = client.list_memories(mem_type="feedback", limit=50)
|
||||
|
||||
# Delete a memory
|
||||
client.delete_memory("api_conventions", scope="global")
|
||||
```
|
||||
|
||||
Console admin SDK:
|
||||
|
||||
```python
|
||||
from turnstone.sdk import TurnstoneConsole
|
||||
|
||||
with TurnstoneConsole("http://localhost:9090", token="tok_xxx") as admin:
|
||||
# List all memories (admin view, no scope auto-resolution)
|
||||
result = admin.list_memories(scope="global", limit=100)
|
||||
|
||||
# Search
|
||||
result = admin.search_memories("architecture", mem_type="project")
|
||||
|
||||
# Get by ID
|
||||
mem = admin.get_memory("a1b2c3d4-e5f6-...")
|
||||
|
||||
# Delete by ID
|
||||
admin.delete_memory("a1b2c3d4-e5f6-...")
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
```typescript
|
||||
import { TurnstoneServer } from "@turnstone/sdk";
|
||||
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://localhost:8080",
|
||||
token: "tok_xxx",
|
||||
});
|
||||
|
||||
// Save a memory
|
||||
const mem = await client.saveMemory({
|
||||
name: "api_conventions",
|
||||
content: "All endpoints use /v1/ prefix. JSON responses.",
|
||||
description: "API design patterns",
|
||||
type: "project",
|
||||
scope: "global",
|
||||
});
|
||||
|
||||
// Search memories
|
||||
const results = await client.searchMemories({
|
||||
query: "authentication",
|
||||
type: "project",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
// List memories
|
||||
const all = await client.listMemories({ type: "feedback", limit: 50 });
|
||||
|
||||
// Delete a memory
|
||||
await client.deleteMemory("api_conventions", { scope: "global" });
|
||||
```
|
||||
|
||||
Console admin SDK:
|
||||
|
||||
```typescript
|
||||
import { TurnstoneConsole } from "@turnstone/sdk";
|
||||
|
||||
const admin = new TurnstoneConsole({
|
||||
baseUrl: "http://localhost:9090",
|
||||
token: "tok_xxx",
|
||||
});
|
||||
|
||||
// List, search, get, delete by ID
|
||||
const mems = await admin.listMemories({ scope: "global" });
|
||||
const found = await admin.searchMemories({ q: "auth", limit: 20 });
|
||||
const one = await admin.getMemory("a1b2c3d4-e5f6-...");
|
||||
await admin.deleteMemory("a1b2c3d4-e5f6-...");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Storage
|
||||
|
||||
Memories are stored in the `structured_memories` table (migration 013).
|
||||
The unique constraint on `(name, scope, scope_id)` ensures upsert semantics.
|
||||
The name is normalized on save: lowercased, hyphens and spaces replaced with
|
||||
underscores.
|
||||
|
||||
## Architecture
|
||||
|
||||
See [Memory Architecture diagram](diagrams/png/23-memory-architecture.png) for
|
||||
the full data flow covering the session tool path, API path, admin path, and
|
||||
BM25 relevance injection.
|
||||
+85
-18
@@ -15,8 +15,10 @@ The Python SDK is included in the `turnstone` package — no extra install requi
|
||||
```python
|
||||
from turnstone.sdk import TurnstoneServer
|
||||
|
||||
# Synchronous client
|
||||
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
# Synchronous client — login with username/password
|
||||
with TurnstoneServer("http://localhost:8080") as client:
|
||||
client.login(username="alice", password="s3cret")
|
||||
|
||||
# Create a workstream
|
||||
ws = client.create_workstream(name="Analysis")
|
||||
|
||||
@@ -33,6 +35,15 @@ with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
client.close_workstream(ws.ws_id)
|
||||
```
|
||||
|
||||
Alternatively, authenticate with an API token:
|
||||
|
||||
```python
|
||||
with TurnstoneServer("http://localhost:8080") as client:
|
||||
client.login(token="ts_abc123...")
|
||||
ws = client.create_workstream(name="CI run")
|
||||
result = client.send_and_wait("Run the test suite.", ws.ws_id)
|
||||
```
|
||||
|
||||
### Async Client
|
||||
|
||||
```python
|
||||
@@ -40,7 +51,8 @@ import asyncio
|
||||
from turnstone.sdk import AsyncTurnstoneServer
|
||||
|
||||
async def main():
|
||||
async with AsyncTurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
async with AsyncTurnstoneServer("http://localhost:8080") as client:
|
||||
await client.login(username="alice", password="s3cret")
|
||||
ws = await client.create_workstream(name="demo")
|
||||
async for event in client.stream_events(ws.ws_id):
|
||||
if event.type == "content":
|
||||
@@ -57,18 +69,21 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|
||||
|----------|--------|---------|
|
||||
| **Workstreams** | `list_workstreams()` | `ListWorkstreamsResponse` |
|
||||
| | `dashboard()` | `DashboardResponse` |
|
||||
| | `create_workstream(*, name, model, auto_approve)` | `CreateWorkstreamResponse` |
|
||||
| | `create_workstream(*, name, model, auto_approve, ws_template)` | `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` |
|
||||
| | `cancel(ws_id)` | `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` |
|
||||
| **Saved** | `list_saved_workstreams()` | `ListSavedWorkstreamsResponse` |
|
||||
| **Auth** | `login(username=..., password=...)` | `AuthLoginResponse` |
|
||||
| | `login(token="ts_xxx")` | `AuthLoginResponse` |
|
||||
| | `logout()` | `StatusResponse` |
|
||||
| | `auth_status()` | `AuthStatusResponse` |
|
||||
| **Health** | `health()` | `HealthResponse` |
|
||||
|
||||
### Console Client API
|
||||
@@ -81,9 +96,23 @@ Both `TurnstoneConsole` (sync) and `AsyncTurnstoneConsole` (async) expose:
|
||||
| | `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` |
|
||||
| | `snapshot()` | `ClusterSnapshotResponse` |
|
||||
| | `create_workstream(*, node_id, name, model, initial_message, ws_template)` | `ConsoleCreateWsResponse` |
|
||||
| **Schedules** | `list_schedules()` | `ListSchedulesResponse` |
|
||||
| | `create_schedule(*, name, schedule_type, initial_message, ...)` | `ScheduleInfo` |
|
||||
| | `get_schedule(task_id)` | `ScheduleInfo` |
|
||||
| | `update_schedule(task_id, *, name=..., enabled=..., ...)` | `ScheduleInfo` |
|
||||
| | `delete_schedule(task_id)` | `StatusResponse` |
|
||||
| | `list_schedule_runs(task_id, *, limit=50)` | `ListScheduleRunsResponse` |
|
||||
| **WS Templates** | `list_ws_templates()` | `ListWsTemplatesResponse` |
|
||||
| | `create_ws_template(*, name, description, ...)` | `WsTemplateInfo` |
|
||||
| | `get_ws_template(template_id)` | `WsTemplateInfo` |
|
||||
| | `update_ws_template(template_id, *, name=..., enabled=..., ...)` | `WsTemplateInfo` |
|
||||
| | `delete_ws_template(template_id)` | `StatusResponse` |
|
||||
| | `list_ws_template_versions(template_id)` | `ListWsTemplateVersionsResponse` |
|
||||
| **Streaming** | `stream_cluster_events()` | `Iterator[ClusterEvent]` |
|
||||
| **Auth** | `login(token)` / `logout()` | `AuthLoginResponse` / `StatusResponse` |
|
||||
| **Auth** | `login(username=..., password=...)` / `login(token="ts_xxx")` | `AuthLoginResponse` |
|
||||
| | `logout()` | `StatusResponse` |
|
||||
| **Health** | `health()` | `ConsoleHealthResponse` |
|
||||
|
||||
### Event Types
|
||||
@@ -107,6 +136,7 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
|
||||
| `error` | `ErrorEvent` | `message` |
|
||||
| `info` | `InfoEvent` | `message` |
|
||||
| `stream_end` | `StreamEndEvent` | — |
|
||||
| `cancelled` | `CancelledEvent` | — |
|
||||
|
||||
**Global events** (from `stream_global_events()`):
|
||||
|
||||
@@ -125,6 +155,9 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
|
||||
| `node_lost` | `NodeLostEvent` | `node_id` |
|
||||
| `cluster_state` | `ClusterStateEvent` | `ws_id`, `node_id`, `state`, `tokens` |
|
||||
| `ws_created` | `ClusterWsCreatedEvent` | `ws_id`, `node_id`, `name` |
|
||||
| `ws_closed` | `ClusterWsClosedEvent` | `ws_id` |
|
||||
| `ws_rename` | `ClusterWsRenameEvent` | `ws_id`, `name` |
|
||||
| `snapshot` | `ClusterSnapshotEvent` | `nodes`, `overview`, `timestamp` |
|
||||
|
||||
### TurnResult
|
||||
|
||||
@@ -165,10 +198,11 @@ Located at `sdk/typescript/`. Zero runtime dependencies for browsers; uses nativ
|
||||
```typescript
|
||||
import { TurnstoneServer } from "@turnstone/sdk";
|
||||
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://localhost:8080",
|
||||
token: "tok_xxx",
|
||||
});
|
||||
const client = new TurnstoneServer({ baseUrl: "http://localhost:8080" });
|
||||
|
||||
// Login with username/password or API token
|
||||
await client.login({ username: "alice", password: "s3cret" });
|
||||
// or: await client.login({ token: "ts_abc123..." });
|
||||
|
||||
// Create workstream and send message
|
||||
const ws = await client.createWorkstream({ name: "demo" });
|
||||
@@ -188,16 +222,14 @@ for await (const event of client.streamEvents(ws.ws_id)) {
|
||||
```typescript
|
||||
import { TurnstoneConsole } from "@turnstone/sdk";
|
||||
|
||||
const console = new TurnstoneConsole({
|
||||
baseUrl: "http://localhost:8081",
|
||||
token: "tok_xxx",
|
||||
});
|
||||
const client = new TurnstoneConsole({ baseUrl: "http://localhost:8090" });
|
||||
await client.login({ username: "alice", password: "s3cret" });
|
||||
|
||||
const overview = await console.overview();
|
||||
const overview = await client.overview();
|
||||
console.log(`Nodes: ${overview.nodes}, Workstreams: ${overview.workstreams}`);
|
||||
|
||||
// Stream cluster events
|
||||
for await (const event of console.clusterEvents()) {
|
||||
for await (const event of client.clusterEvents()) {
|
||||
console.log(event.type, event);
|
||||
}
|
||||
```
|
||||
@@ -256,3 +288,38 @@ sdk/typescript/ TypeScript SDK (npm package)
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
When auth is enabled on the server, the SDK handles JWT-based authentication automatically.
|
||||
|
||||
### Login Flow
|
||||
|
||||
There are two ways to authenticate:
|
||||
|
||||
1. **Username + password** — calls `POST /v1/api/auth/login` with credentials. The server validates against the user database and returns a JWT.
|
||||
|
||||
2. **API token** — calls `POST /v1/api/auth/login` with a `ts_`-prefixed token string. The server looks up the token, resolves the associated user, and returns a JWT.
|
||||
|
||||
In both cases the server returns the JWT in the response body and as a `Set-Cookie` header. The SDK extracts the JWT and includes it as a `Bearer` token in the `Authorization` header on all subsequent requests.
|
||||
|
||||
```python
|
||||
# Username + password
|
||||
client.login(username="alice", password="s3cret")
|
||||
|
||||
# API token (created via admin API or turnstone-admin CLI)
|
||||
client.login(token="ts_abc123...")
|
||||
```
|
||||
|
||||
### Token Lifecycle
|
||||
|
||||
- JWTs have a configurable expiry (default: 24 hours).
|
||||
- `client.auth_status()` returns the current user identity and scopes without refreshing the token.
|
||||
- `client.logout()` clears the stored JWT from the client.
|
||||
- If a request returns 401, the SDK raises `TurnstoneAPIError` — the caller is responsible for re-authenticating.
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
The config-file token (`TURNSTONE_AUTH_TOKEN`) still works as a simple Bearer token for environments that do not use the user/JWT system. When the server receives a non-JWT Bearer token, it falls back to the legacy token check.
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
# Security and Authentication
|
||||
|
||||
Turnstone uses a layered authentication system with three token types,
|
||||
hierarchical scopes, and a split architecture where the console manages
|
||||
credentials while individual server nodes validate JWTs locally.
|
||||
|
||||
---
|
||||
|
||||
## Token Types
|
||||
|
||||
### Config-file tokens
|
||||
|
||||
Static tokens defined in `config.toml` or the `TURNSTONE_AUTH_TOKEN`
|
||||
environment variable. Validated in-memory using `hmac.compare_digest`
|
||||
(timing-safe). Each token maps to a role that determines its scopes.
|
||||
|
||||
```toml
|
||||
[[auth.tokens]]
|
||||
value = "tok_legacy"
|
||||
role = "full" # full → {read, write, approve}
|
||||
```
|
||||
|
||||
Role mappings: `"read"` → `{read}`, `"full"` → `{read, write, approve}`.
|
||||
|
||||
Config tokens are sent directly as `Authorization: Bearer tok_legacy`
|
||||
on every request. No JWT exchange is needed.
|
||||
|
||||
### API tokens
|
||||
|
||||
Database-backed tokens prefixed with `ts_`. Created via the admin CLI
|
||||
(`turnstone-admin create-token`) or the console admin API. Stored as
|
||||
SHA-256 hashes — the raw token is shown exactly once at creation and
|
||||
never persisted in plaintext.
|
||||
|
||||
```
|
||||
$ turnstone-admin create-token --user abc123 --scopes read,write --name "CI bot"
|
||||
Token created: ts_a1b2c3d4e5f6...
|
||||
(save this — it will not be shown again)
|
||||
```
|
||||
|
||||
API tokens can be used directly as `Bearer ts_xxx` headers or exchanged
|
||||
for a JWT via the login endpoint.
|
||||
|
||||
### JWTs
|
||||
|
||||
Short-lived session tokens (24 hours by default). Issued after
|
||||
authenticating with username/password or by exchanging an API token.
|
||||
HS256-signed with a shared secret. Validated locally on every service
|
||||
node — no database call per request.
|
||||
|
||||
Claims:
|
||||
|
||||
| Claim | Description |
|
||||
|-------|-------------|
|
||||
| `sub` | User ID |
|
||||
| `scopes` | Comma-separated scope list (`read,write,approve`) |
|
||||
| `src` | Token source (`password`, `api_token`, `config`) |
|
||||
| `iss` | Issuer — always `turnstone` |
|
||||
| `aud` | Audience — `turnstone-server` or `turnstone-console` |
|
||||
| `iat` | Issued-at timestamp |
|
||||
| `exp` | Expiry timestamp |
|
||||
|
||||
The `aud` claim prevents cross-service token reuse — a JWT issued for the
|
||||
console cannot be used to authenticate against a server node, and vice versa.
|
||||
Tokens without an `aud` claim are accepted during the rollout window when
|
||||
`audience` validation is not specified.
|
||||
|
||||
---
|
||||
|
||||
## Scope Model
|
||||
|
||||
Scopes are hierarchical — higher scopes imply all lower ones.
|
||||
|
||||
| Scope | Grants | Implies |
|
||||
|-------|--------|---------|
|
||||
| `read` | View workstreams, saved workstreams, history | — |
|
||||
| `write` | Send messages, create/close workstreams | `read` |
|
||||
| `approve` | Approve tool calls, admin endpoints | `read`, `write` |
|
||||
|
||||
### Path-to-scope mapping
|
||||
|
||||
| Method | Path pattern | Required scope |
|
||||
|--------|-------------|----------------|
|
||||
| GET | Any protected path | `read` |
|
||||
| POST | `/api/send`, `/api/plan`, `/api/command` | `write` |
|
||||
| POST | `/api/workstreams/new`, `/api/workstreams/close` | `write` |
|
||||
| POST | `/api/cluster/workstreams/new` | `write` |
|
||||
| POST | `/api/approve` | `approve` |
|
||||
| Any | `/api/admin/*` | `approve` |
|
||||
|
||||
Public paths bypass authentication entirely: `/`, `/health`, `/metrics`,
|
||||
`/static/*`, `/shared/*`, `/docs`, `/openapi.json`, `/api/auth/login`,
|
||||
`/api/auth/logout`, `/api/auth/status`, `/api/auth/setup`.
|
||||
|
||||
### RBAC (Granular Permissions)
|
||||
|
||||
> See also: [Governance documentation](governance.md)
|
||||
|
||||
Scopes provide coarse endpoint-level access control. For finer-grained
|
||||
enforcement, the governance layer adds 15 named permissions checked
|
||||
per-endpoint by `require_permission()`. Permissions are bundled into
|
||||
roles; users are assigned roles via the `user_roles` join table.
|
||||
|
||||
At login, `_load_user_permissions()` aggregates all permissions from
|
||||
the user's assigned roles. `_permissions_to_scopes()` derives legacy
|
||||
scopes for backward compatibility (e.g., any `admin.*` permission
|
||||
implies the `approve` scope). The JWT carries both `scopes` and
|
||||
`permissions` claims.
|
||||
|
||||
Three built-in roles are seeded by migration 008:
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| admin | All 15 permissions |
|
||||
| operator | read, write, workstreams.create, workstreams.close |
|
||||
| viewer | read |
|
||||
|
||||
Custom roles can be created with any subset of the valid permissions.
|
||||
Role creation and update validate permissions against a static allowlist.
|
||||
Self-assignment is blocked, and assigning a role requires the caller to
|
||||
hold a superset of the target role's permissions.
|
||||
|
||||
---
|
||||
|
||||
## Login Flows
|
||||
|
||||
### Username and password
|
||||
|
||||
```
|
||||
POST /v1/api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{"username": "admin", "password": "s3cret"}
|
||||
```
|
||||
|
||||
Returns a JWT in the response body and sets an `HttpOnly` session cookie.
|
||||
|
||||
### API token exchange
|
||||
|
||||
```
|
||||
POST /v1/api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{"token": "ts_a1b2c3d4e5f6..."}
|
||||
```
|
||||
|
||||
The API token is hashed, looked up in the database, and exchanged for a
|
||||
JWT with the token's scopes. This is the recommended flow for SDKs and
|
||||
automated clients that need cookie-based sessions.
|
||||
|
||||
### Config-file tokens (direct)
|
||||
|
||||
Config tokens are validated per-request via `hmac.compare_digest`. No
|
||||
login exchange is needed — include the token as a `Bearer` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer tok_legacy
|
||||
```
|
||||
|
||||
### First-time setup
|
||||
|
||||
When no users exist in the database:
|
||||
|
||||
1. `GET /v1/api/auth/status` returns `{"setup_required": true}`
|
||||
2. The UI presents a setup wizard
|
||||
3. `POST /v1/api/auth/setup` creates the first admin user and returns a
|
||||
JWT in one atomic step (no auth required — this is a public endpoint)
|
||||
4. The endpoint returns `409 Conflict` if setup has already been completed
|
||||
(i.e. users already exist in the database)
|
||||
5. Subsequent admin requests require `approve` scope
|
||||
|
||||
The `/api/auth/setup` endpoint is available on both the server and
|
||||
console. It validates input before creating the user:
|
||||
|
||||
- **username**: 1-64 ASCII characters
|
||||
- **display_name**: required (non-empty)
|
||||
- **password**: minimum 8 characters
|
||||
|
||||
```
|
||||
POST /v1/api/auth/setup
|
||||
Content-Type: application/json
|
||||
|
||||
{"username": "admin", "display_name": "Admin", "password": "strongpass"}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"user_id": "u_abc123",
|
||||
"username": "admin",
|
||||
"role": "full",
|
||||
"scopes": "approve,read,write",
|
||||
"jwt": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
```
|
||||
|
||||
The response also sets an `HttpOnly` session cookie containing the JWT,
|
||||
so the browser is immediately authenticated after setup completes.
|
||||
|
||||
---
|
||||
|
||||
## Token Detection Order
|
||||
|
||||
The auth middleware inspects the `Authorization: Bearer <token>` header
|
||||
and classifies the token:
|
||||
|
||||
1. **Contains `.`** → JWT → validate HS256 signature and expiry
|
||||
2. **Starts with `ts_`** → API token → SHA-256 hash, database lookup
|
||||
3. **Otherwise** → config-file token → `hmac.compare_digest` against
|
||||
each configured token
|
||||
|
||||
If a session cookie is present and no `Authorization` header is sent,
|
||||
the cookie value is treated as a JWT (step 1).
|
||||
|
||||
---
|
||||
|
||||
## Password Storage
|
||||
|
||||
Passwords are hashed with **bcrypt** using a random salt per password.
|
||||
Plaintext passwords are only accepted over HTTPS in production
|
||||
deployments.
|
||||
|
||||
---
|
||||
|
||||
## Cookie Security
|
||||
|
||||
| Attribute | Value | Purpose |
|
||||
|-----------|-------|---------|
|
||||
| `HttpOnly` | `true` | Prevents JavaScript access |
|
||||
| `SameSite` | `Lax` | CSRF protection |
|
||||
| `Path` | `/` | Available to all routes |
|
||||
| `Max-Age` | 24 hours | Matches JWT expiry |
|
||||
| `Secure` | `true` (default) | Always set unless explicitly disabled for dev |
|
||||
|
||||
---
|
||||
|
||||
## JWT Configuration
|
||||
|
||||
| Setting | Config key | Env var | Default |
|
||||
|---------|-----------|---------|---------|
|
||||
| Signing secret | `[auth] jwt_secret` | `TURNSTONE_JWT_SECRET` | Auto-generated ephemeral (warning logged) |
|
||||
| Expiry | `[auth] jwt_expiry_hours` | — | 24 hours |
|
||||
| Algorithm | — | — | HS256 (not configurable) |
|
||||
| Minimum secret length | — | — | 32 characters (warning if shorter) |
|
||||
|
||||
All service nodes that need to validate JWTs must share the same signing
|
||||
secret. If no secret is configured, an ephemeral key is generated at
|
||||
startup and a warning is logged — JWTs will not survive restarts or work
|
||||
across nodes.
|
||||
|
||||
The bridge and console **require** `TURNSTONE_JWT_SECRET` when no
|
||||
`--auth-token` is provided. They exit with an error if the secret is
|
||||
missing, since ephemeral secrets would silently break inter-service
|
||||
communication.
|
||||
|
||||
---
|
||||
|
||||
## Admin API Endpoints
|
||||
|
||||
All admin endpoints require `approve` scope.
|
||||
|
||||
### Users
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/v1/api/admin/users` | Create user (username, display_name, password) |
|
||||
| GET | `/v1/api/admin/users` | List all users |
|
||||
| DELETE | `/v1/api/admin/users/{user_id}` | Delete user and cascade tokens |
|
||||
|
||||
### API tokens
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/v1/api/admin/users/{user_id}/tokens` | Create API token (returns raw value once) |
|
||||
| GET | `/v1/api/admin/users/{user_id}/tokens` | List tokens (prefix only, no hashes) |
|
||||
| DELETE | `/v1/api/admin/tokens/{token_id}` | Revoke token |
|
||||
|
||||
---
|
||||
|
||||
## CLI Administration
|
||||
|
||||
The `turnstone-admin` command provides offline user and token management:
|
||||
|
||||
```
|
||||
turnstone-admin create-user --username admin --name "Admin" [--password] [--token]
|
||||
turnstone-admin create-token --user <user_id> --scopes read,write --name "CI bot"
|
||||
turnstone-admin list-users
|
||||
turnstone-admin list-tokens
|
||||
turnstone-admin revoke-token <token_id>
|
||||
```
|
||||
|
||||
When `--password` is omitted, the CLI prompts interactively. When
|
||||
`--token` is passed to `create-user`, an API token is created alongside
|
||||
the user and printed to stdout.
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE api_tokens (
|
||||
token_id TEXT PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL, -- SHA-256 of raw token
|
||||
token_prefix TEXT NOT NULL, -- first 8 chars for display
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id),
|
||||
name TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL, -- comma-separated
|
||||
created TEXT NOT NULL,
|
||||
expires TEXT -- nullable, ISO 8601
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX ix_api_tokens_hash ON api_tokens(token_hash);
|
||||
|
||||
CREATE TABLE channel_users (
|
||||
channel_type TEXT NOT NULL,
|
||||
channel_user_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id),
|
||||
created TEXT NOT NULL,
|
||||
PRIMARY KEY (channel_type, channel_user_id)
|
||||
);
|
||||
```
|
||||
|
||||
The `sessions` and `workstreams` tables have a nullable `user_id`
|
||||
column for attribution when auth is enabled.
|
||||
|
||||
---
|
||||
|
||||
## Revocation
|
||||
|
||||
- **API tokens**: Deleting a token via the admin API or CLI prevents new
|
||||
JWTs from being issued with that token. Existing JWTs derived from the
|
||||
token remain valid until they expire (at most 24 hours).
|
||||
- **Config-file tokens**: Remove the token from `config.toml` and
|
||||
restart the service. No JWTs are involved, so revocation is immediate.
|
||||
- **JWTs**: Cannot be individually revoked. Rely on short expiry (24h)
|
||||
and revoke the underlying credential to prevent renewal.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Console (cluster-wide) Server (per-node)
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ User/Token CRUD (DB) │ │ JWT validation only │
|
||||
│ Login: creds → JWT │ │ (shared signing key) │
|
||||
│ Admin API endpoints │ │ Config tokens: hmac │
|
||||
│ Storage: users, │ │ No auth DB needed │
|
||||
│ api_tokens tables │ │ │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
The console owns the credential database and handles all user/token
|
||||
CRUD. Individual server nodes only need the JWT signing secret to
|
||||
validate session tokens. Config-file tokens are validated locally
|
||||
without any database.
|
||||
|
||||
### Proxy auth forwarding
|
||||
|
||||
When the console proxies requests to server nodes (via `/node/{id}/...`
|
||||
routes), it uses a dedicated **service proxy token** with
|
||||
`aud: turnstone-server` and `write` scope. The user's console JWT
|
||||
(which has `aud: turnstone-console`) is **not** forwarded — it would be
|
||||
rejected by the server's audience validation.
|
||||
|
||||
The proxy token is managed by a `ServiceTokenManager` that auto-rotates
|
||||
1-hour JWTs, refreshing at 80% of lifetime. If `--auth-token` is
|
||||
provided, that static token is used instead.
|
||||
|
||||
### Service-to-service authentication
|
||||
|
||||
The bridge and console collector use `ServiceTokenManager` for
|
||||
auto-rotating JWTs when communicating with server nodes:
|
||||
|
||||
| Service | Identity | Scope | Audience | Purpose |
|
||||
|---------|----------|-------|----------|---------|
|
||||
| Bridge | `bridge` | `approve` | `turnstone-server` | Tool approval proxy, message relay |
|
||||
| Console collector | `console-collector` | `read` | `turnstone-server` | Node health polling |
|
||||
| Console proxy | `console-proxy` | `write` | `turnstone-server` | Proxied API calls |
|
||||
| Channel notify | `system` | `write` | `turnstone-channel` | Notification delivery to channel gateway |
|
||||
|
||||
Service tokens use 1-hour expiry with automatic refresh via
|
||||
`ServiceTokenManager`. The bridge injects auth headers per-request via
|
||||
httpx event hooks to ensure rotated tokens are picked up on SSE
|
||||
reconnects.
|
||||
|
||||
Note that the channel gateway uses a distinct JWT audience
|
||||
(`turnstone-channel`) from the server (`turnstone-server`) and console
|
||||
(`turnstone-console`). A server-scoped JWT cannot authenticate to the
|
||||
channel gateway endpoint, and vice versa.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### config.toml
|
||||
|
||||
```toml
|
||||
[auth]
|
||||
enabled = true
|
||||
jwt_secret = "your-secret-key-here"
|
||||
jwt_expiry_hours = 24
|
||||
|
||||
[[auth.tokens]]
|
||||
value = "tok_legacy"
|
||||
role = "full"
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TURNSTONE_AUTH_ENABLED=1` | Enable authentication |
|
||||
| `TURNSTONE_AUTH_TOKEN=tok_xxx` | Register a config-file token with `full` access |
|
||||
| `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (must match across nodes) |
|
||||
| `TURNSTONE_CORS_ORIGINS=` | CORS allowed origins (comma-separated; empty = same-origin only) |
|
||||
|
||||
---
|
||||
|
||||
## Login Rate Limiting
|
||||
|
||||
The `/api/auth/login` endpoint is protected by a dedicated
|
||||
`LoginRateLimiter` (separate from the general API rate limiter).
|
||||
Limits are enforced per-IP and per-username with a sliding window:
|
||||
|
||||
- **5 attempts** per **5-minute window** per key
|
||||
- Failed logins record against both `ip:{client_ip}` and `user:{username}`
|
||||
- Returns `429 Too Many Requests` with `Retry-After` header when exceeded
|
||||
- Successful logins do not consume the budget
|
||||
|
||||
---
|
||||
|
||||
## CORS Policy
|
||||
|
||||
By default, no CORS headers are sent (same-origin only). To allow
|
||||
cross-origin requests, set `TURNSTONE_CORS_ORIGINS`:
|
||||
|
||||
```bash
|
||||
# Allow specific origins
|
||||
TURNSTONE_CORS_ORIGINS=https://app.example.com,https://admin.example.com
|
||||
|
||||
# Allow all origins (development only)
|
||||
TURNSTONE_CORS_ORIGINS=*
|
||||
```
|
||||
|
||||
When the variable is empty or unset, the CORS middleware is not added
|
||||
and browsers enforce same-origin policy.
|
||||
|
||||
---
|
||||
|
||||
## Security Properties
|
||||
|
||||
- **Timing-safe comparison** for config-file tokens via
|
||||
`hmac.compare_digest` — no timing side-channel.
|
||||
- **Hash-based lookup** for API tokens — the database stores only
|
||||
SHA-256 hashes, eliminating timing attacks on token comparison.
|
||||
- **Local JWT validation** — no network call or database query needed
|
||||
per request on server nodes.
|
||||
- **One-time display** of raw API tokens at creation. The plaintext is
|
||||
never stored; `token_hash` never appears in API responses or logs.
|
||||
- **Structured logging audit trail** — `ctx_user_id` is set on every
|
||||
authenticated request and injected into all log events.
|
||||
- **Scope enforcement** at the middleware layer before any handler
|
||||
executes. Path-to-scope mapping is defined statically.
|
||||
- **JWT audience isolation** — server and console JWTs have distinct
|
||||
`aud` claims, preventing cross-service token reuse.
|
||||
- **Login brute-force protection** — per-IP and per-username rate
|
||||
limiting on the login endpoint.
|
||||
- **Secure cookies by default** — `Secure` flag set unconditionally;
|
||||
24-hour max-age matches JWT expiry.
|
||||
- **CORS restriction** — no CORS headers by default (same-origin only).
|
||||
- **Service JWT auto-rotation** — 1-hour expiry with transparent
|
||||
refresh, eliminating long-lived static tokens for inter-service auth.
|
||||
- **Secret strength validation** — warning logged when JWT secret is
|
||||
shorter than 32 characters.
|
||||
@@ -0,0 +1,353 @@
|
||||
# System Settings
|
||||
|
||||
> See also: [Settings Architecture diagram](diagrams/png/24-settings-architecture.png)
|
||||
|
||||
The system settings feature provides database-backed configuration for server
|
||||
nodes. Settings are stored in the `system_settings` table and managed through
|
||||
the admin API or console Settings tab. This replaces `config.toml` for
|
||||
non-bootstrap settings on server entry points, while the CLI continues to read
|
||||
`config.toml` directly.
|
||||
|
||||
## Overview
|
||||
|
||||
Settings follow a typed registry pattern: every storable setting has a
|
||||
`SettingDef` entry in `settings_registry.py` with type, default, description,
|
||||
validation constraints, and a `restart_required` flag. Unknown keys are rejected
|
||||
at the API boundary.
|
||||
|
||||
At runtime, `ConfigStore` loads all settings from storage into an in-memory
|
||||
cache. Reads are lock-free dict lookups on an immutable snapshot. Writes acquire
|
||||
a lock, persist to storage, and swap the cache atomically.
|
||||
|
||||
---
|
||||
|
||||
## Precedence
|
||||
|
||||
Settings resolution differs between entry points:
|
||||
|
||||
| Entry point | Chain |
|
||||
|-------------|-------|
|
||||
| **Server** (`turnstone-server`, `turnstone-bridge`) | CLI flag > ConfigStore > registry default |
|
||||
| **CLI** (`turnstone`) | CLI flag > config.toml > argparse default |
|
||||
|
||||
The server's `apply_config()` ignores config.toml sections that overlap with
|
||||
ConfigStore. A startup warning is logged for each overlapping key, directing
|
||||
users to the admin Settings API.
|
||||
|
||||
---
|
||||
|
||||
## Bootstrap vs ConfigStore
|
||||
|
||||
**Bootstrap settings** are required before storage is available (database
|
||||
connection, Redis, auth secrets, server bind address). These stay in
|
||||
`config.toml` and environment variables.
|
||||
|
||||
| Category | Section | Where |
|
||||
|----------|---------|-------|
|
||||
| API credentials | `[api]` | config.toml / env |
|
||||
| Database | `[database]` | config.toml / env |
|
||||
| Redis | `[redis]` | config.toml / env |
|
||||
| Auth | `[auth]` | config.toml / env |
|
||||
| Bridge identity | `[bridge]` | config.toml / env |
|
||||
| Console bind | `[console]` | config.toml / env |
|
||||
|
||||
**ConfigStore settings** (~40 settings) are loaded from the database after
|
||||
storage initialization:
|
||||
|
||||
| Section | Settings |
|
||||
|---------|----------|
|
||||
| `model` | name, temperature, max_tokens, reasoning_effort, context_window |
|
||||
| `session` | instructions, retention_days, compact_max_tokens, auto_compact_pct |
|
||||
| `tools` | timeout, truncation, agent_max_turns, skip_permissions, search, search_threshold, search_max_results |
|
||||
| `server` | workstream_idle_timeout, max_workstreams |
|
||||
| `mcp` | config_path, refresh_interval |
|
||||
| `ratelimit` | enabled, requests_per_second, burst |
|
||||
| `health` | backend_probe_interval, backend_probe_timeout, circuit_breaker_threshold, circuit_breaker_cooldown |
|
||||
| `judge` | enabled, model, provider, base_url, api_key, confidence_threshold, max_context_ratio, timeout, read_only_tools |
|
||||
| `memory` | relevance_k, fetch_limit, max_content, nudge_cooldown, nudges |
|
||||
|
||||
Settings are addressed by dotted key (e.g. `memory.relevance_k`). Each has a
|
||||
declared type (`int`, `float`, `str`, `bool`), optional `min_value`/`max_value`
|
||||
range, optional `choices` list, and an `is_secret` flag.
|
||||
|
||||
---
|
||||
|
||||
## Storage
|
||||
|
||||
The `system_settings` table (migration 015) stores settings as JSON-encoded
|
||||
values with a composite primary key of `(key, node_id)`:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `key` | text | Dotted setting key (e.g. `model.temperature`) |
|
||||
| `value` | text | JSON-encoded value |
|
||||
| `node_id` | text | Node ID for per-node overrides (empty string = global) |
|
||||
| `is_secret` | int | 1 if the setting contains secrets |
|
||||
| `changed_by` | text | Username of last editor |
|
||||
| `created` | text | ISO timestamp |
|
||||
| `updated` | text | ISO timestamp |
|
||||
|
||||
Per-node overrides layer on top of global settings. When `ConfigStore` loads,
|
||||
it fetches global settings first, then overlays per-node values.
|
||||
|
||||
---
|
||||
|
||||
## Admin API
|
||||
|
||||
Four endpoints on the **console** server, all requiring the `admin.settings`
|
||||
permission.
|
||||
|
||||
### `GET /v1/api/admin/settings`
|
||||
|
||||
List all settings with their effective values, defaults, and metadata.
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": [
|
||||
{
|
||||
"key": "model.temperature",
|
||||
"value": 0.7,
|
||||
"source": "storage",
|
||||
"type": "float",
|
||||
"description": "Sampling temperature",
|
||||
"section": "model",
|
||||
"is_secret": false,
|
||||
"node_id": "",
|
||||
"changed_by": "admin",
|
||||
"updated": "2026-03-14T10:00:00",
|
||||
"restart_required": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/admin/settings/schema`
|
||||
|
||||
Return the full registry catalog (all defined settings with metadata). Useful
|
||||
for building dynamic admin UIs.
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": [
|
||||
{
|
||||
"key": "model.temperature",
|
||||
"type": "float",
|
||||
"default": 0.5,
|
||||
"description": "Sampling temperature",
|
||||
"section": "model",
|
||||
"is_secret": false,
|
||||
"min_value": 0.0,
|
||||
"max_value": 2.0,
|
||||
"choices": null,
|
||||
"restart_required": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `PUT /v1/api/admin/settings/{key}`
|
||||
|
||||
Update a setting. The value is validated against the registry (type coercion,
|
||||
range, choices). Secret settings (`is_secret=true`) cannot be written via the
|
||||
API -- they must be configured via config.toml or environment variables.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `key` | string | Dotted setting key (e.g. `model.temperature`) |
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"value": 0.7,
|
||||
"node_id": ""
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-----------|--------|----------|---------|-------------|
|
||||
| `value` | any | yes | -- | New value (type-coerced against registry) |
|
||||
| `node_id` | string | no | `""` | Node ID for per-node override |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "model.temperature",
|
||||
"value": 0.7,
|
||||
"source": "storage",
|
||||
"type": "float",
|
||||
"description": "Sampling temperature",
|
||||
"section": "model",
|
||||
"is_secret": false,
|
||||
"node_id": "",
|
||||
"changed_by": "admin",
|
||||
"updated": "",
|
||||
"restart_required": false
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Condition |
|
||||
|--------|-----------|
|
||||
| 400 | Unknown key, invalid value, type mismatch, out of range |
|
||||
| 403 | Secret setting (must use config.toml or env) |
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /v1/api/admin/settings/{key}`
|
||||
|
||||
Reset a setting to its registry default by removing it from storage.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `key` | string | Dotted setting key |
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|--------|----------|---------|-------------|
|
||||
| `node_id` | string | no | `""` | Node ID (empty = global) |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{"status": "ok", "key": "model.temperature", "default": 0.5}
|
||||
```
|
||||
|
||||
**Response (not found):** `404`
|
||||
|
||||
```json
|
||||
{"error": "Setting 'model.temperature' has no stored value"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Secret Settings
|
||||
|
||||
Settings with `is_secret=True` (currently only `judge.api_key`) are blocked
|
||||
from the write API with a `403` response. This prevents accidental exposure
|
||||
through the admin UI or audit logs. Secret settings must be configured via
|
||||
`config.toml` or environment variables.
|
||||
|
||||
The list endpoint masks secret values: stored secrets appear as `"***"`
|
||||
rather than their actual value.
|
||||
|
||||
---
|
||||
|
||||
## Hot Reload
|
||||
|
||||
`ConfigStore` caches all settings in memory for fast, lock-free reads. To
|
||||
refresh the cache after external changes (e.g. direct database edits or
|
||||
cluster-wide propagation):
|
||||
|
||||
```
|
||||
POST /v1/api/_internal/config-reload
|
||||
```
|
||||
|
||||
This triggers `ConfigStore.reload()`, which re-reads all settings from storage
|
||||
and atomically swaps the cache. The `version` counter increments on every
|
||||
reload.
|
||||
|
||||
**Behavior after reload:**
|
||||
|
||||
- New workstreams pick up updated values immediately (via `session_factory`)
|
||||
- Existing sessions keep their frozen configuration (settings are captured at
|
||||
workstream creation time, not read on every turn)
|
||||
- Settings marked `restart_required=True` need a server restart to take effect
|
||||
|
||||
---
|
||||
|
||||
## Migration from config.toml
|
||||
|
||||
On startup, `warn_migrated_settings()` scans `config.toml` for keys that are
|
||||
now managed by ConfigStore. Each overlap produces a warning:
|
||||
|
||||
```
|
||||
WARNING config.toml [model] temperature is now managed via Settings API —
|
||||
this value will be ignored. Use the admin Settings tab or
|
||||
PUT /v1/api/admin/settings/model.temperature to configure.
|
||||
```
|
||||
|
||||
To migrate:
|
||||
|
||||
1. Note the values from `config.toml` for sections that overlap with ConfigStore
|
||||
2. Use `PUT /v1/api/admin/settings/{key}` or the console Settings tab to set
|
||||
each value
|
||||
3. Remove the migrated sections from `config.toml`
|
||||
4. Restart the server to verify no warnings
|
||||
|
||||
---
|
||||
|
||||
## SDK
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from turnstone.sdk import TurnstoneConsole
|
||||
|
||||
with TurnstoneConsole("http://localhost:9090", token="tok_xxx") as admin:
|
||||
# List all settings with effective values
|
||||
result = admin.list_settings()
|
||||
for s in result["settings"]:
|
||||
print(f"{s['key']} = {s['value']} (source: {s['source']})")
|
||||
|
||||
# Get the schema catalog
|
||||
schema = admin.get_settings_schema()
|
||||
|
||||
# Update a setting
|
||||
admin.update_setting("model.temperature", value=0.7)
|
||||
|
||||
# Update with per-node override
|
||||
admin.update_setting("model.temperature", value=0.3, node_id="node-2")
|
||||
|
||||
# Reset to default
|
||||
admin.delete_setting("model.temperature")
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
```typescript
|
||||
import { TurnstoneConsole } from "@turnstone/sdk";
|
||||
|
||||
const admin = new TurnstoneConsole({
|
||||
baseUrl: "http://localhost:9090",
|
||||
token: "tok_xxx",
|
||||
});
|
||||
|
||||
// List all settings
|
||||
const result = await admin.listSettings();
|
||||
for (const s of result.settings) {
|
||||
console.log(`${s.key} = ${s.value} (source: ${s.source})`);
|
||||
}
|
||||
|
||||
// Get schema catalog
|
||||
const schema = await admin.getSettingsSchema();
|
||||
|
||||
// Update a setting
|
||||
await admin.updateSetting("model.temperature", { value: 0.7 });
|
||||
|
||||
// Reset to default
|
||||
await admin.deleteSetting("model.temperature");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
See [Settings Architecture diagram](diagrams/png/24-settings-architecture.png)
|
||||
for the full data flow covering server startup, admin API writes, hot reload,
|
||||
and settings precedence.
|
||||
+396
-35
@@ -1,6 +1,6 @@
|
||||
# Tools Reference
|
||||
|
||||
turnstone exposes 14 built-in tools plus any number of external MCP tools to the
|
||||
turnstone exposes 17 built-in tools plus any number of external MCP tools to the
|
||||
LLM via the OpenAI function-calling interface. Built-in tools are defined as JSON
|
||||
files under `turnstone/tools/` and loaded at startup by `turnstone/core/tools.py`.
|
||||
MCP tools are discovered from configured MCP servers at startup by
|
||||
@@ -46,11 +46,12 @@ schema plus turnstone-specific metadata keys:
|
||||
|
||||
| Name | Description |
|
||||
|---------------------|-------------|
|
||||
| `TOOLS` | All 14 tool definitions (sent to the model). |
|
||||
| `TOOLS` | All 17 tool definitions (sent to the model). |
|
||||
| `AGENT_TOOLS` | Tools with `agent: true` -- available to plan sub-agents. Read-only tools. |
|
||||
| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. |
|
||||
| `AGENT_AUTO_TOOLS` | Set of tool names with `auto_approve: true` -- no user confirmation needed. |
|
||||
| `TASK_AUTO_TOOLS` | Same as `AGENT_AUTO_TOOLS` (identical filter). |
|
||||
| `BUILTIN_TOOL_NAMES`| Frozenset of all 17 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
|
||||
| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. |
|
||||
|
||||
---
|
||||
@@ -68,6 +69,9 @@ Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools
|
||||
- Parses the JSON arguments (with fallback for malformed JSON).
|
||||
- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string
|
||||
to the correct parameter.
|
||||
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 17
|
||||
built-in tools plus `tool_search` (synthetic, client-side BM25 fallback) and
|
||||
the generic `_prepare_mcp_tool()` handler for MCP tools.
|
||||
- Validates arguments and builds a preview dict containing:
|
||||
- `call_id`, `func_name`, `header`, `preview` (for display)
|
||||
- `needs_approval` (bool)
|
||||
@@ -110,9 +114,9 @@ Each item's `execute` callable is invoked:
|
||||
- `read_file` -- reads files, no side effects
|
||||
- `search` -- grep-style search, no side effects
|
||||
- `man` -- reads man pages, no side effects
|
||||
- `remember` -- writes to persistent memory database (lightweight, always auto-approved)
|
||||
- `recall` -- reads from persistent memory database
|
||||
- `forget` -- deletes from persistent memory database (lightweight, always auto-approved)
|
||||
- `memory` -- structured persistent memory (save/search/delete/list)
|
||||
- `recall` -- searches conversation history
|
||||
- `notify` -- sends notifications to linked channels (time-sensitive, auto-approved for urgency)
|
||||
|
||||
**Requires user confirmation** (write operations, network access, side effects):
|
||||
- `bash` -- arbitrary command execution
|
||||
@@ -159,9 +163,11 @@ Every tool defines a `primary_key`. The mapping is:
|
||||
| `web_search` | `query` |
|
||||
| `task` | `prompt` |
|
||||
| `plan` | `prompt` |
|
||||
| `remember` | `key` |
|
||||
| `memory` | `name` |
|
||||
| `recall` | `query` |
|
||||
| `forget` | `key` |
|
||||
| `notify` | `message` |
|
||||
| `read_resource` | `uri` |
|
||||
| `use_prompt` | `name` |
|
||||
|
||||
---
|
||||
|
||||
@@ -183,15 +189,17 @@ Execute a bash command and return stdout + stderr.
|
||||
|
||||
### read_file
|
||||
|
||||
Read the contents of a file, returning numbered lines.
|
||||
Read the contents of a file, returning numbered lines for text files or
|
||||
base64-encoded image data for supported image formats.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|---------|----------|-------------|
|
||||
| `path` | string | yes | Absolute or relative file path. |
|
||||
| `offset` | integer | no | Line number to start from (1-based, default: 1). |
|
||||
| `limit` | integer | no | Maximum number of lines to read. Omit for full file. |
|
||||
| `offset` | integer | no | Line number to start from (1-based, default: 1). Text files only. |
|
||||
| `limit` | integer | no | Maximum number of lines to read. Omit for full file. Text files only. |
|
||||
|
||||
- **What it does**: Reads the file and returns content with line numbers. Must be called before `edit_file` on the same path (the session tracks which files have been read).
|
||||
- **What it does**: For text files, reads and returns content with line numbers. For image files (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO), returns image data as multi-part content when the model supports vision, or a text description when it does not. SVG files are read as text. Images larger than 4 MB are rejected. Must be called before `edit_file` on the same path (the session tracks which files have been read).
|
||||
- **Vision support**: Controlled by `ModelCapabilities.supports_vision`. All commercial OpenAI and Anthropic models have vision enabled. Local models (vLLM, llama.cpp, NIM) default to off — enable via `[models.*.capabilities] supports_vision = true` in config.toml.
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
@@ -335,7 +343,7 @@ Plan before implementing -- an autonomous agent explores the codebase and writes
|
||||
|-----------|--------|----------|-------------|
|
||||
| `prompt` | string | yes | What to plan -- the goal, constraints, and scope. |
|
||||
|
||||
- **What it does**: Spawns a planning sub-agent with `AGENT_TOOLS` (read-only tools: `read_file`, `search`, `math`, `man`, `web_fetch`, `web_search`). The agent explores the codebase and writes a structured plan to `.plan-<session_id>.md` (unique per session, so concurrent workstreams never collide). If the `plan` tool has been called before in the same session, the prior plan is passed to the agent as context so it refines rather than restarts. After completion, the user is prompted to review and can accept, reject, or annotate the plan.
|
||||
- **What it does**: Spawns a planning sub-agent with `AGENT_TOOLS` (read-only tools: `read_file`, `search`, `math`, `man`, `web_fetch`, `web_search`). The agent explores the codebase and writes a structured plan to `.plan-<ws_id>.md` (unique per workstream, so concurrent workstreams never collide). If the `plan` tool has been called before in the same session, the prior plan is passed to the agent as context so it refines rather than restarts. After completion, the user is prompted to review and can accept, reject, or annotate the plan.
|
||||
- **Auto-approve**: No -- requires user confirmation, plus post-execution review gate.
|
||||
- **Agent availability**: Not available to sub-agents (top-level only).
|
||||
|
||||
@@ -343,16 +351,22 @@ Plan before implementing -- an autonomous agent explores the codebase and writes
|
||||
|
||||
## Memory
|
||||
|
||||
### remember
|
||||
### memory
|
||||
|
||||
Save a persistent memory that persists across sessions.
|
||||
Structured persistent memory across sessions with typed, scoped entries.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `key` | string | yes | Short identifier (e.g. `user_name`). |
|
||||
| `value` | string | yes | Content to remember. |
|
||||
| Parameter | Type | Required | Description |
|
||||
|---------------|---------|----------|-------------|
|
||||
| `action` | string | yes | `save`, `search`, `delete`, or `list`. |
|
||||
| `name` | string | save/delete | Short snake_case identifier for the memory. |
|
||||
| `content` | string | save | Memory content to store. |
|
||||
| `description` | string | no | Short description for relevance matching (recommended for `save`). |
|
||||
| `type` | string | no | Memory type: `user`, `project`, `feedback`, or `reference`. Default: `project`. |
|
||||
| `scope` | string | no | Memory scope: `global`, `workstream`, or `user`. Default: `global`. |
|
||||
| `query` | string | search | Search query for finding memories. |
|
||||
| `limit` | integer | no | Max results for `search` or `list`. Default: 20. |
|
||||
|
||||
- **What it does**: Stores a key-value pair in the SQLite memory database. Memories persist across sessions and are included in the system prompt on startup.
|
||||
- **What it does**: Manages structured persistent memories in the database. Memories persist across sessions, have a type classification (user preferences, project knowledge, feedback, reference material) and a scope (global across all workstreams, private to a workstream, or following a user). Relevant memories are included in the system prompt on startup.
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: Not available to sub-agents (top-level only).
|
||||
|
||||
@@ -360,30 +374,118 @@ Save a persistent memory that persists across sessions.
|
||||
|
||||
### recall
|
||||
|
||||
Search memories and past conversations.
|
||||
Search conversation history for past messages and tool results.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|---------|----------|-------------|
|
||||
| `query` | string | no | Search term or phrase. Omit to list all memories. |
|
||||
| `limit` | integer | no | Max conversation results to return (default 20). |
|
||||
| `query` | string | yes | Search term or phrase to find in conversation history. |
|
||||
| `limit` | integer | no | Max results to return (default 20). |
|
||||
|
||||
- **What it does**: With no query, lists all saved memories. With a query, searches both the memory store and conversation history using FTS5 full-text search.
|
||||
- **What it does**: Searches conversation history across sessions using FTS5 full-text search. Returns matching messages, tool calls, and tool results with timestamps and workstream context.
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: Not available to sub-agents (top-level only).
|
||||
|
||||
---
|
||||
|
||||
### forget
|
||||
## Notifications
|
||||
|
||||
Remove a persistent memory by key.
|
||||
### notify
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `key` | string | yes | The memory key to remove (e.g. `user_name`). |
|
||||
Send a notification to a user or channel on an external platform.
|
||||
|
||||
- **What it does**: Deletes the memory entry with the given key from the SQLite database.
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: Not available to sub-agents (top-level only).
|
||||
| Parameter | Type | Required | Description |
|
||||
|----------------|--------|----------|-------------|
|
||||
| `message` | string | yes | Notification content (plain text, max 2000 chars). |
|
||||
| `username` | string | no | Turnstone username — sends to all linked channels. |
|
||||
| `channel_type` | string | no | Platform for direct targeting (`discord`). |
|
||||
| `channel_id` | string | no | Platform-specific channel or user ID for direct targeting. |
|
||||
| `title` | string | no | Optional short title (rendered as bold prefix). |
|
||||
|
||||
Provide either `username` for user-based targeting or `channel_type` +
|
||||
`channel_id` for direct targeting. Do not combine both.
|
||||
|
||||
- **What it does**: Sends a notification via the channel gateway's HTTP endpoint (`POST /v1/api/notify`). The server queries the `services` table for healthy channel gateways, authenticates with a service JWT (`aud: turnstone-channel`), and delivers to the first healthy gateway. On failure, retries up to 2 additional times with backoff (1s, 3s). Rate-limited to 5 notifications per turn (counter only increments on success).
|
||||
- **Auto-approve**: Yes — notifications are time-sensitive and auto-approved so the model can alert users urgently.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
> See [Channel Integrations: Notifications](channels.md#notifications)
|
||||
> for the full delivery flow, service registry details, and security
|
||||
> measures.
|
||||
|
||||
---
|
||||
|
||||
### watch
|
||||
|
||||
Set up periodic polling of a shell command within the current workstream.
|
||||
Results are injected back into the conversation as synthetic user messages,
|
||||
triggering the model to respond and act. Use for monitoring CI/CD pipelines,
|
||||
PR reviews, deployments, file changes, etc.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-------------|---------|----------|-------------|
|
||||
| `action` | string | yes | `create`, `list`, or `cancel`. |
|
||||
| `command` | string | create | Shell command to poll periodically. |
|
||||
| `poll_every`| string | no | Poll interval as duration (`30s`, `5m`, `1h`). Default: `5m`. |
|
||||
| `stop_on` | string | no | Python expression for stop condition (see below). Omit for change detection. |
|
||||
| `name` | string | create | Human-readable watch name (e.g. `pr-review`). Used as identifier for cancel. |
|
||||
| `max_polls` | integer | no | Max poll cycles before auto-cancel. Default: 100. |
|
||||
|
||||
**Actions:**
|
||||
|
||||
- `create` — Start a new watch. Requires approval (same as bash — runs shell
|
||||
commands). Persists to the `watches` table; the server-level `WatchRunner`
|
||||
daemon polls every 15 seconds for due watches.
|
||||
- `list` — Show all active watches in this workstream. Auto-approved.
|
||||
- `cancel` — Stop a watch by name or ID prefix. Auto-approved.
|
||||
|
||||
**Stop condition DSL** — The `stop_on` parameter accepts a Python expression
|
||||
evaluated after each poll. Available variables:
|
||||
|
||||
| Variable | Type | Description |
|
||||
|---------------|------------|-------------|
|
||||
| `output` | `str` | stdout (+stderr) of the command. |
|
||||
| `data` | `Any` | `json.loads(output)`, or `None` if not valid JSON. |
|
||||
| `exit_code` | `int` | Process exit code. |
|
||||
| `prev_output` | `str|None` | Previous poll's stdout (`None` on first poll). |
|
||||
| `changed` | `bool` | `True` if output differs from previous poll. |
|
||||
|
||||
Safe builtins: `len`, `str`, `int`, `float`, `bool`, `abs`, `min`, `max`,
|
||||
`any`, `all`, `isinstance`, `sorted`. No `import`, `open`, `exec`, or
|
||||
`eval`. Security model: equivalent to `bash` — the model already has shell
|
||||
access.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
data["state"] == "MERGED"
|
||||
"error" in output
|
||||
exit_code != 0
|
||||
changed and "ready" in output.lower()
|
||||
data.get("mergedAt") is not None
|
||||
```
|
||||
|
||||
**Lifecycle:**
|
||||
|
||||
1. Model calls `watch(action="create", ...)` — persisted to SQLite.
|
||||
2. `WatchRunner` daemon polls for due watches every 15s.
|
||||
3. Each poll runs the command, evaluates the condition.
|
||||
4. When the condition fires (or max polls reached), the result is injected
|
||||
as a synthetic user message and the watch auto-cancels.
|
||||
5. If the workstream was evicted, it is restored before injection.
|
||||
6. Watches survive server restart (overdue watches fire once on recovery).
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Max 5 active watches per workstream.
|
||||
- Poll interval: 10s–24h.
|
||||
- Output truncated at 64 KB.
|
||||
- Max 5 consecutive watch dispatches per worker thread (depth guard).
|
||||
- Duplicate names rejected within the same workstream.
|
||||
|
||||
- **Auto-approve**: `create` requires approval; `list` and `cancel` are auto-approved.
|
||||
- **Agent availability**: Main session only — not available to plan/task sub-agents.
|
||||
|
||||
> See [Watch Architecture](diagrams/png/18-watch-architecture.png) for the
|
||||
> full poll → evaluate → dispatch flow.
|
||||
|
||||
---
|
||||
|
||||
@@ -402,14 +504,90 @@ Remove a persistent memory by key.
|
||||
| `web_search` | Info | No | Yes | Yes | `query` |
|
||||
| `task` | Agent | No | No | No | `prompt` |
|
||||
| `plan` | Agent | No | No | No | `prompt` |
|
||||
| `remember` | Memory | Yes | No | No | `key` |
|
||||
| `memory` | Memory | Yes | No | No | `name` |
|
||||
| `recall` | Memory | Yes | No | No | `query` |
|
||||
| `forget` | Memory | Yes | No | No | `key` |
|
||||
| `notify` | Notify | Yes | Yes | Yes | `message` |
|
||||
| `watch` | Monitor | No (create) | No | No | `command` |
|
||||
| `read_resource`| MCP | No | Yes | Yes | `uri` |
|
||||
| `use_prompt` | MCP | No | Yes | Yes | `name` |
|
||||
| `tool_search`| Search | Yes | No | No | `query` |
|
||||
|
||||
---
|
||||
|
||||
## Dynamic Tool Search
|
||||
|
||||
When many MCP tools are connected, the total tool count can grow large enough to
|
||||
consume significant context window tokens and reduce model accuracy. Dynamic tool
|
||||
search addresses this by deferring tools the model is unlikely to need on the
|
||||
current turn and letting it search for them on demand.
|
||||
|
||||
### Three-tier approach
|
||||
|
||||
Tool search uses the best available mechanism for each provider:
|
||||
|
||||
1. **Anthropic (native)** -- Models that support it receive `defer_loading: true`
|
||||
on deferred tool definitions plus the `tool_search_tool_bm25_20251119` server-side
|
||||
search tool. Anthropic's API handles search and expansion transparently.
|
||||
|
||||
2. **OpenAI GPT-5.4+ (native)** -- Models with hosted tool search receive
|
||||
`defer_loading: true` on deferred definitions. The API handles search internally.
|
||||
|
||||
3. **vLLM / llama.cpp / NIM (client-side BM25)** -- A synthetic `tool_search`
|
||||
function tool is injected into the tool list. When the model calls it,
|
||||
`_exec_tool_search()` runs a pure-Python BM25 index over tool names and
|
||||
descriptions, then expands the matched tools into the visible set.
|
||||
|
||||
### Configuration
|
||||
|
||||
Tool search is configured in `config.toml` under the `[tools]` section:
|
||||
|
||||
```toml
|
||||
[tools]
|
||||
search = "auto" # "auto", "on", or "off"
|
||||
search_threshold = 20 # minimum total tool count to activate
|
||||
search_max_results = 5 # max tools returned per search call
|
||||
```
|
||||
|
||||
CLI flags override the config file:
|
||||
|
||||
- `--tool-search {auto,on,off}` -- force tool search on or off, or let turnstone
|
||||
decide based on threshold (default: `auto`).
|
||||
- `--tool-search-threshold N` -- minimum tool count to activate (default: 20).
|
||||
- `--tool-search-max-results N` -- max results per search (default: 5).
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Threshold check**: At session startup, `ToolSearchManager.should_activate()`
|
||||
counts total tools (built-in + MCP). If the count is below the threshold, tool
|
||||
search stays off and all tools are sent to the model directly.
|
||||
|
||||
2. **Partitioning**: When active, tools are split into two sets:
|
||||
- **Always-on** -- the 17 built-in tools (members of `BUILTIN_TOOL_NAMES`).
|
||||
These are always visible to the model.
|
||||
- **Deferred** -- all MCP tools. These are not sent in the tool list unless
|
||||
the model searches for them.
|
||||
|
||||
3. **Search and expand**: When the model calls `tool_search` (client-side) or the
|
||||
provider's native search returns results, the matched tools are added to the
|
||||
visible set via `expand_visible()`. Once expanded, a tool stays visible for
|
||||
the remainder of the session.
|
||||
|
||||
4. **Multi-turn persistence**: Expanded tools are never removed. This avoids
|
||||
confusing the model when it references a tool it discovered in an earlier turn.
|
||||
|
||||
### Agent exemption
|
||||
|
||||
Plan and task sub-agents do not use tool search. They operate on scoped tool
|
||||
sets (`AGENT_TOOLS` for plan agents, `TASK_AGENT_TOOLS` for task agents) with
|
||||
MCP tools merged in. Tool search is only active for the top-level session,
|
||||
where the model can interactively search for tools it needs.
|
||||
|
||||
---
|
||||
|
||||
## MCP Tools (External)
|
||||
|
||||
> See also: [MCP Architecture diagram](diagrams/png/20-mcp-architecture.png)
|
||||
|
||||
Turnstone supports the [Model Context Protocol](https://modelcontextprotocol.io/)
|
||||
(MCP) for connecting external tool servers — GitHub, databases, filesystems, or any
|
||||
MCP-compatible service.
|
||||
@@ -421,13 +599,17 @@ MCP-compatible service.
|
||||
|
||||
2. **Discovery**: At startup, `MCPClientManager` connects to each configured server
|
||||
(via stdio subprocess or HTTP), performs the MCP `initialize` handshake, and calls
|
||||
`tools/list` to discover available tools.
|
||||
`tools/list` to discover available tools. During the handshake, the manager checks
|
||||
each server's capabilities for `tools.listChanged` support (push notifications).
|
||||
|
||||
3. **Schema conversion**: Each MCP tool's `inputSchema` is converted to OpenAI
|
||||
function-calling format. The tool name is prefixed: `mcp__{server}__{tool}`.
|
||||
|
||||
4. **Merging**: MCP tools are appended after the 14 built-in tools via
|
||||
4. **Merging**: MCP tools are appended after the 17 built-in tools via
|
||||
`merge_mcp_tools()`. Built-in tools appear first, giving them natural LLM priority.
|
||||
When dynamic tool search is active, MCP tools are deferred rather than directly
|
||||
visible -- the model discovers them via search as needed (see
|
||||
[Dynamic Tool Search](#dynamic-tool-search) above).
|
||||
|
||||
5. **Dispatch**: When the LLM calls an MCP tool, `_prepare_mcp_tool()` builds a
|
||||
generic approval preview and `_exec_mcp_tool()` calls `MCPClientManager.call_tool_sync()`,
|
||||
@@ -500,3 +682,182 @@ MCP tools (3):
|
||||
mcp__github__create_issue [MCP: github] Create a GitHub issue
|
||||
mcp__postgres__query [MCP: postgres] Run a SQL query
|
||||
```
|
||||
|
||||
### Dynamic tool refresh
|
||||
|
||||
MCP tool lists stay up-to-date without restart through three mechanisms:
|
||||
|
||||
1. **Push notifications** -- MCP servers that declare `tools.listChanged: true` in
|
||||
their capabilities send `notifications/tools/list_changed` when their tool list
|
||||
changes. `MCPClientManager` registers a `message_handler` on each `ClientSession`
|
||||
that triggers an immediate refresh for that server.
|
||||
|
||||
2. **Periodic timer** -- Servers that do *not* support push notifications are polled
|
||||
on a configurable interval (default 4 hours). The timer is staggered using a
|
||||
launch-time seed (`monotonic_ns ^ pid`) so cluster nodes don't all hit MCP
|
||||
servers simultaneously. Configure via `[mcp] refresh_interval` in `config.toml`
|
||||
or `--mcp-refresh-interval SECONDS` on the CLI. Set to `0` to disable.
|
||||
|
||||
3. **Manual** -- `/mcp refresh` re-fetches tools from all servers immediately.
|
||||
`/mcp refresh <server>` targets a single server. If a server has disconnected,
|
||||
manual refresh attempts reconnection.
|
||||
|
||||
When tools change, `MCPClientManager` rebuilds its merged tool list using copy-on-write
|
||||
(new list/dict objects assigned atomically) and notifies all active `ChatSession`
|
||||
instances via registered listener callbacks. Each session rebuilds its `_tools`,
|
||||
`_task_tools`, `_agent_tools`, and reconstructs its `ToolSearchManager` (if active),
|
||||
preserving the set of previously expanded (discovered) tools.
|
||||
|
||||
```toml
|
||||
[mcp]
|
||||
refresh_interval = 14400 # seconds (default 4h), 0 to disable
|
||||
```
|
||||
|
||||
```
|
||||
/mcp refresh
|
||||
MCP refresh complete:
|
||||
github: +1 added
|
||||
+ mcp__github__create_pr
|
||||
postgres: no changes
|
||||
|
||||
/mcp refresh github
|
||||
MCP refresh complete:
|
||||
github: no changes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MCP Resources
|
||||
|
||||
MCP servers can expose **resources** -- named data items (files, database rows,
|
||||
API responses) addressable by URI. turnstone discovers resources at startup and
|
||||
makes them available to the model via the `read_resource` built-in tool.
|
||||
|
||||
### Discovery
|
||||
|
||||
During the MCP `initialize` handshake, `MCPClientManager` checks each server's
|
||||
capabilities for the `resources` capability. For servers that declare it:
|
||||
|
||||
1. `list_resources` fetches static resources (fixed URIs).
|
||||
2. `list_resource_templates` fetches URI templates (parameterized patterns like
|
||||
`db://tables/{table}/rows/{id}`).
|
||||
|
||||
Both are stored as `{uri, name, description, mimeType, server}` dicts and
|
||||
merged into a unified catalog.
|
||||
|
||||
### Resource catalog in system message
|
||||
|
||||
The first 50 resources are injected into the system message as an XML-delimited
|
||||
block so the model knows what URIs are available:
|
||||
|
||||
```xml
|
||||
<mcp-resources>
|
||||
file:///project/README.md Project readme
|
||||
db://users/schema User table schema
|
||||
</mcp-resources>
|
||||
Use read_resource(uri='...') to access the resources listed above.
|
||||
```
|
||||
|
||||
### read_resource tool
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `uri` | string | yes | The resource URI to read. |
|
||||
|
||||
- **What it does**: Reads the resource from its MCP server via `MCPClientManager.read_resource_sync()`. Returns text content for text resources or base64-encoded data for binary resources. Output is truncated by the standard tool output limiter.
|
||||
- **Auto-approve**: No -- requires user confirmation (reads external data).
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
### Capability guards
|
||||
|
||||
The `read_resource` tool schema is always loaded (it is a built-in JSON schema),
|
||||
but resource discovery only runs for servers that declare the `resources`
|
||||
capability. Servers without the capability contribute zero resources to the
|
||||
catalog.
|
||||
|
||||
### Refresh
|
||||
|
||||
Resource lists stay current through the same three-tier mechanism as tool lists:
|
||||
|
||||
1. **Push** -- Servers declaring `resources.listChanged: true` send
|
||||
`notifications/resources/list_changed`, triggering an immediate refresh.
|
||||
2. **Periodic** -- Servers without push are polled on the configured refresh
|
||||
interval (default 4 hours, same timer as tools).
|
||||
3. **Manual** -- `/mcp refresh` re-fetches resources alongside tools.
|
||||
|
||||
---
|
||||
|
||||
## MCP Prompts
|
||||
|
||||
MCP servers can also expose **prompts** -- reusable message templates with
|
||||
optional arguments. turnstone discovers prompts at startup for servers that
|
||||
declare the `prompts` capability.
|
||||
|
||||
### Discovery
|
||||
|
||||
Prompt discovery mirrors resource discovery: `list_prompts` is called during
|
||||
the `initialize` handshake. Each prompt is stored with its prefixed name
|
||||
(`mcp__{server}__{prompt}`), description, and argument schema.
|
||||
|
||||
### use_prompt tool
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-------------|--------|----------|-------------|
|
||||
| `name` | string | yes | The prompt name (e.g. `mcp__server__prompt_name`). |
|
||||
| `arguments` | object | no | Key-value argument pairs for the prompt. Values must be strings. |
|
||||
|
||||
- **What it does**: Invokes an MCP prompt template by name via `MCPClientManager.get_prompt_sync()`, expanding it into messages. Returns the expanded prompt content formatted as `[role]: content` blocks joined with blank lines. The prompt catalog is listed in the system message so the model knows which prompts are available. Output is truncated by the standard tool output limiter.
|
||||
- **Auto-approve**: No -- requires user confirmation (invokes external prompt servers).
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
### Invocation
|
||||
|
||||
`MCPClientManager.get_prompt_sync()` calls the server's `get_prompt` method
|
||||
with the provided arguments and returns the expanded messages. The `use_prompt`
|
||||
built-in tool exposes this to the model as a function call.
|
||||
|
||||
### Governance Sync
|
||||
|
||||
Discovered MCP prompts are automatically synced into the `prompt_templates`
|
||||
governance table as first-class governed templates:
|
||||
|
||||
- **Origin tracking**: MCP-sourced templates have `origin="mcp"` and
|
||||
`mcp_server` set to the server name. Manual templates have
|
||||
`origin="manual"`.
|
||||
- **Read-only**: MCP-sourced templates are `readonly=True`. The admin API
|
||||
returns 403 on update/delete attempts. The admin UI disables edit/delete
|
||||
buttons and shows an origin badge.
|
||||
- **Precedence**: If a manual template and MCP prompt share the same name,
|
||||
the manual template wins and the MCP prompt is skipped (with a log
|
||||
warning).
|
||||
- **Lifecycle**: Templates are created on connect, updated on prompt list
|
||||
refresh, and removed when the MCP server no longer exposes the prompt.
|
||||
The sync runs automatically on connect, on `PromptListChangedNotification`,
|
||||
and on manual `/mcp refresh`.
|
||||
- **Schema**: Migration 009 adds `origin`, `mcp_server`, and `readonly`
|
||||
columns to the `prompt_templates` table.
|
||||
|
||||
The `use_prompt` tool allows the model to invoke any discovered MCP prompt at
|
||||
runtime. A catalog of up to 30 prompts is injected into the system message
|
||||
inside `<mcp-prompts>` XML tags so the model can discover available prompts.
|
||||
|
||||
---
|
||||
|
||||
## MCP UI Visibility
|
||||
|
||||
MCP server, resource, and prompt counts are surfaced across the UI:
|
||||
|
||||
- **Server `/health` endpoint**: Returns `mcp.servers`, `mcp.resources`,
|
||||
`mcp.prompts` when MCP is configured
|
||||
- **Server UI**: Magenta status badge in the header showing server count,
|
||||
with resource/prompt counts in tooltip
|
||||
- **Console cluster status bar**: MCP metrics (servers/resources/prompts)
|
||||
with magenta LED dot indicator, shown after a divider from workstream
|
||||
metrics
|
||||
- **Console node detail**: Per-node MCP summary showing server, resource,
|
||||
and prompt counts
|
||||
- **Console collector**: Aggregates MCP counts across all nodes in the
|
||||
cluster overview
|
||||
|
||||
MCP indicators use the `--magenta` design token for consistent theming
|
||||
across light and dark modes.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# MCP Cluster Ops
|
||||
|
||||
An MCP server that exposes tools for executing commands across a [Turnstone](https://github.com/turnstonelabs/turnstone) cluster. Serves as a reference implementation for both MCP server patterns and Turnstone MQ client SDK usage.
|
||||
|
||||
## How it works
|
||||
|
||||
This server uses Turnstone's MQ client (`TurnstoneClient`) to dispatch shell commands to specific nodes via Redis. Remote agents execute the command and the raw bash output is captured directly from the `ToolResultEvent` stream — bypassing the costly "agent reads output → re-generates output as completion tokens" round-trip.
|
||||
|
||||
Multi-node dispatches run in parallel via `asyncio.gather`, so total wall time is bounded by the slowest node rather than the sum.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_nodes` | Discover active nodes in the cluster |
|
||||
| `run_on_node` | Execute a command on a specific node |
|
||||
| `run_on_nodes` | Execute a command on selected nodes in parallel |
|
||||
| `run_on_all_nodes` | Execute a command on ALL active nodes in parallel |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running Turnstone cluster (at least one `turnstone-server` + `turnstone-bridge`)
|
||||
- Redis accessible from wherever this MCP server runs
|
||||
- Python 3.11+
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# From the turnstone repo root:
|
||||
pip install -e ./examples/mcp-cluster-ops
|
||||
|
||||
# Or install turnstone with MQ support first, then the example:
|
||||
pip install -e ".[mq]"
|
||||
pip install -e ./examples/mcp-cluster-ops
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `REDIS_HOST` | `localhost` | Redis host |
|
||||
| `REDIS_PORT` | `6379` | Redis port |
|
||||
| `REDIS_PASSWORD` | _(none)_ | Redis password (use env vars, not config files) |
|
||||
| `MCP_CLUSTER_OPS_TIMEOUT` | `120` | Default command timeout (seconds, clamped 5-3600) |
|
||||
| `MCP_CLUSTER_OPS_MAX_OUTPUT` | `8192` | Max output bytes per node (0 = unlimited) |
|
||||
| `MCP_CLUSTER_OPS_MAX_NODES` | `32` | Max concurrent node dispatches |
|
||||
| `MCP_CLUSTER_OPS_MAX_COMMAND` | `65536` | Max command string length |
|
||||
|
||||
### Register with Turnstone
|
||||
|
||||
**TOML** (`~/.config/turnstone/config.toml`):
|
||||
|
||||
```toml
|
||||
[mcp.servers.cluster-ops]
|
||||
command = "mcp-cluster-ops"
|
||||
|
||||
[mcp.servers.cluster-ops.env]
|
||||
REDIS_HOST = "redis.example.com"
|
||||
```
|
||||
|
||||
**JSON** (via `--mcp-config`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"cluster-ops": {
|
||||
"command": "mcp-cluster-ops",
|
||||
"env": {
|
||||
"REDIS_HOST": "redis.example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
Once registered, the tools appear in any Turnstone session. The model can:
|
||||
|
||||
```
|
||||
> Check disk usage across the cluster
|
||||
|
||||
[calls list_nodes → discovers node-1, node-2, node-3]
|
||||
[calls run_on_all_nodes with "df -h /"]
|
||||
|
||||
node-1: /dev/sda1 500G 320G 180G 64% /
|
||||
node-2: /dev/sda1 500G 410G 90G 82% /
|
||||
node-3: /dev/sda1 1.0T 200G 800G 20% /
|
||||
```
|
||||
|
||||
## Why MQ client instead of HTTP SDK?
|
||||
|
||||
The HTTP SDK (`TurnstoneServer`) talks to a single server instance. The MQ client (`TurnstoneClient`) routes through Redis with `target_node` support, which is the entire point of cross-node cluster operations.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**This MCP server grants the calling agent shell access to cluster nodes.**
|
||||
|
||||
- Commands are executed with `auto_approve=True` and the privileges of the
|
||||
Turnstone server process on the target node.
|
||||
- Command output (which may contain secrets, credentials, or sensitive data)
|
||||
is returned through the MCP tool result and becomes part of the LLM context.
|
||||
- The security boundary is at the MCP host layer -- use Turnstone's tool
|
||||
policy system to restrict which agents can invoke these tools.
|
||||
- Set `REDIS_PASSWORD` via your environment or a secrets manager -- avoid
|
||||
hardcoding passwords in config files.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd examples/mcp-cluster-ops
|
||||
|
||||
# Run tests
|
||||
pip install -e ".[test]"
|
||||
pytest
|
||||
|
||||
# Lint
|
||||
pip install -e ".[dev]"
|
||||
ruff check mcp_cluster_ops/
|
||||
mypy --strict mcp_cluster_ops/
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
"""MCP server for Turnstone cluster operations."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from mcp_cluster_ops.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,404 @@
|
||||
"""MCP server for Turnstone cluster operations.
|
||||
|
||||
Exposes tools to execute commands on specific nodes in a Turnstone cluster.
|
||||
Uses the MQ client (``TurnstoneClient``) for direct node targeting via Redis.
|
||||
|
||||
Usage::
|
||||
|
||||
mcp-cluster-ops # via entry point
|
||||
python -m mcp_cluster_ops # via module
|
||||
|
||||
Configure in ``~/.config/turnstone/config.toml``::
|
||||
|
||||
[mcp.servers.cluster-ops]
|
||||
command = "mcp-cluster-ops"
|
||||
|
||||
[mcp.servers.cluster-ops.env]
|
||||
REDIS_HOST = "redis.example.com"
|
||||
|
||||
Environment variables
|
||||
---------------------
|
||||
REDIS_HOST Redis host (default: localhost)
|
||||
REDIS_PORT Redis port (default: 6379)
|
||||
REDIS_PASSWORD Redis password (default: none)
|
||||
MCP_CLUSTER_OPS_TIMEOUT Default command timeout in seconds (default: 120)
|
||||
MCP_CLUSTER_OPS_MAX_OUTPUT Max output bytes per node (default: 8192, 0=unlimited)
|
||||
|
||||
Performance notes
|
||||
-----------------
|
||||
Remote agents are told to reply with only "ok" or "failed" — the raw bash
|
||||
output is captured directly from the ToolResultEvent that already flows
|
||||
through Redis, bypassing the costly "agent reads output then re-generates
|
||||
output as completion tokens" round-trip.
|
||||
|
||||
All multi-node dispatches run in parallel via ``asyncio.gather`` so total
|
||||
wall time is bounded by the slowest node, not the sum of all nodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from turnstone.mq.client import TurnResult, TurnstoneClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_TIMEOUT = int(os.environ.get("MCP_CLUSTER_OPS_TIMEOUT", "120"))
|
||||
_DEFAULT_MAX_OUTPUT = int(os.environ.get("MCP_CLUSTER_OPS_MAX_OUTPUT", "8192"))
|
||||
_MAX_CONCURRENT_NODES = int(os.environ.get("MCP_CLUSTER_OPS_MAX_NODES", "32"))
|
||||
_MAX_COMMAND_LEN = int(os.environ.get("MCP_CLUSTER_OPS_MAX_COMMAND", "65536"))
|
||||
_MIN_TIMEOUT = 5
|
||||
_MAX_TIMEOUT = 3600
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers (pure functions, easily testable)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _redis_kwargs() -> dict[str, Any]:
|
||||
"""Build Redis connection kwargs from environment variables.
|
||||
|
||||
Follows the same env var convention as ``turnstone.mq.broker.add_redis_args``:
|
||||
``REDIS_HOST``, ``REDIS_PORT``, ``REDIS_PASSWORD``.
|
||||
"""
|
||||
kwargs: dict[str, Any] = {"host": os.environ.get("REDIS_HOST", "localhost")}
|
||||
port = os.environ.get("REDIS_PORT")
|
||||
if port is not None:
|
||||
kwargs["port"] = int(port)
|
||||
password = os.environ.get("REDIS_PASSWORD")
|
||||
if password:
|
||||
kwargs["password"] = password
|
||||
return kwargs
|
||||
|
||||
|
||||
def _exec_prompt(command: str) -> str:
|
||||
"""Build the prompt sent to the remote agent.
|
||||
|
||||
Instructs it to run the command and reply minimally so that the raw
|
||||
bash output (captured via ToolResultEvent) is the primary result,
|
||||
avoiding token waste from re-transcription.
|
||||
"""
|
||||
return (
|
||||
"Execute this shell command using the bash tool:\n"
|
||||
f" {command}\n\n"
|
||||
"After the tool completes, reply with only 'ok' or 'failed'.\n"
|
||||
"Do NOT repeat, quote, or summarise the command output in your reply."
|
||||
)
|
||||
|
||||
|
||||
def _extract_output(result: TurnResult) -> str:
|
||||
"""Extract useful output from a TurnResult.
|
||||
|
||||
Prefers raw bash ToolResultEvent output (zero LLM re-transcription cost)
|
||||
over agent content. Falls back through tool results and content.
|
||||
"""
|
||||
bash_outputs = [out for name, out in result.tool_results if name == "bash"]
|
||||
if bash_outputs:
|
||||
return "\n".join(bash_outputs)
|
||||
content: str = result.content
|
||||
if content:
|
||||
return content
|
||||
if result.tool_results:
|
||||
return str(result.tool_results[0][1])
|
||||
return ""
|
||||
|
||||
|
||||
def _truncate(text: str, max_bytes: int) -> str:
|
||||
"""Truncate *text* to at most *max_bytes* UTF-8 bytes.
|
||||
|
||||
Appends a marker when truncation occurs. Handles multi-byte characters
|
||||
safely by decoding with ``errors='ignore'``.
|
||||
|
||||
Pass ``max_bytes=0`` to disable truncation.
|
||||
"""
|
||||
if max_bytes <= 0:
|
||||
return text
|
||||
encoded = text.encode("utf-8")
|
||||
if len(encoded) <= max_bytes:
|
||||
return text
|
||||
truncated = encoded[:max_bytes].decode("utf-8", errors="ignore")
|
||||
omitted = len(encoded) - len(truncated.encode("utf-8"))
|
||||
return truncated + f"\n... [truncated: {omitted} bytes omitted]"
|
||||
|
||||
|
||||
def _clamp_timeout(timeout: int) -> float:
|
||||
"""Clamp timeout to a safe range."""
|
||||
return float(max(_MIN_TIMEOUT, min(timeout, _MAX_TIMEOUT)))
|
||||
|
||||
|
||||
def _validate_command(command: str) -> str | None:
|
||||
"""Validate a command string. Returns an error message or None."""
|
||||
if not command.strip():
|
||||
return "command must be a non-empty string"
|
||||
if len(command) > _MAX_COMMAND_LEN:
|
||||
return f"command too long ({len(command)} chars, max {_MAX_COMMAND_LEN})"
|
||||
return None
|
||||
|
||||
|
||||
def _format_node_result(
|
||||
node_id: str,
|
||||
result: TurnResult,
|
||||
max_output: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Format a single node's TurnResult for JSON output."""
|
||||
raw = _extract_output(result)
|
||||
output = _truncate(raw, max_output)
|
||||
entry: dict[str, Any] = {
|
||||
"node": node_id,
|
||||
"ok": result.ok,
|
||||
}
|
||||
if result.timed_out:
|
||||
entry["timed_out"] = True
|
||||
if result.ok:
|
||||
entry["output"] = output
|
||||
else:
|
||||
entry["output"] = output or None
|
||||
if result.errors:
|
||||
entry["error"] = "; ".join(result.errors)
|
||||
return entry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core dispatch functions (testable with mocked TurnstoneClient)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _exec_on_node_sync(
|
||||
redis_kw: dict[str, Any],
|
||||
node_id: str,
|
||||
command: str,
|
||||
timeout: float,
|
||||
) -> tuple[str, TurnResult]:
|
||||
"""Dispatch *command* to *node_id* and block until complete.
|
||||
|
||||
Runs inside ``asyncio.to_thread`` so it does not block the event loop.
|
||||
Each call creates its own ``TurnstoneClient`` to avoid Redis pub/sub
|
||||
subscription conflicts between concurrent dispatches.
|
||||
"""
|
||||
prompt = _exec_prompt(command)
|
||||
with TurnstoneClient(**redis_kw) as client:
|
||||
result = client.send_and_wait(
|
||||
message=prompt,
|
||||
target_node=node_id,
|
||||
auto_approve=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return node_id, result
|
||||
|
||||
|
||||
async def _dispatch_parallel(
|
||||
redis_kw: dict[str, Any],
|
||||
node_ids: list[str],
|
||||
command: str,
|
||||
timeout: float,
|
||||
max_output: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Dispatch *command* to all *node_ids* concurrently.
|
||||
|
||||
Total wall time is bounded by the slowest node.
|
||||
"""
|
||||
tasks = [
|
||||
asyncio.to_thread(_exec_on_node_sync, redis_kw, nid, command, timeout) for nid in node_ids
|
||||
]
|
||||
outcomes = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for nid, outcome in zip(node_ids, outcomes, strict=True):
|
||||
if isinstance(outcome, BaseException):
|
||||
if not isinstance(outcome, Exception):
|
||||
raise outcome # propagate KeyboardInterrupt, SystemExit, etc.
|
||||
results.append({"node": nid, "ok": False, "error": str(outcome)})
|
||||
else:
|
||||
_, turn_result = outcome
|
||||
results.append(_format_node_result(nid, turn_result, max_output))
|
||||
return results
|
||||
|
||||
|
||||
def _list_nodes_sync(redis_kw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""List active cluster nodes (blocking)."""
|
||||
with TurnstoneClient(**redis_kw) as client:
|
||||
nodes: list[dict[str, Any]] = client.list_nodes()
|
||||
return nodes
|
||||
|
||||
|
||||
async def _list_nodes_impl(redis_kw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""List active cluster nodes."""
|
||||
return await asyncio.to_thread(_list_nodes_sync, redis_kw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP server
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(server: FastMCP[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Lifespan context — stores Redis kwargs for tool handlers."""
|
||||
kw = _redis_kwargs()
|
||||
yield {"redis_kwargs": kw}
|
||||
|
||||
|
||||
mcp = FastMCP(
|
||||
"turnstone-cluster-ops",
|
||||
instructions=(
|
||||
"Tools for executing commands across a Turnstone AI cluster. "
|
||||
"Use list_nodes first to discover available nodes, then run_on_node "
|
||||
"to execute commands on specific nodes or run_on_all_nodes for "
|
||||
"cluster-wide operations."
|
||||
),
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_nodes(ctx: Context[Any, Any, Any]) -> str:
|
||||
"""List all active nodes in the Turnstone cluster.
|
||||
|
||||
Call this before dispatching work to discover available node IDs.
|
||||
Returns a JSON array of node metadata objects.
|
||||
"""
|
||||
redis_kw: dict[str, Any] = ctx.request_context.lifespan_context["redis_kwargs"]
|
||||
nodes = await _list_nodes_impl(redis_kw)
|
||||
return json.dumps(nodes, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def run_on_node(
|
||||
node_id: str,
|
||||
command: str,
|
||||
ctx: Context[Any, Any, Any],
|
||||
timeout: int = _DEFAULT_TIMEOUT,
|
||||
) -> str:
|
||||
"""Execute a shell command on a specific node and return the raw output.
|
||||
|
||||
Use list_nodes first to discover available node IDs.
|
||||
|
||||
Args:
|
||||
node_id: Target node ID (e.g. 'worker-1.example.com').
|
||||
command: Shell command to execute on the target node.
|
||||
timeout: Timeout in seconds (default: 120).
|
||||
"""
|
||||
node_id = node_id.strip()
|
||||
if not node_id:
|
||||
return json.dumps({"error": "node_id must be a non-empty string"})
|
||||
cmd_err = _validate_command(command)
|
||||
if cmd_err:
|
||||
return json.dumps({"error": cmd_err})
|
||||
|
||||
redis_kw: dict[str, Any] = ctx.request_context.lifespan_context["redis_kwargs"]
|
||||
max_output = _DEFAULT_MAX_OUTPUT
|
||||
|
||||
log.info("run_on_node node=%s cmd=%r", node_id, command)
|
||||
_, result = await asyncio.to_thread(
|
||||
_exec_on_node_sync, redis_kw, node_id, command, _clamp_timeout(timeout)
|
||||
)
|
||||
formatted = _format_node_result(node_id, result, max_output)
|
||||
return json.dumps(formatted, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def run_on_nodes(
|
||||
node_ids: list[str],
|
||||
command: str,
|
||||
ctx: Context[Any, Any, Any],
|
||||
timeout: int = _DEFAULT_TIMEOUT,
|
||||
) -> str:
|
||||
"""Execute a shell command on specific nodes in parallel.
|
||||
|
||||
Results are collected from each node. Total wall time is bounded by
|
||||
the slowest node rather than the sum.
|
||||
|
||||
Args:
|
||||
node_ids: List of node IDs to target.
|
||||
command: Shell command to execute.
|
||||
timeout: Timeout per node in seconds (default: 120).
|
||||
"""
|
||||
cmd_err = _validate_command(command)
|
||||
if cmd_err:
|
||||
return json.dumps({"error": cmd_err})
|
||||
|
||||
redis_kw: dict[str, Any] = ctx.request_context.lifespan_context["redis_kwargs"]
|
||||
max_output = _DEFAULT_MAX_OUTPUT
|
||||
|
||||
clean_ids = list(dict.fromkeys(nid.strip() for nid in node_ids if nid.strip()))
|
||||
if not clean_ids:
|
||||
return json.dumps({"error": "node_ids must be a non-empty list"})
|
||||
if len(clean_ids) > _MAX_CONCURRENT_NODES:
|
||||
return json.dumps(
|
||||
{"error": f"Too many nodes ({len(clean_ids)}), max is {_MAX_CONCURRENT_NODES}"}
|
||||
)
|
||||
|
||||
log.info("run_on_nodes nodes=%s cmd=%r", clean_ids, command)
|
||||
results = await _dispatch_parallel(
|
||||
redis_kw, clean_ids, command, _clamp_timeout(timeout), max_output
|
||||
)
|
||||
return json.dumps(results, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def run_on_all_nodes(
|
||||
command: str,
|
||||
ctx: Context[Any, Any, Any],
|
||||
timeout: int = _DEFAULT_TIMEOUT,
|
||||
) -> str:
|
||||
"""Execute a shell command on ALL active nodes in parallel.
|
||||
|
||||
Discovers nodes automatically, then dispatches in parallel. Useful for
|
||||
cluster-wide operations like checking disk usage, GPU status, or
|
||||
running processes.
|
||||
|
||||
Args:
|
||||
command: Shell command to execute on every node.
|
||||
timeout: Timeout per node in seconds (default: 120).
|
||||
"""
|
||||
cmd_err = _validate_command(command)
|
||||
if cmd_err:
|
||||
return json.dumps({"error": cmd_err})
|
||||
|
||||
redis_kw: dict[str, Any] = ctx.request_context.lifespan_context["redis_kwargs"]
|
||||
max_output = _DEFAULT_MAX_OUTPUT
|
||||
|
||||
nodes = await _list_nodes_impl(redis_kw)
|
||||
if not nodes:
|
||||
return json.dumps({"error": "No active nodes found in cluster"})
|
||||
|
||||
node_ids = list(
|
||||
dict.fromkeys(
|
||||
nid.strip() for n in nodes if (nid := n.get("node_id") or n.get("id")) and nid.strip()
|
||||
)
|
||||
)
|
||||
if not node_ids:
|
||||
return json.dumps({"error": "No nodes with identifiable IDs found"})
|
||||
if len(node_ids) > _MAX_CONCURRENT_NODES:
|
||||
return json.dumps(
|
||||
{"error": f"Too many nodes ({len(node_ids)}), max is {_MAX_CONCURRENT_NODES}"}
|
||||
)
|
||||
log.info("run_on_all_nodes nodes=%s cmd=%r", node_ids, command)
|
||||
results = await _dispatch_parallel(
|
||||
redis_kw, node_ids, command, _clamp_timeout(timeout), max_output
|
||||
)
|
||||
return json.dumps(results, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the MCP cluster-ops server via stdio transport."""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
mcp.run(transport="stdio")
|
||||
@@ -0,0 +1,57 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.29"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mcp-cluster-ops"
|
||||
version = "0.1.0"
|
||||
description = "MCP server for Turnstone cluster operations — reference implementation."
|
||||
requires-python = ">=3.11"
|
||||
license = "BUSL-1.1"
|
||||
dependencies = [
|
||||
"turnstone[mq]",
|
||||
"mcp>=1.6",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mcp-cluster-ops = "mcp_cluster_ops.server:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest>=9.0"]
|
||||
dev = ["ruff>=0.9", "mypy>=1.14", "types-redis>=4.6"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM", "TCH"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["mcp", "mcp.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["turnstone", "turnstone.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tests.*"
|
||||
disallow_untyped_defs = false
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Tests for pure helper functions in mcp_cluster_ops.server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from turnstone.mq.client import TurnResult
|
||||
|
||||
from mcp_cluster_ops.server import (
|
||||
_clamp_timeout,
|
||||
_exec_prompt,
|
||||
_extract_output,
|
||||
_format_node_result,
|
||||
_truncate,
|
||||
_validate_command,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _truncate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTruncate:
|
||||
def test_empty_string(self):
|
||||
assert _truncate("", 100) == ""
|
||||
|
||||
def test_under_limit(self):
|
||||
assert _truncate("hello", 100) == "hello"
|
||||
|
||||
def test_at_limit(self):
|
||||
text = "x" * 50
|
||||
assert _truncate(text, 50) == text
|
||||
|
||||
def test_over_limit(self):
|
||||
text = "x" * 200
|
||||
result = _truncate(text, 50)
|
||||
assert result.startswith("x" * 50)
|
||||
assert "truncated" in result
|
||||
assert "150 bytes omitted" in result
|
||||
|
||||
def test_unicode_boundary(self):
|
||||
# U+00E9 (é) is 2 bytes in UTF-8 (0xC3 0xA9), so 5 chars = 10 bytes
|
||||
text = "\u00e9\u00e9\u00e9\u00e9\u00e9"
|
||||
result = _truncate(text, 5)
|
||||
# Should not crash, should truncate cleanly
|
||||
assert "truncated" in result
|
||||
|
||||
def test_zero_disables(self):
|
||||
text = "x" * 10000
|
||||
assert _truncate(text, 0) == text
|
||||
|
||||
def test_custom_max(self):
|
||||
text = "abcdefghij" # 10 bytes
|
||||
result = _truncate(text, 5)
|
||||
assert result.startswith("abcde")
|
||||
assert "truncated" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractOutput:
|
||||
def test_bash_result_preferred(self):
|
||||
r = TurnResult(
|
||||
content_parts=["agent said something"],
|
||||
tool_results=[("bash", "raw output")],
|
||||
)
|
||||
assert _extract_output(r) == "raw output"
|
||||
|
||||
def test_multiple_bash_results_joined(self):
|
||||
r = TurnResult(
|
||||
tool_results=[("bash", "line1"), ("bash", "line2")],
|
||||
)
|
||||
assert _extract_output(r) == "line1\nline2"
|
||||
|
||||
def test_content_fallback(self):
|
||||
r = TurnResult(
|
||||
content_parts=["agent response"],
|
||||
tool_results=[("read_file", "file contents")],
|
||||
)
|
||||
assert _extract_output(r) == "agent response"
|
||||
|
||||
def test_any_tool_fallback(self):
|
||||
r = TurnResult(
|
||||
tool_results=[("read_file", "file contents")],
|
||||
)
|
||||
assert _extract_output(r) == "file contents"
|
||||
|
||||
def test_empty_result(self):
|
||||
r = TurnResult()
|
||||
assert _extract_output(r) == ""
|
||||
|
||||
def test_bash_preferred_over_content(self):
|
||||
r = TurnResult(
|
||||
content_parts=["I ran the command"],
|
||||
tool_results=[("read_file", "data"), ("bash", "output")],
|
||||
)
|
||||
assert _extract_output(r) == "output"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _exec_prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExecPrompt:
|
||||
def test_contains_command(self):
|
||||
result = _exec_prompt("ls -la /tmp")
|
||||
assert "ls -la /tmp" in result
|
||||
|
||||
def test_suppression_instruction(self):
|
||||
result = _exec_prompt("echo hello")
|
||||
assert "Do NOT repeat" in result
|
||||
assert "ok" in result.lower() or "failed" in result.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _format_node_result
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatNodeResult:
|
||||
def test_success(self):
|
||||
r = TurnResult(tool_results=[("bash", "output data")])
|
||||
fmt = _format_node_result("node-1", r, 8192)
|
||||
assert fmt["node"] == "node-1"
|
||||
assert fmt["ok"] is True
|
||||
assert fmt["output"] == "output data"
|
||||
assert "timed_out" not in fmt
|
||||
|
||||
def test_timeout(self):
|
||||
r = TurnResult(timed_out=True)
|
||||
fmt = _format_node_result("node-1", r, 8192)
|
||||
assert fmt["ok"] is False
|
||||
assert fmt["timed_out"] is True
|
||||
|
||||
def test_error(self):
|
||||
r = TurnResult(errors=["connection refused"])
|
||||
fmt = _format_node_result("node-1", r, 8192)
|
||||
assert fmt["ok"] is False
|
||||
assert fmt["error"] == "connection refused"
|
||||
|
||||
def test_truncation_applied(self):
|
||||
r = TurnResult(tool_results=[("bash", "x" * 200)])
|
||||
fmt = _format_node_result("node-1", r, 50)
|
||||
assert "truncated" in fmt["output"]
|
||||
|
||||
def test_unlimited_output(self):
|
||||
big = "x" * 100000
|
||||
r = TurnResult(tool_results=[("bash", big)])
|
||||
fmt = _format_node_result("node-1", r, 0)
|
||||
assert fmt["output"] == big
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateCommand:
|
||||
def test_valid(self):
|
||||
assert _validate_command("ls -la") is None
|
||||
|
||||
def test_empty(self):
|
||||
assert _validate_command("") is not None
|
||||
|
||||
def test_whitespace_only(self):
|
||||
assert _validate_command(" ") is not None
|
||||
|
||||
def test_too_long(self):
|
||||
err = _validate_command("x" * 100000)
|
||||
assert err is not None
|
||||
assert "too long" in err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _clamp_timeout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClampTimeout:
|
||||
def test_normal(self):
|
||||
assert _clamp_timeout(60) == 60.0
|
||||
|
||||
def test_too_low(self):
|
||||
assert _clamp_timeout(1) == 5.0
|
||||
|
||||
def test_too_high(self):
|
||||
assert _clamp_timeout(99999) == 3600.0
|
||||
|
||||
def test_negative(self):
|
||||
assert _clamp_timeout(-1) == 5.0
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for MCP tool handlers with mocked TurnstoneClient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from turnstone.mq.client import TurnResult
|
||||
|
||||
from mcp_cluster_ops.server import (
|
||||
_dispatch_parallel,
|
||||
_exec_on_node_sync,
|
||||
_list_nodes_impl,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _list_nodes_impl
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListNodesImpl:
|
||||
def test_returns_nodes(self):
|
||||
nodes = [{"node_id": "a", "model": "gpt-5"}, {"node_id": "b", "model": "gpt-5"}]
|
||||
with patch("mcp_cluster_ops.server.TurnstoneClient") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_nodes.return_value = nodes
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
result = asyncio.run(_list_nodes_impl({"host": "localhost"}))
|
||||
assert result == nodes
|
||||
|
||||
def test_empty_cluster(self):
|
||||
with patch("mcp_cluster_ops.server.TurnstoneClient") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_nodes.return_value = []
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
result = asyncio.run(_list_nodes_impl({"host": "localhost"}))
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _exec_on_node_sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExecOnNodeSync:
|
||||
def test_success(self):
|
||||
turn_result = TurnResult(
|
||||
tool_results=[("bash", "hello world")],
|
||||
)
|
||||
with patch("mcp_cluster_ops.server.TurnstoneClient") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_and_wait.return_value = turn_result
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
node_id, result = _exec_on_node_sync(
|
||||
{"host": "localhost"}, "node-1", "echo hello", 60.0
|
||||
)
|
||||
assert node_id == "node-1"
|
||||
assert result.ok
|
||||
mock_client.send_and_wait.assert_called_once()
|
||||
call_kwargs = mock_client.send_and_wait.call_args
|
||||
assert call_kwargs.kwargs["target_node"] == "node-1"
|
||||
assert call_kwargs.kwargs["auto_approve"] is True
|
||||
|
||||
def test_timeout(self):
|
||||
turn_result = TurnResult(timed_out=True)
|
||||
with patch("mcp_cluster_ops.server.TurnstoneClient") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_and_wait.return_value = turn_result
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
_, result = _exec_on_node_sync({"host": "localhost"}, "node-1", "sleep 9999", 1.0)
|
||||
assert result.timed_out
|
||||
assert not result.ok
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _dispatch_parallel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDispatchParallel:
|
||||
def test_parallel_success(self):
|
||||
def fake_exec(redis_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
return (node_id, TurnResult(tool_results=[("bash", f"output-{node_id}")]))
|
||||
|
||||
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
|
||||
results = asyncio.run(
|
||||
_dispatch_parallel(
|
||||
{"host": "localhost"},
|
||||
["a", "b", "c"],
|
||||
"echo hi",
|
||||
60.0,
|
||||
8192,
|
||||
)
|
||||
)
|
||||
assert len(results) == 3
|
||||
assert all(r["ok"] for r in results)
|
||||
outputs = {r["node"]: r["output"] for r in results}
|
||||
assert outputs["a"] == "output-a"
|
||||
assert outputs["b"] == "output-b"
|
||||
|
||||
def test_partial_failure(self):
|
||||
def fake_exec(redis_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
if node_id == "bad":
|
||||
raise ConnectionError("Redis down")
|
||||
return (node_id, TurnResult(tool_results=[("bash", "ok")]))
|
||||
|
||||
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
|
||||
results = asyncio.run(
|
||||
_dispatch_parallel(
|
||||
{"host": "localhost"},
|
||||
["good", "bad"],
|
||||
"echo hi",
|
||||
60.0,
|
||||
8192,
|
||||
)
|
||||
)
|
||||
assert len(results) == 2
|
||||
good = next(r for r in results if r["node"] == "good")
|
||||
bad = next(r for r in results if r["node"] == "bad")
|
||||
assert good["ok"] is True
|
||||
assert bad["ok"] is False
|
||||
assert "Redis down" in bad["error"]
|
||||
|
||||
def test_all_fail(self):
|
||||
def fake_exec(redis_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
raise RuntimeError(f"fail-{node_id}")
|
||||
|
||||
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
|
||||
results = asyncio.run(
|
||||
_dispatch_parallel(
|
||||
{"host": "localhost"},
|
||||
["a", "b"],
|
||||
"echo hi",
|
||||
60.0,
|
||||
8192,
|
||||
)
|
||||
)
|
||||
assert all(not r["ok"] for r in results)
|
||||
assert "fail-a" in results[0]["error"]
|
||||
assert "fail-b" in results[1]["error"]
|
||||
+33
-4
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.3.5"
|
||||
version = "0.6.2"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
@@ -32,6 +32,9 @@ dependencies = [
|
||||
"pydantic>=2.0",
|
||||
"sqlalchemy>=2.0",
|
||||
"alembic>=1.14",
|
||||
"structlog>=24.1",
|
||||
"PyJWT>=2.8",
|
||||
"bcrypt>=4.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -40,14 +43,15 @@ Repository = "https://github.com/turnstonelabs/turnstone"
|
||||
Issues = "https://github.com/turnstonelabs/turnstone/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest>=9.0", "pytest-cov>=6.0"]
|
||||
test = ["pytest>=9.0", "pytest-cov>=6.0", "croniter>=3.0"]
|
||||
dev = ["ruff>=0.9", "mypy>=1.14", "types-redis>=4.6"]
|
||||
mq = ["redis>=7.2"]
|
||||
console = ["redis>=7.2"]
|
||||
console = ["redis>=7.2", "croniter>=3.0"]
|
||||
sim = ["redis>=7.2"]
|
||||
anthropic = ["anthropic>=0.39"]
|
||||
postgres = ["psycopg[binary]>=3.2"]
|
||||
|
||||
discord = ["discord.py>=2.4", "redis>=7.2"]
|
||||
all = ["turnstone[mq,console,sim,anthropic,postgres,discord]"]
|
||||
|
||||
[project.scripts]
|
||||
turnstone = "turnstone.cli:main"
|
||||
@@ -56,6 +60,9 @@ turnstone-server = "turnstone.server:main"
|
||||
turnstone-bridge = "turnstone.mq.bridge:main"
|
||||
turnstone-console = "turnstone.console.server:main"
|
||||
turnstone-sim = "turnstone.sim.cli:main"
|
||||
turnstone-admin = "turnstone.admin:main"
|
||||
turnstone-channel = "turnstone.channels.cli:main"
|
||||
turnstone-bootstrap = "turnstone.bootstrap:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = [
|
||||
@@ -128,10 +135,32 @@ ignore_missing_imports = true
|
||||
module = ["sqlalchemy", "sqlalchemy.*", "alembic", "alembic.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["structlog", "structlog.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["jwt", "jwt.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["anthropic", "anthropic.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["discord", "discord.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["croniter", "croniter.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["turnstone.channels.discord.*"]
|
||||
disallow_subclassing_any = false
|
||||
disallow_untyped_decorators = false
|
||||
warn_unused_ignores = false
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tests.*"
|
||||
disallow_untyped_defs = false
|
||||
|
||||
+5092
-14
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Server API",
|
||||
"version": "0.3.0",
|
||||
"version": "0.6.1",
|
||||
"description": "Single-node workstream management, chat interaction, and real-time streaming."
|
||||
},
|
||||
"paths": {
|
||||
@@ -314,6 +314,57 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/cancel": {
|
||||
"post": {
|
||||
"summary": "Cancel the active generation in a workstream",
|
||||
"operationId": "v1_api_cancel_post",
|
||||
"tags": [
|
||||
"Chat"
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CancelRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StatusResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/events": {
|
||||
"get": {
|
||||
"summary": "Per-workstream SSE event stream",
|
||||
@@ -365,12 +416,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/sessions": {
|
||||
"/v1/api/workstreams/saved": {
|
||||
"get": {
|
||||
"summary": "List saved sessions",
|
||||
"operationId": "v1_api_sessions_get",
|
||||
"summary": "List saved workstreams",
|
||||
"operationId": "v1_api_workstreams_saved_get",
|
||||
"tags": [
|
||||
"Sessions"
|
||||
"Workstreams"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -378,7 +429,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListSessionsResponse"
|
||||
"$ref": "#/components/schemas/ListSavedWorkstreamsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,6 +478,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/setup": {
|
||||
"post": {
|
||||
"summary": "Create first admin user",
|
||||
"operationId": "v1_api_auth_setup_post",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthSetupRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthSetupResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Error 409",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Error 503",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/status": {
|
||||
"get": {
|
||||
"summary": "Return auth state",
|
||||
"operationId": "v1_api_auth_status_get",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthStatusResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/logout": {
|
||||
"post": {
|
||||
"summary": "Clear auth cookie",
|
||||
@@ -448,6 +581,195 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/memories": {
|
||||
"get": {
|
||||
"summary": "List structured memories",
|
||||
"operationId": "v1_api_memories_get",
|
||||
"tags": [
|
||||
"Memories"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Filter by memory type"
|
||||
},
|
||||
{
|
||||
"name": "scope",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Filter by scope"
|
||||
},
|
||||
{
|
||||
"name": "scope_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Filter by scope identifier"
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 100
|
||||
},
|
||||
"description": "Max results (default 100, max 200)"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListMemoriesResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Save (upsert) a structured memory",
|
||||
"operationId": "v1_api_memories_post",
|
||||
"tags": [
|
||||
"Memories"
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SaveMemoryRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MemoryInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/memories/search": {
|
||||
"post": {
|
||||
"summary": "Search structured memories by query",
|
||||
"operationId": "v1_api_memories_search_post",
|
||||
"tags": [
|
||||
"Memories"
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SearchMemoriesRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListMemoriesResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/memories/{name}": {
|
||||
"delete": {
|
||||
"summary": "Delete a structured memory by name and scope",
|
||||
"operationId": "v1_api_memories_{name}_delete",
|
||||
"tags": [
|
||||
"Memories"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scope",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Scope (default: global)"
|
||||
},
|
||||
{
|
||||
"name": "scope_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Scope identifier"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StatusResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"summary": "Server health check",
|
||||
@@ -503,17 +825,27 @@
|
||||
"type": "object"
|
||||
},
|
||||
"AuthLoginRequest": {
|
||||
"description": "POST /v1/api/auth/login request body.",
|
||||
"description": "POST /v1/api/auth/login request body.\n\nEither username+password or token must be provided.",
|
||||
"properties": {
|
||||
"username": {
|
||||
"default": "",
|
||||
"description": "Login username",
|
||||
"title": "Username",
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"default": "",
|
||||
"description": "Login password",
|
||||
"title": "Password",
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"description": "Bearer token to authenticate",
|
||||
"default": "",
|
||||
"description": "Legacy: bearer token to authenticate",
|
||||
"title": "Token",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"token"
|
||||
],
|
||||
"title": "AuthLoginRequest",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -525,14 +857,35 @@
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"default": "",
|
||||
"description": "Authenticated user ID",
|
||||
"title": "User Id",
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"description": "Assigned role",
|
||||
"description": "Legacy role",
|
||||
"examples": [
|
||||
"full",
|
||||
"read"
|
||||
],
|
||||
"title": "Role",
|
||||
"type": "string"
|
||||
},
|
||||
"scopes": {
|
||||
"default": "",
|
||||
"description": "Comma-separated scopes",
|
||||
"examples": [
|
||||
"read,write,approve"
|
||||
],
|
||||
"title": "Scopes",
|
||||
"type": "string"
|
||||
},
|
||||
"jwt": {
|
||||
"default": "",
|
||||
"description": "JWT session token (if JWT auth is configured)",
|
||||
"title": "Jwt",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -541,6 +894,97 @@
|
||||
"title": "AuthLoginResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"AuthSetupRequest": {
|
||||
"description": "POST /v1/api/auth/setup request body.",
|
||||
"properties": {
|
||||
"username": {
|
||||
"description": "Login username (1-64 ASCII characters)",
|
||||
"title": "Username",
|
||||
"type": "string"
|
||||
},
|
||||
"display_name": {
|
||||
"description": "Display name",
|
||||
"title": "Display Name",
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"description": "Password (minimum 8 characters)",
|
||||
"title": "Password",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"username",
|
||||
"display_name",
|
||||
"password"
|
||||
],
|
||||
"title": "AuthSetupRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"AuthSetupResponse": {
|
||||
"description": "POST /v1/api/auth/setup success response.",
|
||||
"properties": {
|
||||
"status": {
|
||||
"default": "ok",
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"title": "User Id",
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"title": "Username",
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"default": "full",
|
||||
"title": "Role",
|
||||
"type": "string"
|
||||
},
|
||||
"scopes": {
|
||||
"default": "approve,read,write",
|
||||
"title": "Scopes",
|
||||
"type": "string"
|
||||
},
|
||||
"jwt": {
|
||||
"default": "",
|
||||
"description": "JWT session token",
|
||||
"title": "Jwt",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"user_id",
|
||||
"username"
|
||||
],
|
||||
"title": "AuthSetupResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"AuthStatusResponse": {
|
||||
"description": "GET /v1/api/auth/status response.",
|
||||
"properties": {
|
||||
"auth_enabled": {
|
||||
"title": "Auth Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"has_users": {
|
||||
"title": "Has Users",
|
||||
"type": "boolean"
|
||||
},
|
||||
"setup_required": {
|
||||
"title": "Setup Required",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"auth_enabled",
|
||||
"has_users",
|
||||
"setup_required"
|
||||
],
|
||||
"title": "AuthStatusResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"SendRequest": {
|
||||
"properties": {
|
||||
"message": {
|
||||
@@ -658,6 +1102,20 @@
|
||||
"title": "CommandRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"CancelRequest": {
|
||||
"properties": {
|
||||
"ws_id": {
|
||||
"description": "Target workstream ID",
|
||||
"title": "Ws Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ws_id"
|
||||
],
|
||||
"title": "CancelRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"CreateWorkstreamRequest": {
|
||||
"properties": {
|
||||
"name": {
|
||||
@@ -677,6 +1135,24 @@
|
||||
"description": "Auto-approve all tool calls",
|
||||
"title": "Auto Approve",
|
||||
"type": "boolean"
|
||||
},
|
||||
"resume_ws": {
|
||||
"default": "",
|
||||
"description": "Workstream ID to resume atomically during creation (empty = fresh start)",
|
||||
"title": "Resume Ws",
|
||||
"type": "string"
|
||||
},
|
||||
"template": {
|
||||
"default": "",
|
||||
"description": "Prompt template name (replaces default templates)",
|
||||
"title": "Template",
|
||||
"type": "string"
|
||||
},
|
||||
"ws_template": {
|
||||
"default": "",
|
||||
"description": "Workstream template name to apply defaults from",
|
||||
"title": "Ws Template",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "CreateWorkstreamRequest",
|
||||
@@ -693,6 +1169,18 @@
|
||||
"description": "Assigned workstream name",
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"resumed": {
|
||||
"default": false,
|
||||
"description": "Whether a previous workstream was resumed",
|
||||
"title": "Resumed",
|
||||
"type": "boolean"
|
||||
},
|
||||
"message_count": {
|
||||
"default": 0,
|
||||
"description": "Number of messages in the resumed workstream",
|
||||
"title": "Message Count",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -745,18 +1233,6 @@
|
||||
"state": {
|
||||
"title": "State",
|
||||
"type": "string"
|
||||
},
|
||||
"session_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Session Id"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -837,18 +1313,6 @@
|
||||
"title": "State",
|
||||
"type": "string"
|
||||
},
|
||||
"session_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Session Id"
|
||||
},
|
||||
"title": {
|
||||
"default": "",
|
||||
"title": "Title",
|
||||
@@ -903,26 +1367,26 @@
|
||||
"title": "DashboardWorkstream",
|
||||
"type": "object"
|
||||
},
|
||||
"ListSessionsResponse": {
|
||||
"ListSavedWorkstreamsResponse": {
|
||||
"properties": {
|
||||
"sessions": {
|
||||
"workstreams": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SessionInfo"
|
||||
"$ref": "#/components/schemas/SavedWorkstreamInfo"
|
||||
},
|
||||
"title": "Sessions",
|
||||
"title": "Workstreams",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sessions"
|
||||
"workstreams"
|
||||
],
|
||||
"title": "ListSessionsResponse",
|
||||
"title": "ListSavedWorkstreamsResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"SessionInfo": {
|
||||
"SavedWorkstreamInfo": {
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"title": "Session Id",
|
||||
"ws_id": {
|
||||
"title": "Ws Id",
|
||||
"type": "string"
|
||||
},
|
||||
"alias": {
|
||||
@@ -963,12 +1427,12 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"session_id",
|
||||
"ws_id",
|
||||
"created",
|
||||
"updated",
|
||||
"message_count"
|
||||
],
|
||||
"title": "SessionInfo",
|
||||
"title": "SavedWorkstreamInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"HealthResponse": {
|
||||
@@ -1017,6 +1481,17 @@
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"mcp": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/McpStatus"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1052,6 +1527,27 @@
|
||||
"title": "BackendStatus",
|
||||
"type": "object"
|
||||
},
|
||||
"McpStatus": {
|
||||
"properties": {
|
||||
"servers": {
|
||||
"default": 0,
|
||||
"title": "Servers",
|
||||
"type": "integer"
|
||||
},
|
||||
"resources": {
|
||||
"default": 0,
|
||||
"title": "Resources",
|
||||
"type": "integer"
|
||||
},
|
||||
"prompts": {
|
||||
"default": 0,
|
||||
"title": "Prompts",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"title": "McpStatus",
|
||||
"type": "object"
|
||||
},
|
||||
"WorkstreamCounts": {
|
||||
"properties": {
|
||||
"total": {
|
||||
@@ -1087,6 +1583,200 @@
|
||||
},
|
||||
"title": "WorkstreamCounts",
|
||||
"type": "object"
|
||||
},
|
||||
"SaveMemoryRequest": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "Memory identifier (normalized to snake_case)",
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"description": "Memory content",
|
||||
"maxLength": 65536,
|
||||
"title": "Content",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"default": "",
|
||||
"description": "Short description for relevance matching",
|
||||
"title": "Description",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"default": "project",
|
||||
"description": "Memory type",
|
||||
"enum": [
|
||||
"user",
|
||||
"project",
|
||||
"feedback",
|
||||
"reference"
|
||||
],
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
},
|
||||
"scope": {
|
||||
"default": "global",
|
||||
"description": "Memory scope",
|
||||
"enum": [
|
||||
"global",
|
||||
"workstream",
|
||||
"user"
|
||||
],
|
||||
"title": "Scope",
|
||||
"type": "string"
|
||||
},
|
||||
"scope_id": {
|
||||
"default": "",
|
||||
"description": "Scope identifier (ws_id for workstream, user_id for user scope)",
|
||||
"title": "Scope Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"content"
|
||||
],
|
||||
"title": "SaveMemoryRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"MemoryInfo": {
|
||||
"properties": {
|
||||
"memory_id": {
|
||||
"title": "Memory Id",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"default": "",
|
||||
"title": "Description",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"user",
|
||||
"project",
|
||||
"feedback",
|
||||
"reference"
|
||||
],
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
},
|
||||
"scope": {
|
||||
"enum": [
|
||||
"global",
|
||||
"workstream",
|
||||
"user"
|
||||
],
|
||||
"title": "Scope",
|
||||
"type": "string"
|
||||
},
|
||||
"scope_id": {
|
||||
"default": "",
|
||||
"title": "Scope Id",
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"title": "Content",
|
||||
"type": "string"
|
||||
},
|
||||
"created": {
|
||||
"title": "Created",
|
||||
"type": "string"
|
||||
},
|
||||
"updated": {
|
||||
"title": "Updated",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"memory_id",
|
||||
"name",
|
||||
"type",
|
||||
"scope",
|
||||
"content",
|
||||
"created",
|
||||
"updated"
|
||||
],
|
||||
"title": "MemoryInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"ListMemoriesResponse": {
|
||||
"properties": {
|
||||
"memories": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MemoryInfo"
|
||||
},
|
||||
"title": "Memories",
|
||||
"type": "array"
|
||||
},
|
||||
"total": {
|
||||
"default": 0,
|
||||
"title": "Total",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"memories"
|
||||
],
|
||||
"title": "ListMemoriesResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"SearchMemoriesRequest": {
|
||||
"properties": {
|
||||
"query": {
|
||||
"description": "Search query text",
|
||||
"title": "Query",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"default": "",
|
||||
"description": "Filter by memory type",
|
||||
"enum": [
|
||||
"",
|
||||
"user",
|
||||
"project",
|
||||
"feedback",
|
||||
"reference"
|
||||
],
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
},
|
||||
"scope": {
|
||||
"default": "",
|
||||
"description": "Filter by scope",
|
||||
"enum": [
|
||||
"",
|
||||
"global",
|
||||
"workstream",
|
||||
"user"
|
||||
],
|
||||
"title": "Scope",
|
||||
"type": "string"
|
||||
},
|
||||
"scope_id": {
|
||||
"default": "",
|
||||
"description": "Filter by scope_id",
|
||||
"title": "Scope Id",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"default": 20,
|
||||
"description": "Max results (1-50)",
|
||||
"maximum": 50,
|
||||
"minimum": 1,
|
||||
"title": "Limit",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
],
|
||||
"title": "SearchMemoriesRequest",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,58 @@
|
||||
import { BaseClient, type ClientOptions } from "./base.js";
|
||||
import type { ClusterEvent } from "./events.js";
|
||||
import type {
|
||||
AdminListMemoriesOptions,
|
||||
AdminMemoryInfo,
|
||||
AdminSearchMemoriesOptions,
|
||||
AuditQueryOptions,
|
||||
AuditResponse,
|
||||
AuthLoginResponse,
|
||||
AuthSetupResponse,
|
||||
AuthStatusResponse,
|
||||
ClusterNodesResponse,
|
||||
ClusterOverviewResponse,
|
||||
ClusterSnapshotResponse,
|
||||
ClusterWorkstreamsResponse,
|
||||
ConsoleCreateWsRequest,
|
||||
ConsoleCreateWsResponse,
|
||||
ConsoleHealthResponse,
|
||||
CreateMcpServerRequest,
|
||||
CreatePolicyOptions,
|
||||
CreateRoleOptions,
|
||||
CreateScheduleRequest,
|
||||
CreateTemplateOptions,
|
||||
CreateWsTemplateOptions,
|
||||
ImportMcpConfigResponse,
|
||||
ListAdminMemoriesResponse,
|
||||
ListMcpServersResponse,
|
||||
ListScheduleRunsResponse,
|
||||
ListSchedulesResponse,
|
||||
ListSettingSchemaResponse,
|
||||
ListSettingsResponse,
|
||||
McpServerDetail,
|
||||
NodeDetailResponse,
|
||||
NodesOptions,
|
||||
OrgInfo,
|
||||
PromptTemplateInfo,
|
||||
RoleInfo,
|
||||
ScheduleInfo,
|
||||
SettingInfo,
|
||||
StatusResponse,
|
||||
ToolPolicyInfo,
|
||||
UpdateMcpServerRequest,
|
||||
UpdateOrgOptions,
|
||||
UpdatePolicyOptions,
|
||||
UpdateRoleOptions,
|
||||
UpdateScheduleRequest,
|
||||
UpdateSettingOptions,
|
||||
UpdateTemplateOptions,
|
||||
UpdateWsTemplateOptions,
|
||||
UsageQueryOptions,
|
||||
UsageResponse,
|
||||
UserRoleInfo,
|
||||
WorkstreamsOptions,
|
||||
WsTemplateInfo,
|
||||
WsTemplateVersionInfo,
|
||||
} from "./types.js";
|
||||
|
||||
/** Async client for the turnstone console API. */
|
||||
@@ -26,6 +67,10 @@ export class TurnstoneConsole extends BaseClient {
|
||||
return this.request("GET", "/v1/api/cluster/overview");
|
||||
}
|
||||
|
||||
async snapshot(): Promise<ClusterSnapshotResponse> {
|
||||
return this.request("GET", "/v1/api/cluster/snapshot");
|
||||
}
|
||||
|
||||
async nodes(opts?: NodesOptions): Promise<ClusterNodesResponse> {
|
||||
return this.request("GET", "/v1/api/cluster/nodes", {
|
||||
params: {
|
||||
@@ -70,9 +115,33 @@ export class TurnstoneConsole extends BaseClient {
|
||||
|
||||
// -- Auth -----------------------------------------------------------------
|
||||
|
||||
async login(token: string): Promise<AuthLoginResponse> {
|
||||
return this.request("POST", "/v1/api/auth/login", {
|
||||
json: { token },
|
||||
async login(opts: {
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}): Promise<AuthLoginResponse> {
|
||||
const body =
|
||||
opts.username && opts.password
|
||||
? { username: opts.username, password: opts.password }
|
||||
: { token: opts.token ?? "" };
|
||||
return this.request("POST", "/v1/api/auth/login", { json: body });
|
||||
}
|
||||
|
||||
async authStatus(): Promise<AuthStatusResponse> {
|
||||
return this.request("GET", "/v1/api/auth/status");
|
||||
}
|
||||
|
||||
async setup(opts: {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
}): Promise<AuthSetupResponse> {
|
||||
return this.request("POST", "/v1/api/auth/setup", {
|
||||
json: {
|
||||
username: opts.username,
|
||||
display_name: opts.displayName,
|
||||
password: opts.password,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,4 +154,310 @@ export class TurnstoneConsole extends BaseClient {
|
||||
async health(): Promise<ConsoleHealthResponse> {
|
||||
return this.request("GET", "/health");
|
||||
}
|
||||
|
||||
// -- Schedules ------------------------------------------------------------
|
||||
|
||||
async listSchedules(): Promise<ListSchedulesResponse> {
|
||||
return this.request("GET", "/v1/api/admin/schedules");
|
||||
}
|
||||
|
||||
async createSchedule(opts: CreateScheduleRequest): Promise<ScheduleInfo> {
|
||||
return this.request("POST", "/v1/api/admin/schedules", { json: opts });
|
||||
}
|
||||
|
||||
async getSchedule(taskId: string): Promise<ScheduleInfo> {
|
||||
return this.request("GET", `/v1/api/admin/schedules/${taskId}`);
|
||||
}
|
||||
|
||||
async updateSchedule(
|
||||
taskId: string,
|
||||
opts: UpdateScheduleRequest,
|
||||
): Promise<ScheduleInfo> {
|
||||
return this.request("PUT", `/v1/api/admin/schedules/${taskId}`, {
|
||||
json: opts,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSchedule(taskId: string): Promise<StatusResponse> {
|
||||
return this.request("DELETE", `/v1/api/admin/schedules/${taskId}`);
|
||||
}
|
||||
|
||||
async listScheduleRuns(
|
||||
taskId: string,
|
||||
opts?: { limit?: number },
|
||||
): Promise<ListScheduleRunsResponse> {
|
||||
return this.request("GET", `/v1/api/admin/schedules/${taskId}/runs`, {
|
||||
params: { limit: opts?.limit ?? 50 },
|
||||
});
|
||||
}
|
||||
|
||||
// -- Governance: Roles ------------------------------------------------------
|
||||
|
||||
async listRoles(): Promise<{ roles: RoleInfo[] }> {
|
||||
return this.request("GET", "/v1/api/admin/roles");
|
||||
}
|
||||
|
||||
async createRole(opts: CreateRoleOptions): Promise<RoleInfo> {
|
||||
return this.request("POST", "/v1/api/admin/roles", { json: opts });
|
||||
}
|
||||
|
||||
async updateRole(roleId: string, opts: UpdateRoleOptions): Promise<RoleInfo> {
|
||||
return this.request("PUT", `/v1/api/admin/roles/${roleId}`, {
|
||||
json: opts,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRole(roleId: string): Promise<StatusResponse> {
|
||||
return this.request("DELETE", `/v1/api/admin/roles/${roleId}`);
|
||||
}
|
||||
|
||||
async listUserRoles(userId: string): Promise<{ roles: UserRoleInfo[] }> {
|
||||
return this.request("GET", `/v1/api/admin/users/${userId}/roles`);
|
||||
}
|
||||
|
||||
async assignRole(userId: string, roleId: string): Promise<StatusResponse> {
|
||||
return this.request("POST", `/v1/api/admin/users/${userId}/roles`, {
|
||||
json: { role_id: roleId },
|
||||
});
|
||||
}
|
||||
|
||||
async unassignRole(userId: string, roleId: string): Promise<StatusResponse> {
|
||||
return this.request(
|
||||
"DELETE",
|
||||
`/v1/api/admin/users/${userId}/roles/${roleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// -- Governance: Organizations ----------------------------------------------
|
||||
|
||||
async listOrgs(): Promise<{ orgs: OrgInfo[] }> {
|
||||
return this.request("GET", "/v1/api/admin/orgs");
|
||||
}
|
||||
|
||||
async getOrg(orgId: string): Promise<OrgInfo> {
|
||||
return this.request("GET", `/v1/api/admin/orgs/${orgId}`);
|
||||
}
|
||||
|
||||
async updateOrg(orgId: string, opts: UpdateOrgOptions): Promise<OrgInfo> {
|
||||
return this.request("PUT", `/v1/api/admin/orgs/${orgId}`, { json: opts });
|
||||
}
|
||||
|
||||
// -- Governance: Tool Policies ----------------------------------------------
|
||||
|
||||
async listPolicies(): Promise<{ policies: ToolPolicyInfo[] }> {
|
||||
return this.request("GET", "/v1/api/admin/policies");
|
||||
}
|
||||
|
||||
async createPolicy(opts: CreatePolicyOptions): Promise<ToolPolicyInfo> {
|
||||
return this.request("POST", "/v1/api/admin/policies", { json: opts });
|
||||
}
|
||||
|
||||
async updatePolicy(
|
||||
policyId: string,
|
||||
opts: UpdatePolicyOptions,
|
||||
): Promise<ToolPolicyInfo> {
|
||||
return this.request("PUT", `/v1/api/admin/policies/${policyId}`, {
|
||||
json: opts,
|
||||
});
|
||||
}
|
||||
|
||||
async deletePolicy(policyId: string): Promise<StatusResponse> {
|
||||
return this.request("DELETE", `/v1/api/admin/policies/${policyId}`);
|
||||
}
|
||||
|
||||
// -- Governance: Prompt Templates -------------------------------------------
|
||||
|
||||
async listTemplates(): Promise<{ templates: PromptTemplateInfo[] }> {
|
||||
return this.request("GET", "/v1/api/admin/templates");
|
||||
}
|
||||
|
||||
async createTemplate(
|
||||
opts: CreateTemplateOptions,
|
||||
): Promise<PromptTemplateInfo> {
|
||||
return this.request("POST", "/v1/api/admin/templates", { json: opts });
|
||||
}
|
||||
|
||||
async updateTemplate(
|
||||
templateId: string,
|
||||
opts: UpdateTemplateOptions,
|
||||
): Promise<PromptTemplateInfo> {
|
||||
return this.request("PUT", `/v1/api/admin/templates/${templateId}`, {
|
||||
json: opts,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTemplate(templateId: string): Promise<StatusResponse> {
|
||||
return this.request("DELETE", `/v1/api/admin/templates/${templateId}`);
|
||||
}
|
||||
|
||||
// -- Governance: Workstream Templates ----------------------------------------
|
||||
|
||||
async listWsTemplates(): Promise<WsTemplateInfo[]> {
|
||||
const data = await this.request<{ ws_templates: WsTemplateInfo[] }>(
|
||||
"GET",
|
||||
"/v1/api/admin/ws-templates",
|
||||
);
|
||||
return data.ws_templates || [];
|
||||
}
|
||||
|
||||
async createWsTemplate(
|
||||
opts: CreateWsTemplateOptions,
|
||||
): Promise<WsTemplateInfo> {
|
||||
return this.request("POST", "/v1/api/admin/ws-templates", {
|
||||
json: opts,
|
||||
});
|
||||
}
|
||||
|
||||
async getWsTemplate(wsTemplateId: string): Promise<WsTemplateInfo> {
|
||||
return this.request("GET", `/v1/api/admin/ws-templates/${wsTemplateId}`);
|
||||
}
|
||||
|
||||
async updateWsTemplate(
|
||||
wsTemplateId: string,
|
||||
opts: UpdateWsTemplateOptions,
|
||||
): Promise<WsTemplateInfo> {
|
||||
return this.request("PUT", `/v1/api/admin/ws-templates/${wsTemplateId}`, {
|
||||
json: opts,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteWsTemplate(wsTemplateId: string): Promise<void> {
|
||||
await this.request("DELETE", `/v1/api/admin/ws-templates/${wsTemplateId}`);
|
||||
}
|
||||
|
||||
async listWsTemplateVersions(
|
||||
wsTemplateId: string,
|
||||
): Promise<WsTemplateVersionInfo[]> {
|
||||
const data = await this.request<{ versions: WsTemplateVersionInfo[] }>(
|
||||
"GET",
|
||||
`/v1/api/admin/ws-templates/${wsTemplateId}/versions`,
|
||||
);
|
||||
return data.versions || [];
|
||||
}
|
||||
|
||||
// -- Governance: Usage & Audit ----------------------------------------------
|
||||
|
||||
async getUsage(opts: UsageQueryOptions): Promise<UsageResponse> {
|
||||
const params: Record<string, string> = { since: opts.since };
|
||||
if (opts.until) params.until = opts.until;
|
||||
if (opts.user_id) params.user_id = opts.user_id;
|
||||
if (opts.model) params.model = opts.model;
|
||||
if (opts.group_by) params.group_by = opts.group_by;
|
||||
return this.request("GET", "/v1/api/admin/usage", { params });
|
||||
}
|
||||
|
||||
async getAudit(opts?: AuditQueryOptions): Promise<AuditResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
if (opts?.action) params.action = opts.action;
|
||||
if (opts?.user_id) params.user_id = opts.user_id;
|
||||
if (opts?.since) params.since = opts.since;
|
||||
if (opts?.until) params.until = opts.until;
|
||||
if (opts?.limit !== undefined) params.limit = String(opts.limit);
|
||||
if (opts?.offset !== undefined) params.offset = String(opts.offset);
|
||||
return this.request("GET", "/v1/api/admin/audit", { params });
|
||||
}
|
||||
|
||||
// -- Admin: Memories ------------------------------------------------------
|
||||
|
||||
async listMemories(
|
||||
opts?: AdminListMemoriesOptions,
|
||||
): Promise<ListAdminMemoriesResponse> {
|
||||
const params: Record<string, string | number> = {};
|
||||
if (opts?.type) params.type = opts.type;
|
||||
if (opts?.scope) params.scope = opts.scope;
|
||||
if (opts?.scope_id) params.scope_id = opts.scope_id;
|
||||
if (opts?.limit !== undefined) params.limit = opts.limit;
|
||||
return this.request("GET", "/v1/api/admin/memories", { params });
|
||||
}
|
||||
|
||||
async searchMemories(
|
||||
opts: AdminSearchMemoriesOptions,
|
||||
): Promise<ListAdminMemoriesResponse> {
|
||||
const params: Record<string, string | number> = { q: opts.q };
|
||||
if (opts.type) params.type = opts.type;
|
||||
if (opts.scope) params.scope = opts.scope;
|
||||
if (opts.scope_id) params.scope_id = opts.scope_id;
|
||||
if (opts.limit !== undefined) params.limit = opts.limit;
|
||||
return this.request("GET", "/v1/api/admin/memories/search", { params });
|
||||
}
|
||||
|
||||
async getMemory(memoryId: string): Promise<AdminMemoryInfo> {
|
||||
return this.request("GET", `/v1/api/admin/memories/${memoryId}`);
|
||||
}
|
||||
|
||||
async deleteMemory(memoryId: string): Promise<StatusResponse> {
|
||||
return this.request("DELETE", `/v1/api/admin/memories/${memoryId}`);
|
||||
}
|
||||
|
||||
// -- System: Settings -------------------------------------------------------
|
||||
|
||||
async listSettings(): Promise<ListSettingsResponse> {
|
||||
return this.request("GET", "/v1/api/admin/settings");
|
||||
}
|
||||
|
||||
async getSettingsSchema(): Promise<ListSettingSchemaResponse> {
|
||||
return this.request("GET", "/v1/api/admin/settings/schema");
|
||||
}
|
||||
|
||||
async updateSetting(
|
||||
key: string,
|
||||
opts: UpdateSettingOptions,
|
||||
): Promise<SettingInfo> {
|
||||
return this.request("PUT", `/v1/api/admin/settings/${key}`, {
|
||||
json: opts,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSetting(key: string, nodeId?: string): Promise<StatusResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
if (nodeId) params.node_id = nodeId;
|
||||
return this.request("DELETE", `/v1/api/admin/settings/${key}`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// -- MCP servers ----------------------------------------------------------
|
||||
|
||||
async listMcpServers(opts?: {
|
||||
reveal?: boolean;
|
||||
}): Promise<ListMcpServersResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
if (opts?.reveal) params.reveal = "true";
|
||||
return this.request("GET", "/v1/api/admin/mcp-servers", { params });
|
||||
}
|
||||
|
||||
async createMcpServer(
|
||||
body: CreateMcpServerRequest,
|
||||
): Promise<McpServerDetail> {
|
||||
return this.request("POST", "/v1/api/admin/mcp-servers", { json: body });
|
||||
}
|
||||
|
||||
async getMcpServer(serverId: string): Promise<McpServerDetail> {
|
||||
return this.request("GET", `/v1/api/admin/mcp-servers/${serverId}`);
|
||||
}
|
||||
|
||||
async updateMcpServer(
|
||||
serverId: string,
|
||||
body: UpdateMcpServerRequest,
|
||||
): Promise<McpServerDetail> {
|
||||
return this.request("PUT", `/v1/api/admin/mcp-servers/${serverId}`, {
|
||||
json: body,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMcpServer(serverId: string): Promise<StatusResponse> {
|
||||
return this.request("DELETE", `/v1/api/admin/mcp-servers/${serverId}`);
|
||||
}
|
||||
|
||||
async reloadMcpServers(): Promise<StatusResponse> {
|
||||
return this.request("POST", "/v1/api/admin/mcp-servers/reload");
|
||||
}
|
||||
|
||||
async importMcpConfig(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<ImportMcpConfigResponse> {
|
||||
return this.request("POST", "/v1/api/admin/mcp-servers/import", {
|
||||
json: { config },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ClusterOverviewResponse, ClusterSnapshotNode } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server SSE events
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -46,6 +48,12 @@ export interface ApproveRequestEvent {
|
||||
items: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface ApprovalResolvedEvent {
|
||||
type: "approval_resolved";
|
||||
approved: boolean;
|
||||
feedback: string;
|
||||
}
|
||||
|
||||
export interface ToolResultEvent {
|
||||
type: "tool_result";
|
||||
call_id: string;
|
||||
@@ -93,6 +101,10 @@ export interface ClearUiEvent {
|
||||
type: "clear_ui";
|
||||
}
|
||||
|
||||
export interface CancelledEvent {
|
||||
type: "cancelled";
|
||||
}
|
||||
|
||||
// Global events
|
||||
|
||||
export interface WsStateEvent {
|
||||
@@ -135,6 +147,7 @@ export type ServerEvent =
|
||||
| StreamEndEvent
|
||||
| ToolInfoEvent
|
||||
| ApproveRequestEvent
|
||||
| ApprovalResolvedEvent
|
||||
| ToolResultEvent
|
||||
| ToolOutputChunkEvent
|
||||
| StatusEvent
|
||||
@@ -143,6 +156,7 @@ export type ServerEvent =
|
||||
| ErrorEvent
|
||||
| BusyErrorEvent
|
||||
| ClearUiEvent
|
||||
| CancelledEvent
|
||||
| WsStateEvent
|
||||
| WsActivityEvent
|
||||
| WsRenameEvent
|
||||
@@ -191,6 +205,13 @@ export interface ClusterWsRenameEvent {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ClusterSnapshotEvent {
|
||||
type: "snapshot";
|
||||
nodes: ClusterSnapshotNode[];
|
||||
overview: ClusterOverviewResponse;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Discriminated union of all console cluster SSE event types. */
|
||||
export type ClusterEvent =
|
||||
| NodeJoinedEvent
|
||||
@@ -198,7 +219,8 @@ export type ClusterEvent =
|
||||
| ClusterStateEvent
|
||||
| ClusterWsCreatedEvent
|
||||
| ClusterWsClosedEvent
|
||||
| ClusterWsRenameEvent;
|
||||
| ClusterWsRenameEvent
|
||||
| ClusterSnapshotEvent;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type guards
|
||||
@@ -234,6 +256,16 @@ export function isApproveRequestEvent(
|
||||
return e.type === "approve_request";
|
||||
}
|
||||
|
||||
export function isApprovalResolvedEvent(
|
||||
e: ServerEvent,
|
||||
): e is ApprovalResolvedEvent {
|
||||
return e.type === "approval_resolved";
|
||||
}
|
||||
|
||||
export function isPlanReviewEvent(e: ServerEvent): e is PlanReviewEvent {
|
||||
return e.type === "plan_review";
|
||||
}
|
||||
|
||||
export function isCancelledEvent(e: ServerEvent): e is CancelledEvent {
|
||||
return e.type === "cancelled";
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export type {
|
||||
StreamEndEvent,
|
||||
ToolInfoEvent,
|
||||
ApproveRequestEvent,
|
||||
ApprovalResolvedEvent,
|
||||
ToolResultEvent,
|
||||
ToolOutputChunkEvent,
|
||||
StatusEvent,
|
||||
@@ -45,6 +46,7 @@ export type {
|
||||
ErrorEvent,
|
||||
BusyErrorEvent,
|
||||
ClearUiEvent,
|
||||
CancelledEvent,
|
||||
WsStateEvent,
|
||||
WsActivityEvent,
|
||||
WsRenameEvent,
|
||||
@@ -55,6 +57,7 @@ export type {
|
||||
ClusterWsCreatedEvent,
|
||||
ClusterWsClosedEvent,
|
||||
ClusterWsRenameEvent,
|
||||
ClusterSnapshotEvent,
|
||||
} from "./events.js";
|
||||
|
||||
export {
|
||||
@@ -65,7 +68,9 @@ export {
|
||||
isToolResultEvent,
|
||||
isWsStateEvent,
|
||||
isApproveRequestEvent,
|
||||
isApprovalResolvedEvent,
|
||||
isPlanReviewEvent,
|
||||
isCancelledEvent,
|
||||
} from "./events.js";
|
||||
|
||||
// Request/response types
|
||||
@@ -83,28 +88,85 @@ export type {
|
||||
DashboardWorkstream,
|
||||
DashboardAggregate,
|
||||
DashboardResponse,
|
||||
SessionInfo,
|
||||
ListSessionsResponse,
|
||||
SavedWorkstreamInfo,
|
||||
ListSavedWorkstreamsResponse,
|
||||
BackendStatus,
|
||||
McpStatus,
|
||||
WorkstreamCounts,
|
||||
HealthResponse,
|
||||
AuthLoginRequest,
|
||||
AuthLoginResponse,
|
||||
AuthStatusResponse,
|
||||
AuthSetupResponse,
|
||||
StatusResponse,
|
||||
ErrorResponse,
|
||||
ClusterOverviewResponse,
|
||||
ClusterNodeInfo,
|
||||
ClusterNodesResponse,
|
||||
ClusterSnapshotNode,
|
||||
ClusterSnapshotResponse,
|
||||
ClusterWorkstreamInfo,
|
||||
ClusterWorkstreamsResponse,
|
||||
NodeDetailResponse,
|
||||
ConsoleCreateWsRequest,
|
||||
ConsoleCreateWsResponse,
|
||||
ConsoleHealthResponse,
|
||||
CreateScheduleRequest,
|
||||
UpdateScheduleRequest,
|
||||
ScheduleInfo,
|
||||
ScheduleRunInfo,
|
||||
ListSchedulesResponse,
|
||||
ListScheduleRunsResponse,
|
||||
RoleInfo,
|
||||
CreateRoleOptions,
|
||||
UpdateRoleOptions,
|
||||
UserRoleInfo,
|
||||
OrgInfo,
|
||||
UpdateOrgOptions,
|
||||
ToolPolicyInfo,
|
||||
CreatePolicyOptions,
|
||||
UpdatePolicyOptions,
|
||||
PromptTemplateInfo,
|
||||
CreateTemplateOptions,
|
||||
UpdateTemplateOptions,
|
||||
WsTemplateInfo,
|
||||
CreateWsTemplateOptions,
|
||||
UpdateWsTemplateOptions,
|
||||
WsTemplateVersionInfo,
|
||||
UsageBreakdownItem,
|
||||
UsageResponse,
|
||||
UsageQueryOptions,
|
||||
AuditEventInfo,
|
||||
AuditQueryOptions,
|
||||
AuditResponse,
|
||||
TurnResult,
|
||||
SendAndWaitOptions,
|
||||
NodesOptions,
|
||||
WorkstreamsOptions,
|
||||
// Memory types
|
||||
SaveMemoryRequest,
|
||||
MemoryInfo,
|
||||
ListMemoriesResponse,
|
||||
SearchMemoriesRequest,
|
||||
ListMemoriesOptions,
|
||||
DeleteMemoryOptions,
|
||||
AdminMemoryInfo,
|
||||
ListAdminMemoriesResponse,
|
||||
AdminListMemoriesOptions,
|
||||
AdminSearchMemoriesOptions,
|
||||
// Settings types
|
||||
SettingInfo,
|
||||
ListSettingsResponse,
|
||||
SettingSchemaInfo,
|
||||
ListSettingSchemaResponse,
|
||||
UpdateSettingOptions,
|
||||
// MCP server types
|
||||
McpServerStatus,
|
||||
McpServerDetail,
|
||||
ListMcpServersResponse,
|
||||
CreateMcpServerRequest,
|
||||
UpdateMcpServerRequest,
|
||||
ImportMcpConfigResponse,
|
||||
} from "./types.js";
|
||||
|
||||
// SSE parser (for advanced usage)
|
||||
|
||||
@@ -2,12 +2,20 @@ import { BaseClient, type ClientOptions } from "./base.js";
|
||||
import type { ServerEvent } from "./events.js";
|
||||
import type {
|
||||
AuthLoginResponse,
|
||||
AuthSetupResponse,
|
||||
AuthStatusResponse,
|
||||
CreateWorkstreamRequest,
|
||||
CreateWorkstreamResponse,
|
||||
DashboardResponse,
|
||||
DeleteMemoryOptions,
|
||||
HealthResponse,
|
||||
ListSessionsResponse,
|
||||
ListMemoriesOptions,
|
||||
ListMemoriesResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
ListWorkstreamsResponse,
|
||||
MemoryInfo,
|
||||
SaveMemoryRequest,
|
||||
SearchMemoriesRequest,
|
||||
SendAndWaitOptions,
|
||||
SendResponse,
|
||||
StatusResponse,
|
||||
@@ -84,6 +92,12 @@ export class TurnstoneServer extends BaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
async cancel(wsId: string): Promise<StatusResponse> {
|
||||
return this.request("POST", "/v1/api/cancel", {
|
||||
json: { ws_id: wsId },
|
||||
});
|
||||
}
|
||||
|
||||
// -- Streaming ------------------------------------------------------------
|
||||
|
||||
async *streamEvents(wsId: string): AsyncIterableIterator<ServerEvent> {
|
||||
@@ -176,17 +190,74 @@ export class TurnstoneServer extends BaseClient {
|
||||
return result;
|
||||
}
|
||||
|
||||
// -- Sessions -------------------------------------------------------------
|
||||
// -- Saved workstreams ----------------------------------------------------
|
||||
|
||||
async listSessions(): Promise<ListSessionsResponse> {
|
||||
return this.request("GET", "/v1/api/sessions");
|
||||
async listSavedWorkstreams(): Promise<ListSavedWorkstreamsResponse> {
|
||||
return this.request("GET", "/v1/api/workstreams/saved");
|
||||
}
|
||||
|
||||
// -- Memories -------------------------------------------------------------
|
||||
|
||||
async listMemories(
|
||||
opts?: ListMemoriesOptions,
|
||||
): Promise<ListMemoriesResponse> {
|
||||
const params: Record<string, string | number> = {};
|
||||
if (opts?.type) params.type = opts.type;
|
||||
if (opts?.scope) params.scope = opts.scope;
|
||||
if (opts?.scope_id) params.scope_id = opts.scope_id;
|
||||
if (opts?.limit !== undefined) params.limit = opts.limit;
|
||||
return this.request("GET", "/v1/api/memories", { params });
|
||||
}
|
||||
|
||||
async saveMemory(opts: SaveMemoryRequest): Promise<MemoryInfo> {
|
||||
return this.request("POST", "/v1/api/memories", { json: opts });
|
||||
}
|
||||
|
||||
async searchMemories(
|
||||
opts: SearchMemoriesRequest,
|
||||
): Promise<ListMemoriesResponse> {
|
||||
return this.request("POST", "/v1/api/memories/search", { json: opts });
|
||||
}
|
||||
|
||||
async deleteMemory(
|
||||
name: string,
|
||||
opts?: DeleteMemoryOptions,
|
||||
): Promise<StatusResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
if (opts?.scope) params.scope = opts.scope;
|
||||
if (opts?.scope_id) params.scope_id = opts.scope_id;
|
||||
return this.request("DELETE", `/v1/api/memories/${name}`, { params });
|
||||
}
|
||||
|
||||
// -- Auth -----------------------------------------------------------------
|
||||
|
||||
async login(token: string): Promise<AuthLoginResponse> {
|
||||
return this.request("POST", "/v1/api/auth/login", {
|
||||
json: { token },
|
||||
async login(opts: {
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}): Promise<AuthLoginResponse> {
|
||||
const body =
|
||||
opts.username && opts.password
|
||||
? { username: opts.username, password: opts.password }
|
||||
: { token: opts.token ?? "" };
|
||||
return this.request("POST", "/v1/api/auth/login", { json: body });
|
||||
}
|
||||
|
||||
async authStatus(): Promise<AuthStatusResponse> {
|
||||
return this.request("GET", "/v1/api/auth/status");
|
||||
}
|
||||
|
||||
async setup(opts: {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
}): Promise<AuthSetupResponse> {
|
||||
return this.request("POST", "/v1/api/auth/setup", {
|
||||
json: {
|
||||
username: opts.username,
|
||||
display_name: opts.displayName,
|
||||
password: opts.password,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+554
-7
@@ -17,6 +17,24 @@ export interface AuthLoginRequest {
|
||||
export interface AuthLoginResponse {
|
||||
status: string;
|
||||
role: string;
|
||||
scopes?: string;
|
||||
jwt?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
export interface AuthStatusResponse {
|
||||
auth_enabled: boolean;
|
||||
has_users: boolean;
|
||||
setup_required: boolean;
|
||||
}
|
||||
|
||||
export interface AuthSetupResponse {
|
||||
status: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
scopes: string;
|
||||
jwt?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -53,11 +71,16 @@ export interface CreateWorkstreamRequest {
|
||||
name?: string;
|
||||
model?: string;
|
||||
auto_approve?: boolean;
|
||||
resume_ws?: string;
|
||||
template?: string;
|
||||
ws_template?: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkstreamResponse {
|
||||
ws_id: string;
|
||||
name: string;
|
||||
resumed?: boolean;
|
||||
message_count?: number;
|
||||
}
|
||||
|
||||
export interface CloseWorkstreamRequest {
|
||||
@@ -68,7 +91,6 @@ export interface WorkstreamInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
session_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ListWorkstreamsResponse {
|
||||
@@ -79,7 +101,6 @@ export interface DashboardWorkstream {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
session_id?: string | null;
|
||||
title?: string;
|
||||
tokens?: number;
|
||||
context_ratio?: number;
|
||||
@@ -106,11 +127,11 @@ export interface DashboardResponse {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server API — Sessions
|
||||
// Server API — Saved workstreams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SessionInfo {
|
||||
session_id: string;
|
||||
export interface SavedWorkstreamInfo {
|
||||
ws_id: string;
|
||||
alias?: string | null;
|
||||
title?: string | null;
|
||||
created: string;
|
||||
@@ -118,8 +139,8 @@ export interface SessionInfo {
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface ListSessionsResponse {
|
||||
sessions: SessionInfo[];
|
||||
export interface ListSavedWorkstreamsResponse {
|
||||
workstreams: SavedWorkstreamInfo[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -140,6 +161,12 @@ export interface WorkstreamCounts {
|
||||
error?: number;
|
||||
}
|
||||
|
||||
export interface McpStatus {
|
||||
servers: number;
|
||||
resources: number;
|
||||
prompts: number;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
version?: string;
|
||||
@@ -147,6 +174,7 @@ export interface HealthResponse {
|
||||
model?: string;
|
||||
workstreams?: WorkstreamCounts;
|
||||
backend?: BackendStatus | null;
|
||||
mcp?: McpStatus | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -225,11 +253,30 @@ export interface NodeDetailResponse {
|
||||
aggregate: ClusterAggregate;
|
||||
}
|
||||
|
||||
export interface ClusterSnapshotNode {
|
||||
node_id: string;
|
||||
server_url: string;
|
||||
max_ws: number;
|
||||
reachable: boolean;
|
||||
version: string;
|
||||
health: Record<string, string>;
|
||||
aggregate: Record<string, number>;
|
||||
workstreams: ClusterWorkstreamInfo[];
|
||||
}
|
||||
|
||||
export interface ClusterSnapshotResponse {
|
||||
nodes: ClusterSnapshotNode[];
|
||||
overview: ClusterOverviewResponse;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ConsoleCreateWsRequest {
|
||||
node_id?: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
initial_message?: string;
|
||||
template?: string;
|
||||
ws_template?: string;
|
||||
}
|
||||
|
||||
export interface ConsoleCreateWsResponse {
|
||||
@@ -247,6 +294,319 @@ export interface ConsoleHealthResponse {
|
||||
versions: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console API — Schedules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateScheduleRequest {
|
||||
name: string;
|
||||
schedule_type: string;
|
||||
initial_message: string;
|
||||
description?: string;
|
||||
cron_expr?: string;
|
||||
at_time?: string;
|
||||
target_mode?: string;
|
||||
model?: string;
|
||||
auto_approve?: boolean;
|
||||
auto_approve_tools?: string[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateScheduleRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
schedule_type?: string;
|
||||
cron_expr?: string;
|
||||
at_time?: string;
|
||||
target_mode?: string;
|
||||
model?: string;
|
||||
initial_message?: string;
|
||||
auto_approve?: boolean;
|
||||
auto_approve_tools?: string[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduleInfo {
|
||||
task_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
schedule_type: string;
|
||||
cron_expr: string;
|
||||
at_time: string;
|
||||
target_mode: string;
|
||||
model: string;
|
||||
initial_message: string;
|
||||
auto_approve: boolean;
|
||||
auto_approve_tools: string[];
|
||||
enabled: boolean;
|
||||
created_by: string;
|
||||
last_run: string | null;
|
||||
next_run: string | null;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface ListSchedulesResponse {
|
||||
schedules: ScheduleInfo[];
|
||||
}
|
||||
|
||||
export interface ScheduleRunInfo {
|
||||
run_id: string;
|
||||
task_id: string;
|
||||
node_id: string;
|
||||
ws_id: string;
|
||||
correlation_id: string;
|
||||
started: string;
|
||||
status: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ListScheduleRunsResponse {
|
||||
runs: ScheduleRunInfo[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console API — Governance: Roles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RoleInfo {
|
||||
role_id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
permissions: string;
|
||||
builtin: boolean;
|
||||
org_id: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface CreateRoleOptions {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
permissions?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRoleOptions {
|
||||
display_name?: string;
|
||||
permissions?: string;
|
||||
}
|
||||
|
||||
export interface UserRoleInfo extends RoleInfo {
|
||||
assigned_by: string;
|
||||
assignment_created: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console API — Governance: Orgs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OrgInfo {
|
||||
org_id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
settings: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface UpdateOrgOptions {
|
||||
display_name?: string;
|
||||
settings?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console API — Governance: Tool Policies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ToolPolicyInfo {
|
||||
policy_id: string;
|
||||
name: string;
|
||||
tool_pattern: string;
|
||||
action: string;
|
||||
priority: number;
|
||||
org_id: string;
|
||||
enabled: boolean;
|
||||
created_by: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface CreatePolicyOptions {
|
||||
name: string;
|
||||
tool_pattern: string;
|
||||
action: string;
|
||||
priority?: number;
|
||||
org_id?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePolicyOptions {
|
||||
name?: string;
|
||||
tool_pattern?: string;
|
||||
action?: string;
|
||||
priority?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console API — Governance: Prompt Templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptTemplateInfo {
|
||||
template_id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
content: string;
|
||||
variables: string;
|
||||
is_default: boolean;
|
||||
org_id: string;
|
||||
created_by: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
origin: string;
|
||||
mcp_server: string;
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
export interface CreateTemplateOptions {
|
||||
name: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
variables?: string;
|
||||
is_default?: boolean;
|
||||
org_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTemplateOptions {
|
||||
name?: string;
|
||||
content?: string;
|
||||
category?: string;
|
||||
variables?: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console API — Governance: Workstream Templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WsTemplateInfo {
|
||||
ws_template_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
system_prompt: string;
|
||||
prompt_template: string;
|
||||
prompt_template_hash: string;
|
||||
model: string;
|
||||
auto_approve: boolean;
|
||||
auto_approve_tools: string;
|
||||
temperature: number | null;
|
||||
reasoning_effort: string;
|
||||
max_tokens: number | null;
|
||||
token_budget: number;
|
||||
agent_max_turns: number | null;
|
||||
notify_on_complete: string;
|
||||
org_id: string;
|
||||
created_by: string;
|
||||
enabled: boolean;
|
||||
version: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface CreateWsTemplateOptions {
|
||||
name: string;
|
||||
description?: string;
|
||||
system_prompt?: string;
|
||||
prompt_template?: string;
|
||||
model?: string;
|
||||
auto_approve?: boolean;
|
||||
auto_approve_tools?: string;
|
||||
temperature?: number | null;
|
||||
reasoning_effort?: string;
|
||||
max_tokens?: number | null;
|
||||
token_budget?: number;
|
||||
agent_max_turns?: number | null;
|
||||
notify_on_complete?: string;
|
||||
org_id?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateWsTemplateOptions {
|
||||
name?: string;
|
||||
description?: string;
|
||||
system_prompt?: string;
|
||||
prompt_template?: string;
|
||||
model?: string;
|
||||
auto_approve?: boolean;
|
||||
auto_approve_tools?: string;
|
||||
temperature?: number | null;
|
||||
reasoning_effort?: string;
|
||||
max_tokens?: number | null;
|
||||
token_budget?: number;
|
||||
agent_max_turns?: number | null;
|
||||
notify_on_complete?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface WsTemplateVersionInfo {
|
||||
id: number;
|
||||
ws_template_id: string;
|
||||
version: number;
|
||||
snapshot: string;
|
||||
changed_by: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console API — Governance: Usage & Audit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UsageBreakdownItem {
|
||||
key?: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
tool_calls_count: number;
|
||||
}
|
||||
|
||||
export interface UsageResponse {
|
||||
summary: UsageBreakdownItem[];
|
||||
breakdown: UsageBreakdownItem[];
|
||||
}
|
||||
|
||||
export interface UsageQueryOptions {
|
||||
since: string;
|
||||
until?: string;
|
||||
user_id?: string;
|
||||
model?: string;
|
||||
group_by?: string;
|
||||
}
|
||||
|
||||
export interface AuditEventInfo {
|
||||
event_id: string;
|
||||
timestamp: string;
|
||||
user_id: string;
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
detail: string;
|
||||
ip_address: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export interface AuditQueryOptions {
|
||||
action?: string;
|
||||
user_id?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface AuditResponse {
|
||||
events: AuditEventInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDK-specific types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -284,5 +644,192 @@ export interface WorkstreamsOptions {
|
||||
per_page?: number;
|
||||
}
|
||||
|
||||
// -- Server API: Memories ---------------------------------------------------
|
||||
|
||||
export interface SaveMemoryRequest {
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string;
|
||||
type?: "user" | "project" | "feedback" | "reference";
|
||||
scope?: "global" | "workstream" | "user";
|
||||
scope_id?: string;
|
||||
}
|
||||
|
||||
export interface MemoryInfo {
|
||||
memory_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
scope: string;
|
||||
scope_id: string;
|
||||
content: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface ListMemoriesResponse {
|
||||
memories: MemoryInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SearchMemoriesRequest {
|
||||
query: string;
|
||||
type?: string;
|
||||
scope?: string;
|
||||
scope_id?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ListMemoriesOptions {
|
||||
type?: string;
|
||||
scope?: string;
|
||||
scope_id?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface DeleteMemoryOptions {
|
||||
scope?: string;
|
||||
scope_id?: string;
|
||||
}
|
||||
|
||||
// -- Console API: Admin Memories --------------------------------------------
|
||||
|
||||
export interface AdminMemoryInfo {
|
||||
memory_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
scope: string;
|
||||
scope_id: string;
|
||||
content: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
last_accessed: string;
|
||||
access_count: number;
|
||||
}
|
||||
|
||||
export interface ListAdminMemoriesResponse {
|
||||
memories: AdminMemoryInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AdminListMemoriesOptions {
|
||||
type?: string;
|
||||
scope?: string;
|
||||
scope_id?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AdminSearchMemoriesOptions {
|
||||
q: string;
|
||||
type?: string;
|
||||
scope?: string;
|
||||
scope_id?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// -- Console API: MCP Servers -----------------------------------------------
|
||||
|
||||
export interface McpServerStatus {
|
||||
connected: boolean;
|
||||
tools: number;
|
||||
resources: number;
|
||||
prompts: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface McpServerDetail {
|
||||
server_id: string;
|
||||
name: string;
|
||||
transport: string;
|
||||
command: string;
|
||||
args: string;
|
||||
url: string;
|
||||
headers: string;
|
||||
env: string;
|
||||
auto_approve: boolean;
|
||||
enabled: boolean;
|
||||
created_by: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
status: Record<string, McpServerStatus>;
|
||||
}
|
||||
|
||||
export interface ListMcpServersResponse {
|
||||
servers: McpServerDetail[];
|
||||
}
|
||||
|
||||
export interface CreateMcpServerRequest {
|
||||
name: string;
|
||||
transport: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
env?: Record<string, string>;
|
||||
auto_approve?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateMcpServerRequest {
|
||||
name?: string;
|
||||
transport?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
env?: Record<string, string>;
|
||||
auto_approve?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ImportMcpConfigResponse {
|
||||
imported: string[];
|
||||
skipped: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// -- Console API: System Settings -------------------------------------------
|
||||
|
||||
export interface SettingInfo {
|
||||
key: string;
|
||||
value: unknown;
|
||||
source: string;
|
||||
type: string;
|
||||
description: string;
|
||||
section: string;
|
||||
is_secret: boolean;
|
||||
node_id: string;
|
||||
changed_by: string;
|
||||
updated: string;
|
||||
restart_required: boolean;
|
||||
}
|
||||
|
||||
export interface ListSettingsResponse {
|
||||
settings: SettingInfo[];
|
||||
}
|
||||
|
||||
export interface SettingSchemaInfo {
|
||||
key: string;
|
||||
type: string;
|
||||
default: unknown;
|
||||
description: string;
|
||||
section: string;
|
||||
is_secret: boolean;
|
||||
min_value: number | null;
|
||||
max_value: number | null;
|
||||
choices: string[] | null;
|
||||
restart_required: boolean;
|
||||
}
|
||||
|
||||
export interface ListSettingSchemaResponse {
|
||||
schema: SettingSchemaInfo[];
|
||||
}
|
||||
|
||||
export interface UpdateSettingOptions {
|
||||
value: unknown;
|
||||
node_id?: string;
|
||||
}
|
||||
|
||||
// Re-export event types for convenience
|
||||
export type { ServerEvent, ClusterEvent } from "./events.js";
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isToolResultEvent,
|
||||
isWsStateEvent,
|
||||
isApproveRequestEvent,
|
||||
isApprovalResolvedEvent,
|
||||
isPlanReviewEvent,
|
||||
isReasoningEvent,
|
||||
} from "../src/events.js";
|
||||
@@ -62,6 +63,15 @@ describe("event type guards", () => {
|
||||
expect(isApproveRequestEvent(e)).toBe(true);
|
||||
});
|
||||
|
||||
it("isApprovalResolvedEvent", () => {
|
||||
const e: ServerEvent = {
|
||||
type: "approval_resolved",
|
||||
approved: false,
|
||||
feedback: "Approval timed out",
|
||||
};
|
||||
expect(isApprovalResolvedEvent(e)).toBe(true);
|
||||
});
|
||||
|
||||
it("isPlanReviewEvent", () => {
|
||||
const e: ServerEvent = { type: "plan_review", content: "## Plan" };
|
||||
expect(isPlanReviewEvent(e)).toBe(true);
|
||||
|
||||
+20
-2
@@ -59,7 +59,7 @@
|
||||
"user_prompt": "Change the default port from 8000 to 9000 in both server.py and config.py",
|
||||
"setup": {
|
||||
"files": {
|
||||
"server.py": "from config import PORT\n\ndef run():\n print(f'Listening on port {PORT}')\n",
|
||||
"server.py": "import socket\n\ndef run():\n sock = socket.socket()\n sock.bind(('localhost', 8000))\n print('Server running on port 8000')\n",
|
||||
"config.py": "PORT = 8000\nHOST = 'localhost'\n"
|
||||
}
|
||||
},
|
||||
@@ -126,7 +126,7 @@
|
||||
"app.py": "import sqlite3\nfrom flask import Flask, jsonify\n\napp = Flask(__name__)\nDB = 'data.db'\n\ndef get_db():\n return sqlite3.connect(DB)\n\n@app.route('/users')\ndef list_users():\n db = get_db()\n users = db.execute('SELECT * FROM users').fetchall()\n db.close()\n return jsonify(users)\n\n@app.route('/users/<int:uid>')\ndef get_user(uid):\n db = get_db()\n user = db.execute('SELECT * FROM users WHERE id=?', (uid,)).fetchone()\n db.close()\n return jsonify(user)\n\nif __name__ == '__main__':\n app.run(port=8000)\n"
|
||||
}
|
||||
},
|
||||
"expected_actions": [{ "tool": "plan" }],
|
||||
"expected_actions": [{ "tool": "create_plan" }],
|
||||
"match_mode": "subset"
|
||||
},
|
||||
{
|
||||
@@ -175,6 +175,24 @@
|
||||
{ "tool": "man", "args_pattern": { "page": "tar" } }
|
||||
],
|
||||
"match_mode": "subset"
|
||||
},
|
||||
{
|
||||
"id": "math-calculation",
|
||||
"description": "Use the math tool for precise calculations, not bash or mental math",
|
||||
"user_prompt": "What is 2^64 - 1? Use the math tool to calculate it precisely.",
|
||||
"expected_actions": [
|
||||
{ "tool": "math", "args_pattern": { "code": "2.*64" } }
|
||||
],
|
||||
"match_mode": "subset"
|
||||
},
|
||||
{
|
||||
"id": "web-search-query",
|
||||
"description": "Use web_search for general knowledge lookups, not web_fetch",
|
||||
"user_prompt": "Search the web for the current population of Tokyo",
|
||||
"expected_actions": [
|
||||
{ "tool": "web_search", "args_pattern": { "query": "Tokyo" } }
|
||||
],
|
||||
"match_mode": "subset"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests for turnstone.mq.async_broker.AsyncRedisBroker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.mq.async_broker import AsyncRedisBroker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def broker() -> AsyncRedisBroker:
|
||||
return AsyncRedisBroker(host="localhost", port=6379, db=0, prefix="test", response_ttl=120)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis() -> AsyncMock:
|
||||
"""Return a mock Redis client with common async methods."""
|
||||
r = AsyncMock()
|
||||
r.rpush = AsyncMock()
|
||||
r.publish = AsyncMock()
|
||||
r.expire = AsyncMock()
|
||||
r.get = AsyncMock(return_value=None)
|
||||
r.set = AsyncMock()
|
||||
r.delete = AsyncMock()
|
||||
r.blpop = AsyncMock(return_value=None)
|
||||
ps = AsyncMock()
|
||||
ps.subscribe = AsyncMock()
|
||||
ps.unsubscribe = AsyncMock()
|
||||
ps.close = AsyncMock()
|
||||
ps.get_message = AsyncMock(return_value=None)
|
||||
r.pubsub = MagicMock(return_value=ps)
|
||||
return r
|
||||
|
||||
|
||||
def _inject_redis(broker: AsyncRedisBroker, mock_redis: AsyncMock) -> None:
|
||||
"""Inject a mock Redis client into the broker, simulating connect()."""
|
||||
broker._redis = mock_redis
|
||||
broker._pubsub = mock_redis.pubsub()
|
||||
|
||||
|
||||
class TestConstructor:
|
||||
def test_stores_config(self) -> None:
|
||||
b = AsyncRedisBroker(host="h", port=1234, db=2, prefix="pfx", password="pw")
|
||||
assert b._host == "h"
|
||||
assert b._port == 1234
|
||||
assert b._db == 2
|
||||
assert b._prefix == "pfx"
|
||||
assert b._password == "pw"
|
||||
assert b._redis is None
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
b = AsyncRedisBroker()
|
||||
assert b._host == "localhost"
|
||||
assert b._port == 6379
|
||||
assert b._prefix == "turnstone"
|
||||
|
||||
|
||||
class TestConnect:
|
||||
@pytest.mark.anyio
|
||||
async def test_creates_connection(self) -> None:
|
||||
b = AsyncRedisBroker()
|
||||
mock_r = AsyncMock()
|
||||
mock_r.pubsub = MagicMock(return_value=AsyncMock())
|
||||
with patch("redis.asyncio.Redis", return_value=mock_r):
|
||||
await b.connect()
|
||||
assert b._redis is mock_r
|
||||
assert b._pubsub is not None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connect_idempotent(
|
||||
self, broker: AsyncRedisBroker, mock_redis: AsyncMock
|
||||
) -> None:
|
||||
_inject_redis(broker, mock_redis)
|
||||
old = broker._redis
|
||||
await broker.connect()
|
||||
assert broker._redis is old
|
||||
|
||||
|
||||
class TestPushInbound:
|
||||
@pytest.mark.anyio
|
||||
async def test_shared_queue(self, broker: AsyncRedisBroker, mock_redis: AsyncMock) -> None:
|
||||
_inject_redis(broker, mock_redis)
|
||||
await broker.push_inbound('{"type":"send"}')
|
||||
mock_redis.rpush.assert_awaited_once_with("test:inbound", '{"type":"send"}')
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_per_node_queue(self, broker: AsyncRedisBroker, mock_redis: AsyncMock) -> None:
|
||||
_inject_redis(broker, mock_redis)
|
||||
await broker.push_inbound('{"type":"send"}', node_id="node-1")
|
||||
mock_redis.rpush.assert_awaited_once_with("test:inbound:node-1", '{"type":"send"}')
|
||||
|
||||
|
||||
class TestPublishOutbound:
|
||||
@pytest.mark.anyio
|
||||
async def test_publishes(self, broker: AsyncRedisBroker, mock_redis: AsyncMock) -> None:
|
||||
_inject_redis(broker, mock_redis)
|
||||
await broker.publish_outbound("test:events:global", '{"event":"data"}')
|
||||
mock_redis.publish.assert_awaited_once_with("test:events:global", '{"event":"data"}')
|
||||
|
||||
|
||||
class TestPushResponse:
|
||||
@pytest.mark.anyio
|
||||
async def test_rpush_and_expire(self, broker: AsyncRedisBroker, mock_redis: AsyncMock) -> None:
|
||||
_inject_redis(broker, mock_redis)
|
||||
await broker.push_response("req-123", '{"ok":true}')
|
||||
mock_redis.rpush.assert_awaited_once_with("test:resp:req-123", '{"ok":true}')
|
||||
mock_redis.expire.assert_awaited_once_with("test:resp:req-123", 120)
|
||||
|
||||
|
||||
class TestSubscribe:
|
||||
@pytest.mark.anyio
|
||||
async def test_creates_task(self, broker: AsyncRedisBroker, mock_redis: AsyncMock) -> None:
|
||||
_inject_redis(broker, mock_redis)
|
||||
await broker.subscribe("test:events:global", lambda msg: None)
|
||||
assert "test:events:global" in broker._callbacks
|
||||
assert broker._listener_task is not None
|
||||
assert isinstance(broker._listener_task, asyncio.Task)
|
||||
# Clean up.
|
||||
broker._listener_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await broker._listener_task
|
||||
|
||||
|
||||
class TestUnsubscribe:
|
||||
@pytest.mark.anyio
|
||||
async def test_cancels_task(self, broker: AsyncRedisBroker, mock_redis: AsyncMock) -> None:
|
||||
_inject_redis(broker, mock_redis)
|
||||
await broker.subscribe("test:events:ch", lambda msg: None)
|
||||
assert "test:events:ch" in broker._callbacks
|
||||
await broker.unsubscribe("test:events:ch")
|
||||
assert "test:events:ch" not in broker._callbacks
|
||||
|
||||
|
||||
class TestRoutingPrimitives:
|
||||
@pytest.mark.anyio
|
||||
async def test_get_ws_owner(self, broker: AsyncRedisBroker, mock_redis: AsyncMock) -> None:
|
||||
_inject_redis(broker, mock_redis)
|
||||
mock_redis.get.return_value = "node-1"
|
||||
result = await broker.get_ws_owner("ws-abc")
|
||||
mock_redis.get.assert_awaited_once_with("test:ws:ws-abc")
|
||||
assert result == "node-1"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_set_ws_owner(self, broker: AsyncRedisBroker, mock_redis: AsyncMock) -> None:
|
||||
_inject_redis(broker, mock_redis)
|
||||
await broker.set_ws_owner("ws-abc", "node-2")
|
||||
mock_redis.set.assert_awaited_once_with("test:ws:ws-abc", "node-2")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_set_ws_owner_with_ttl(
|
||||
self, broker: AsyncRedisBroker, mock_redis: AsyncMock
|
||||
) -> None:
|
||||
_inject_redis(broker, mock_redis)
|
||||
await broker.set_ws_owner("ws-abc", "node-2", ttl=300)
|
||||
mock_redis.set.assert_awaited_once_with("test:ws:ws-abc", "node-2", ex=300)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_del_ws_owner(self, broker: AsyncRedisBroker, mock_redis: AsyncMock) -> None:
|
||||
_inject_redis(broker, mock_redis)
|
||||
await broker.del_ws_owner("ws-abc")
|
||||
mock_redis.delete.assert_awaited_once_with("test:ws:ws-abc")
|
||||
|
||||
|
||||
class TestClose:
|
||||
@pytest.mark.anyio
|
||||
async def test_cancels_tasks_and_closes(
|
||||
self, broker: AsyncRedisBroker, mock_redis: AsyncMock
|
||||
) -> None:
|
||||
_inject_redis(broker, mock_redis)
|
||||
await broker.subscribe("ch1", lambda m: None)
|
||||
assert len(broker._callbacks) == 1
|
||||
assert broker._listener_task is not None
|
||||
await broker.close()
|
||||
assert len(broker._callbacks) == 0
|
||||
assert broker._listener_task is None
|
||||
assert broker._redis is None
|
||||
assert broker._pubsub is None
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Tests for turnstone.core.audit."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.audit import record_audit
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path):
|
||||
path = str(tmp_path / "test.db")
|
||||
backend = SQLiteBackend(path)
|
||||
yield backend
|
||||
backend.close()
|
||||
|
||||
|
||||
def test_record_audit_basic(storage):
|
||||
record_audit(
|
||||
storage, "user-1", "user.create", "user", "u123", {"username": "alice"}, "127.0.0.1"
|
||||
)
|
||||
events = storage.list_audit_events()
|
||||
assert len(events) == 1
|
||||
ev = events[0]
|
||||
assert ev["user_id"] == "user-1"
|
||||
assert ev["action"] == "user.create"
|
||||
assert ev["resource_type"] == "user"
|
||||
assert ev["resource_id"] == "u123"
|
||||
assert ev["ip_address"] == "127.0.0.1"
|
||||
detail = json.loads(ev["detail"])
|
||||
assert detail["username"] == "alice"
|
||||
|
||||
|
||||
def test_record_audit_no_detail(storage):
|
||||
record_audit(storage, "user-1", "token.revoke", "token", "t456")
|
||||
events = storage.list_audit_events()
|
||||
assert len(events) == 1
|
||||
assert events[0]["detail"] == "{}"
|
||||
|
||||
|
||||
def test_record_audit_silent_on_failure():
|
||||
"""record_audit should not raise even if storage is broken."""
|
||||
|
||||
class BrokenStorage:
|
||||
def record_audit_event(self, **kw):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
# Should not raise
|
||||
record_audit(BrokenStorage(), "u1", "test.action")
|
||||
|
||||
|
||||
def test_record_audit_generates_unique_ids(storage):
|
||||
record_audit(storage, "u1", "a.one")
|
||||
record_audit(storage, "u1", "a.two")
|
||||
events = storage.list_audit_events()
|
||||
assert len(events) == 2
|
||||
assert events[0]["event_id"] != events[1]["event_id"]
|
||||
+414
-79
@@ -1,7 +1,9 @@
|
||||
"""Tests for turnstone.core.auth — bearer token authentication and cookies."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
import queue
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -15,7 +17,7 @@ from turnstone.core.auth import (
|
||||
load_auth_config,
|
||||
make_clear_cookie,
|
||||
make_set_cookie,
|
||||
required_role,
|
||||
required_scope,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -87,69 +89,85 @@ class TestIsPublicPath:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRequiredRole:
|
||||
class TestRequiredScope:
|
||||
def test_get_api_needs_read(self):
|
||||
assert required_role("GET", "/api/workstreams") == "read"
|
||||
assert required_scope("GET", "/api/workstreams") == "read"
|
||||
|
||||
def test_get_events_needs_read(self):
|
||||
assert required_role("GET", "/api/events") == "read"
|
||||
assert required_scope("GET", "/api/events") == "read"
|
||||
|
||||
def test_get_dashboard_needs_read(self):
|
||||
assert required_role("GET", "/api/dashboard") == "read"
|
||||
def test_post_send_needs_write(self):
|
||||
assert required_scope("POST", "/api/send") == "write"
|
||||
|
||||
def test_post_send_needs_full(self):
|
||||
assert required_role("POST", "/api/send") == "full"
|
||||
def test_post_approve_needs_approve(self):
|
||||
assert required_scope("POST", "/api/approve") == "approve"
|
||||
|
||||
def test_post_approve_needs_full(self):
|
||||
assert required_role("POST", "/api/approve") == "full"
|
||||
def test_post_plan_needs_write(self):
|
||||
assert required_scope("POST", "/api/plan") == "write"
|
||||
|
||||
def test_post_plan_needs_full(self):
|
||||
assert required_role("POST", "/api/plan") == "full"
|
||||
def test_post_command_needs_write(self):
|
||||
assert required_scope("POST", "/api/command") == "write"
|
||||
|
||||
def test_post_command_needs_full(self):
|
||||
assert required_role("POST", "/api/command") == "full"
|
||||
def test_post_workstreams_new_needs_write(self):
|
||||
assert required_scope("POST", "/api/workstreams/new") == "write"
|
||||
|
||||
def test_post_workstreams_new_needs_full(self):
|
||||
assert required_role("POST", "/api/workstreams/new") == "full"
|
||||
def test_post_workstreams_close_needs_write(self):
|
||||
assert required_scope("POST", "/api/workstreams/close") == "write"
|
||||
|
||||
def test_post_workstreams_close_needs_full(self):
|
||||
assert required_role("POST", "/api/workstreams/close") == "full"
|
||||
|
||||
def test_all_write_paths_need_full(self):
|
||||
def test_all_write_paths_need_write(self):
|
||||
for path in WRITE_PATHS:
|
||||
assert required_role("POST", path) == "full"
|
||||
scope = required_scope("POST", path)
|
||||
assert scope in ("write", "approve"), f"{path} should need write or approve"
|
||||
|
||||
def test_post_unknown_path_needs_read(self):
|
||||
assert required_role("POST", "/api/unknown") == "read"
|
||||
assert required_scope("POST", "/api/unknown") == "read"
|
||||
|
||||
def test_v1_post_send_needs_full(self):
|
||||
assert required_role("POST", "/v1/api/send") == "full"
|
||||
def test_v1_post_send_needs_write(self):
|
||||
assert required_scope("POST", "/v1/api/send") == "write"
|
||||
|
||||
def test_v1_post_approve_needs_full(self):
|
||||
assert required_role("POST", "/v1/api/approve") == "full"
|
||||
def test_v1_post_approve_needs_approve(self):
|
||||
assert required_scope("POST", "/v1/api/approve") == "approve"
|
||||
|
||||
def test_v1_get_workstreams_needs_read(self):
|
||||
assert required_role("GET", "/v1/api/workstreams") == "read"
|
||||
assert required_scope("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_post_cluster_ws_new_needs_write(self):
|
||||
assert required_scope("POST", "/v1/api/cluster/workstreams/new") == "write"
|
||||
|
||||
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_write(self):
|
||||
assert required_scope("POST", "/node/node-a/v1/api/send") == "write"
|
||||
|
||||
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_approve_needs_approve(self):
|
||||
assert required_scope("POST", "/node/node-a/v1/api/approve") == "approve"
|
||||
|
||||
def test_proxy_v1_read_endpoint_needs_read(self):
|
||||
assert required_role("GET", "/node/node-a/v1/api/workstreams") == "read"
|
||||
assert required_scope("GET", "/node/node-a/v1/api/workstreams") == "read"
|
||||
|
||||
# Memory endpoints
|
||||
def test_get_memories_needs_read(self):
|
||||
assert required_scope("GET", "/api/memories") == "read"
|
||||
|
||||
def test_post_memories_needs_write(self):
|
||||
assert required_scope("POST", "/api/memories") == "write"
|
||||
|
||||
def test_post_memories_search_needs_read(self):
|
||||
"""Search via POST is non-mutating — requires only read scope."""
|
||||
assert required_scope("POST", "/api/memories/search") == "read"
|
||||
|
||||
def test_delete_memory_needs_write(self):
|
||||
assert required_scope("DELETE", "/api/memories/my_key") == "write"
|
||||
|
||||
def test_v1_post_memories_needs_write(self):
|
||||
assert required_scope("POST", "/v1/api/memories") == "write"
|
||||
|
||||
def test_v1_delete_memory_needs_write(self):
|
||||
assert required_scope("DELETE", "/v1/api/memories/test_key") == "write"
|
||||
|
||||
def test_admin_memories_needs_approve(self):
|
||||
assert required_scope("GET", "/api/admin/memories") == "approve"
|
||||
|
||||
def test_admin_memory_delete_needs_approve(self):
|
||||
assert required_scope("DELETE", "/api/admin/memories/some-id") == "approve"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -268,12 +286,24 @@ class TestMakeSetCookie:
|
||||
|
||||
def test_max_age_default(self):
|
||||
val = make_set_cookie("tok_abc")
|
||||
assert "Max-Age=2592000" in val # 30 days
|
||||
assert "Max-Age=86400" in val # 24 hours (matches JWT expiry)
|
||||
|
||||
def test_max_age_custom(self):
|
||||
val = make_set_cookie("tok_abc", max_age=3600)
|
||||
assert "Max-Age=3600" in val
|
||||
|
||||
def test_secure_default(self):
|
||||
val = make_set_cookie("tok_abc")
|
||||
assert "; Secure" in val
|
||||
|
||||
def test_secure_false(self):
|
||||
val = make_set_cookie("tok_abc", secure=False)
|
||||
assert "; Secure" not in val
|
||||
|
||||
def test_secure_true(self):
|
||||
val = make_set_cookie("tok_abc", secure=True)
|
||||
assert "; Secure" in val
|
||||
|
||||
|
||||
class TestMakeClearCookie:
|
||||
def test_max_age_zero(self):
|
||||
@@ -306,68 +336,78 @@ class TestCheckRequest:
|
||||
)
|
||||
|
||||
def test_disabled_allows_all(self, disabled):
|
||||
allowed, status, msg = check_request(disabled, "POST", "/api/send", None)
|
||||
allowed, status, msg, _result = check_request(disabled, "POST", "/api/send", None)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_disabled_allows_no_header(self, disabled):
|
||||
allowed, status, msg = check_request(disabled, "GET", "/api/workstreams", None)
|
||||
allowed, status, msg, _result = check_request(disabled, "GET", "/api/workstreams", None)
|
||||
assert allowed is True
|
||||
|
||||
def test_public_path_no_token_ok(self, enabled):
|
||||
allowed, status, msg = check_request(enabled, "GET", "/health", None)
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/health", None)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_public_root_no_token_ok(self, enabled):
|
||||
allowed, status, msg = check_request(enabled, "GET", "/", None)
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/", None)
|
||||
assert allowed is True
|
||||
|
||||
def test_public_static_no_token_ok(self, enabled):
|
||||
allowed, status, msg = check_request(enabled, "GET", "/static/style.css", None)
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/static/style.css", None)
|
||||
assert allowed is True
|
||||
|
||||
def test_api_no_token_401(self, enabled):
|
||||
allowed, status, msg = check_request(enabled, "GET", "/api/workstreams", None)
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/api/workstreams", None)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
assert "Unauthorized" in msg
|
||||
|
||||
def test_api_invalid_token_401(self, enabled):
|
||||
allowed, status, msg = check_request(
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "GET", "/api/workstreams", "Bearer wrong_token"
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
def test_api_read_token_ok(self, enabled):
|
||||
allowed, status, msg = check_request(enabled, "GET", "/api/workstreams", "Bearer tok_read")
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "GET", "/api/workstreams", "Bearer tok_read"
|
||||
)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_api_full_token_ok(self, enabled):
|
||||
allowed, status, msg = check_request(enabled, "GET", "/api/workstreams", "Bearer tok_full")
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "GET", "/api/workstreams", "Bearer tok_full"
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_write_read_token_403(self, enabled):
|
||||
allowed, status, msg = check_request(enabled, "POST", "/api/send", "Bearer tok_read")
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/send", "Bearer tok_read"
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
assert "Forbidden" in msg
|
||||
|
||||
def test_write_full_token_ok(self, enabled):
|
||||
allowed, status, msg = check_request(enabled, "POST", "/api/send", "Bearer tok_full")
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/send", "Bearer tok_full"
|
||||
)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_approve_read_token_403(self, enabled):
|
||||
allowed, status, msg = check_request(enabled, "POST", "/api/approve", "Bearer tok_read")
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/approve", "Bearer tok_read"
|
||||
)
|
||||
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(
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/node/node-a/api/send", "Bearer tok_read"
|
||||
)
|
||||
assert allowed is False
|
||||
@@ -375,7 +415,7 @@ class TestCheckRequest:
|
||||
|
||||
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(
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/node/node-a/api/send/", "Bearer tok_read"
|
||||
)
|
||||
assert allowed is False
|
||||
@@ -383,20 +423,22 @@ class TestCheckRequest:
|
||||
|
||||
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")
|
||||
allowed, status, msg, _result = 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(
|
||||
allowed, status, msg, _result = 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(
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/node/node-a/v1/api/send", "Bearer tok_read"
|
||||
)
|
||||
assert allowed is False
|
||||
@@ -404,14 +446,14 @@ class TestCheckRequest:
|
||||
|
||||
def test_proxy_v1_write_full_token_ok(self, enabled):
|
||||
"""Full tokens pass through v1 proxy write routes."""
|
||||
allowed, status, msg = check_request(
|
||||
allowed, status, msg, _result = 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(
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/node/node-a/v1/api/cluster/workstreams/new",
|
||||
@@ -422,25 +464,27 @@ class TestCheckRequest:
|
||||
|
||||
def test_proxy_read_endpoint_read_token_ok(self, enabled):
|
||||
"""Read tokens can access proxy read endpoints."""
|
||||
allowed, status, msg = check_request(
|
||||
allowed, status, msg, _result = 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(
|
||||
allowed, status, msg, _result = 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")
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/approve", "Bearer tok_full"
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_no_auth_header_string(self, enabled):
|
||||
allowed, status, msg = check_request(enabled, "GET", "/api/dashboard", "")
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/api/dashboard", "")
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
@@ -461,7 +505,7 @@ class TestCheckRequestWithCookie:
|
||||
)
|
||||
|
||||
def test_cookie_fallback_when_no_bearer(self, enabled):
|
||||
allowed, status, _ = check_request(
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
@@ -473,7 +517,7 @@ class TestCheckRequestWithCookie:
|
||||
|
||||
def test_bearer_takes_precedence_over_cookie(self, enabled):
|
||||
# Bearer is full, cookie is read — Bearer should win
|
||||
allowed, status, _ = check_request(
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/send",
|
||||
@@ -483,7 +527,7 @@ class TestCheckRequestWithCookie:
|
||||
assert allowed is True
|
||||
|
||||
def test_invalid_cookie_401(self, enabled):
|
||||
allowed, status, _ = check_request(
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
@@ -494,7 +538,7 @@ class TestCheckRequestWithCookie:
|
||||
assert status == 401
|
||||
|
||||
def test_cookie_read_on_write_403(self, enabled):
|
||||
allowed, status, _ = check_request(
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/send",
|
||||
@@ -505,7 +549,7 @@ class TestCheckRequestWithCookie:
|
||||
assert status == 403
|
||||
|
||||
def test_cookie_full_on_write_ok(self, enabled):
|
||||
allowed, status, _ = check_request(
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/send",
|
||||
@@ -515,7 +559,7 @@ class TestCheckRequestWithCookie:
|
||||
assert allowed is True
|
||||
|
||||
def test_no_cookie_no_bearer_401(self, enabled):
|
||||
allowed, status, _ = check_request(
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
@@ -526,7 +570,7 @@ class TestCheckRequestWithCookie:
|
||||
assert status == 401
|
||||
|
||||
def test_login_path_public(self, enabled):
|
||||
allowed, status, _ = check_request(
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
@@ -535,7 +579,7 @@ class TestCheckRequestWithCookie:
|
||||
assert allowed is True
|
||||
|
||||
def test_logout_path_public(self, enabled):
|
||||
allowed, status, _ = check_request(
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/auth/logout",
|
||||
@@ -552,12 +596,28 @@ class TestCheckRequestWithCookie:
|
||||
class TestLoadAuthConfig:
|
||||
"""Tests for load_auth_config with mocked config + env vars."""
|
||||
|
||||
def test_default_disabled(self):
|
||||
def test_default_enabled(self):
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is False
|
||||
assert cfg.enabled is True
|
||||
assert cfg.tokens == {}
|
||||
|
||||
def test_explicit_disable(self):
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={"enabled": False}),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_env_disable(self):
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "0"}, clear=True),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_config_file_tokens(self):
|
||||
mock_cfg = {
|
||||
"enabled": True,
|
||||
@@ -685,7 +745,7 @@ class TestServerAuth:
|
||||
srv_mod._metrics.model = "test-model"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.session_id = "test-session-id"
|
||||
mock_session.ws_id = "test-session-id"
|
||||
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.id = "test-ws"
|
||||
@@ -705,6 +765,7 @@ class TestServerAuth:
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
),
|
||||
cors_origins=["*"],
|
||||
)
|
||||
cls.client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@@ -902,7 +963,7 @@ class TestServerLogin:
|
||||
srv_mod._metrics.model = "test-model"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.session_id = "test-session-id"
|
||||
mock_session.ws_id = "test-session-id"
|
||||
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.id = "test-ws"
|
||||
@@ -1040,3 +1101,277 @@ class TestConsoleLogin:
|
||||
self.test_client.post("/v1/api/auth/logout")
|
||||
resp = self.test_client.get("/v1/api/cluster/overview")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Security hardening tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoginRateLimiter:
|
||||
def test_allows_under_limit(self):
|
||||
from turnstone.core.auth import LoginRateLimiter
|
||||
|
||||
limiter = LoginRateLimiter(max_attempts=3, window_seconds=60)
|
||||
for _ in range(3):
|
||||
ok, _ = limiter.check("ip:1.2.3.4")
|
||||
assert ok
|
||||
limiter.record("ip:1.2.3.4")
|
||||
# 4th should be blocked (3 recorded)
|
||||
ok, retry = limiter.check("ip:1.2.3.4")
|
||||
assert not ok
|
||||
assert retry > 0
|
||||
|
||||
def test_different_keys_independent(self):
|
||||
from turnstone.core.auth import LoginRateLimiter
|
||||
|
||||
limiter = LoginRateLimiter(max_attempts=2, window_seconds=60)
|
||||
limiter.record("ip:a")
|
||||
limiter.record("ip:a")
|
||||
ok_a, _ = limiter.check("ip:a")
|
||||
ok_b, _ = limiter.check("ip:b")
|
||||
assert not ok_a
|
||||
assert ok_b
|
||||
|
||||
def test_cleanup(self):
|
||||
from turnstone.core.auth import LoginRateLimiter
|
||||
|
||||
limiter = LoginRateLimiter(max_attempts=1, window_seconds=60)
|
||||
limiter.record("ip:old")
|
||||
removed = limiter.cleanup(max_age=0.0)
|
||||
assert removed == 1
|
||||
ok, _ = limiter.check("ip:old")
|
||||
assert ok
|
||||
|
||||
def test_max_keys_protection(self):
|
||||
from turnstone.core.auth import LoginRateLimiter
|
||||
|
||||
limiter = LoginRateLimiter(max_attempts=5, window_seconds=60)
|
||||
limiter.MAX_KEYS = 2
|
||||
limiter.record("a")
|
||||
limiter.record("b")
|
||||
limiter.record("c") # should be silently dropped (at capacity)
|
||||
assert "c" not in limiter._attempts
|
||||
|
||||
|
||||
class TestJWTAudienceIssuer:
|
||||
SECRET = "test-secret-that-is-at-least-32-chars"
|
||||
|
||||
def test_create_jwt_includes_iss(self):
|
||||
import jwt as pyjwt
|
||||
|
||||
from turnstone.core.auth import JWT_ISSUER, create_jwt
|
||||
|
||||
token = create_jwt("user1", frozenset({"read"}), "test", self.SECRET)
|
||||
payload = pyjwt.decode(
|
||||
token, self.SECRET, algorithms=["HS256"], options={"verify_aud": False}
|
||||
)
|
||||
assert payload["iss"] == JWT_ISSUER
|
||||
|
||||
def test_create_jwt_with_audience(self):
|
||||
import jwt as pyjwt
|
||||
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
|
||||
|
||||
token = create_jwt(
|
||||
"user1", frozenset({"read"}), "test", self.SECRET, audience=JWT_AUD_SERVER
|
||||
)
|
||||
payload = pyjwt.decode(token, self.SECRET, algorithms=["HS256"], audience=JWT_AUD_SERVER)
|
||||
assert payload["aud"] == JWT_AUD_SERVER
|
||||
|
||||
def test_validate_jwt_wrong_audience_rejected(self):
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, JWT_AUD_SERVER, create_jwt, validate_jwt
|
||||
|
||||
token = create_jwt(
|
||||
"user1", frozenset({"read"}), "test", self.SECRET, audience=JWT_AUD_SERVER
|
||||
)
|
||||
result = validate_jwt(token, self.SECRET, audience=JWT_AUD_CONSOLE)
|
||||
assert result is None
|
||||
|
||||
def test_validate_jwt_correct_audience_accepted(self):
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt, validate_jwt
|
||||
|
||||
token = create_jwt(
|
||||
"user1", frozenset({"read"}), "test", self.SECRET, audience=JWT_AUD_SERVER
|
||||
)
|
||||
result = validate_jwt(token, self.SECRET, audience=JWT_AUD_SERVER)
|
||||
assert result is not None
|
||||
assert result.user_id == "user1"
|
||||
|
||||
def test_validate_jwt_no_audience_backward_compat(self):
|
||||
from turnstone.core.auth import create_jwt, validate_jwt
|
||||
|
||||
# Token without aud claim should be accepted when audience="" (backward compat)
|
||||
token = create_jwt("user1", frozenset({"read"}), "test", self.SECRET)
|
||||
result = validate_jwt(token, self.SECRET, audience="")
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestServiceTokenManager:
|
||||
SECRET = "test-secret-that-is-at-least-32-chars"
|
||||
|
||||
def test_auto_mints_on_first_access(self):
|
||||
from turnstone.core.auth import ServiceTokenManager
|
||||
|
||||
mgr = ServiceTokenManager(
|
||||
user_id="svc",
|
||||
scopes=frozenset({"read"}),
|
||||
source="test",
|
||||
secret=self.SECRET,
|
||||
)
|
||||
token = mgr.token
|
||||
assert token # non-empty
|
||||
assert isinstance(token, str)
|
||||
|
||||
def test_bearer_header_format(self):
|
||||
from turnstone.core.auth import ServiceTokenManager
|
||||
|
||||
mgr = ServiceTokenManager(
|
||||
user_id="svc",
|
||||
scopes=frozenset({"read"}),
|
||||
source="test",
|
||||
secret=self.SECRET,
|
||||
)
|
||||
header = mgr.bearer_header
|
||||
assert "Authorization" in header
|
||||
assert header["Authorization"].startswith("Bearer ")
|
||||
|
||||
def test_token_stable_within_window(self):
|
||||
from turnstone.core.auth import ServiceTokenManager
|
||||
|
||||
mgr = ServiceTokenManager(
|
||||
user_id="svc",
|
||||
scopes=frozenset({"read"}),
|
||||
source="test",
|
||||
secret=self.SECRET,
|
||||
expiry_hours=1,
|
||||
)
|
||||
t1 = mgr.token
|
||||
t2 = mgr.token
|
||||
assert t1 == t2
|
||||
|
||||
def test_token_rotates_near_expiry(self):
|
||||
from turnstone.core.auth import ServiceTokenManager
|
||||
|
||||
mgr = ServiceTokenManager(
|
||||
user_id="svc",
|
||||
scopes=frozenset({"read"}),
|
||||
source="test",
|
||||
secret=self.SECRET,
|
||||
expiry_hours=1,
|
||||
)
|
||||
_ = mgr.token # initial mint
|
||||
# Simulate expiry by backdating _expires_at
|
||||
mgr._expires_at = 0.0
|
||||
t2 = mgr.token
|
||||
# Token was re-minted (even if payload matches within same second,
|
||||
# the internal state was refreshed)
|
||||
assert t2 # non-empty, valid token
|
||||
assert mgr._expires_at > 0.0 # was refreshed
|
||||
|
||||
def test_audience_included(self):
|
||||
import jwt as pyjwt
|
||||
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
|
||||
|
||||
mgr = ServiceTokenManager(
|
||||
user_id="svc",
|
||||
scopes=frozenset({"read"}),
|
||||
source="test",
|
||||
secret=self.SECRET,
|
||||
audience=JWT_AUD_SERVER,
|
||||
)
|
||||
payload = pyjwt.decode(
|
||||
mgr.token, self.SECRET, algorithms=["HS256"], audience=JWT_AUD_SERVER
|
||||
)
|
||||
assert payload["aud"] == JWT_AUD_SERVER
|
||||
|
||||
|
||||
class TestIsSecureRequest:
|
||||
def test_https_scheme(self):
|
||||
from turnstone.core.auth import is_secure_request
|
||||
|
||||
assert is_secure_request({}, scheme="https") is True
|
||||
|
||||
def test_http_scheme(self):
|
||||
from turnstone.core.auth import is_secure_request
|
||||
|
||||
assert is_secure_request({}, scheme="http") is False
|
||||
|
||||
def test_x_forwarded_proto_https(self):
|
||||
from turnstone.core.auth import is_secure_request
|
||||
|
||||
assert is_secure_request({"x-forwarded-proto": "https"}, scheme="http") is True
|
||||
|
||||
def test_x_forwarded_proto_http(self):
|
||||
from turnstone.core.auth import is_secure_request
|
||||
|
||||
assert is_secure_request({"x-forwarded-proto": "http"}, scheme="http") is False
|
||||
|
||||
|
||||
class TestSecretStrength:
|
||||
def test_short_secret_warns(self, caplog):
|
||||
import logging
|
||||
|
||||
from turnstone.core.auth import _MIN_SECRET_LENGTH
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="turnstone.core.auth"):
|
||||
import turnstone.core.auth as auth_mod
|
||||
|
||||
old = os.environ.get("TURNSTONE_JWT_SECRET", "")
|
||||
os.environ["TURNSTONE_JWT_SECRET"] = "short"
|
||||
try:
|
||||
secret = auth_mod.load_jwt_secret()
|
||||
assert secret == "short"
|
||||
assert any(str(_MIN_SECRET_LENGTH) in r.message for r in caplog.records)
|
||||
finally:
|
||||
if old:
|
||||
os.environ["TURNSTONE_JWT_SECRET"] = old
|
||||
else:
|
||||
os.environ.pop("TURNSTONE_JWT_SECRET", None)
|
||||
|
||||
|
||||
class TestCorsConfigurable:
|
||||
"""Verify CORS middleware is only added when origins are configured."""
|
||||
|
||||
def test_no_cors_origins_no_cors_headers(self):
|
||||
"""Without cors_origins, no Access-Control headers."""
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import turnstone.server as srv_mod
|
||||
|
||||
app = srv_mod.create_app(
|
||||
workstreams=MagicMock(),
|
||||
global_queue=queue.Queue(),
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(enabled=False),
|
||||
)
|
||||
client = TestClient(app)
|
||||
resp = client.get("/health", headers={"Origin": "http://evil.com"})
|
||||
assert "Access-Control-Allow-Origin" not in resp.headers
|
||||
client.close()
|
||||
|
||||
def test_cors_origins_set(self):
|
||||
"""With cors_origins, CORS headers are present."""
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import turnstone.server as srv_mod
|
||||
|
||||
app = srv_mod.create_app(
|
||||
workstreams=MagicMock(),
|
||||
global_queue=queue.Queue(),
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(enabled=False),
|
||||
cors_origins=["http://example.com"],
|
||||
)
|
||||
client = TestClient(app)
|
||||
resp = client.get(
|
||||
"/health",
|
||||
headers={"Origin": "http://example.com"},
|
||||
)
|
||||
assert resp.headers.get("Access-Control-Allow-Origin") == "http://example.com"
|
||||
client.close()
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Tests for user identity, API tokens, JWT, and scoped auth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.auth import (
|
||||
AuthConfig,
|
||||
AuthResult,
|
||||
_authenticate_token,
|
||||
check_request,
|
||||
create_jwt,
|
||||
generate_token,
|
||||
hash_password,
|
||||
hash_token,
|
||||
parse_scopes,
|
||||
required_scope,
|
||||
token_prefix,
|
||||
validate_jwt,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AuthResult
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthResult:
|
||||
def test_frozen(self):
|
||||
r = AuthResult(user_id="u1", scopes=frozenset({"read"}), token_source="config")
|
||||
with pytest.raises(AttributeError):
|
||||
r.user_id = "u2" # type: ignore[misc]
|
||||
|
||||
def test_has_scope(self):
|
||||
r = AuthResult(user_id="", scopes=frozenset({"read", "write"}), token_source="config")
|
||||
assert r.has_scope("read")
|
||||
assert r.has_scope("write")
|
||||
assert not r.has_scope("approve")
|
||||
|
||||
def test_empty_scopes(self):
|
||||
r = AuthResult(user_id="", scopes=frozenset(), token_source="config")
|
||||
assert not r.has_scope("read")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token generation and hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTokenHelpers:
|
||||
def test_generate_token_format(self):
|
||||
tok = generate_token()
|
||||
assert tok.startswith("ts_")
|
||||
assert len(tok) == 3 + 64 # ts_ + 64 hex chars
|
||||
|
||||
def test_generate_token_unique(self):
|
||||
tokens = {generate_token() for _ in range(10)}
|
||||
assert len(tokens) == 10
|
||||
|
||||
def test_hash_token_deterministic(self):
|
||||
assert hash_token("ts_abc") == hash_token("ts_abc")
|
||||
|
||||
def test_hash_token_hex(self):
|
||||
h = hash_token("test")
|
||||
assert len(h) == 64 # SHA-256 hex
|
||||
int(h, 16) # valid hex
|
||||
|
||||
def test_token_prefix(self):
|
||||
assert token_prefix("ts_abcdefgh1234") == "ts_abcde"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password hashing (bcrypt)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
def test_hash_and_verify(self):
|
||||
pw = "hunter2"
|
||||
hashed = hash_password(pw)
|
||||
assert verify_password(pw, hashed)
|
||||
|
||||
def test_wrong_password(self):
|
||||
hashed = hash_password("correct")
|
||||
assert not verify_password("wrong", hashed)
|
||||
|
||||
def test_hash_is_different_each_time(self):
|
||||
h1 = hash_password("same")
|
||||
h2 = hash_password("same")
|
||||
assert h1 != h2 # different salts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scope parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseScopes:
|
||||
def test_single_scope(self):
|
||||
assert parse_scopes("read") == frozenset({"read"})
|
||||
|
||||
def test_hierarchy_write(self):
|
||||
assert parse_scopes("write") == frozenset({"read", "write"})
|
||||
|
||||
def test_hierarchy_approve(self):
|
||||
assert parse_scopes("approve") == frozenset({"read", "write", "approve"})
|
||||
|
||||
def test_comma_separated(self):
|
||||
assert parse_scopes("read,write") == frozenset({"read", "write"})
|
||||
|
||||
def test_redundant_scopes(self):
|
||||
# approve already includes read,write
|
||||
assert parse_scopes("read,approve") == frozenset({"read", "write", "approve"})
|
||||
|
||||
def test_empty_string(self):
|
||||
assert parse_scopes("") == frozenset()
|
||||
|
||||
def test_invalid_scope_filtered(self):
|
||||
assert parse_scopes("bogus") == frozenset()
|
||||
|
||||
def test_mixed_valid_invalid(self):
|
||||
assert parse_scopes("read,bogus,approve") == frozenset({"read", "write", "approve"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWT create / validate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestJWT:
|
||||
SECRET = "test-secret-key-for-jwt"
|
||||
|
||||
def test_round_trip(self):
|
||||
scopes = frozenset({"read", "write"})
|
||||
token = create_jwt("user123", scopes, "database", self.SECRET, expiry_hours=1)
|
||||
result = validate_jwt(token, self.SECRET)
|
||||
assert result is not None
|
||||
assert result.user_id == "user123"
|
||||
assert result.scopes == frozenset({"read", "write"})
|
||||
|
||||
def test_expired_token(self):
|
||||
import jwt
|
||||
|
||||
payload = {
|
||||
"sub": "user1",
|
||||
"scopes": "read",
|
||||
"src": "database",
|
||||
"iat": int(time.time()) - 7200,
|
||||
"exp": int(time.time()) - 3600,
|
||||
}
|
||||
token = jwt.encode(payload, self.SECRET, algorithm="HS256")
|
||||
assert validate_jwt(token, self.SECRET) is None
|
||||
|
||||
def test_invalid_signature(self):
|
||||
token = create_jwt("user1", frozenset({"read"}), "db", self.SECRET)
|
||||
assert validate_jwt(token, "wrong-secret") is None
|
||||
|
||||
def test_malformed_token(self):
|
||||
assert validate_jwt("not.a.jwt", self.SECRET) is None
|
||||
|
||||
def test_contains_dots(self):
|
||||
"""JWTs contain dots, used for detection."""
|
||||
token = create_jwt("u1", frozenset({"read"}), "db", self.SECRET)
|
||||
assert "." in token
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# required_scope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRequiredScope:
|
||||
def test_get_read(self):
|
||||
assert required_scope("GET", "/api/workstreams") == "read"
|
||||
|
||||
def test_post_write(self):
|
||||
assert required_scope("POST", "/api/send") == "write"
|
||||
|
||||
def test_post_approve(self):
|
||||
assert required_scope("POST", "/api/approve") == "approve"
|
||||
|
||||
def test_admin_prefix(self):
|
||||
assert required_scope("GET", "/api/admin/users") == "approve"
|
||||
assert required_scope("POST", "/api/admin/users") == "approve"
|
||||
assert required_scope("DELETE", "/api/admin/users/abc") == "approve"
|
||||
|
||||
def test_versioned_path(self):
|
||||
assert required_scope("POST", "/v1/api/send") == "write"
|
||||
assert required_scope("POST", "/v1/api/approve") == "approve"
|
||||
|
||||
def test_proxy_write(self):
|
||||
assert required_scope("POST", "/node/n1/api/send") == "write"
|
||||
|
||||
def test_proxy_approve(self):
|
||||
assert required_scope("POST", "/node/n1/api/approve") == "approve"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _authenticate_token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthenticateToken:
|
||||
def test_config_token_read(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
result = _authenticate_token("tok_read", cfg)
|
||||
assert result is not None
|
||||
assert result.scopes == frozenset({"read"})
|
||||
assert result.token_source == "config"
|
||||
|
||||
def test_config_token_full(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
result = _authenticate_token("tok_full", cfg)
|
||||
assert result is not None
|
||||
assert result.scopes == frozenset({"read", "write", "approve"})
|
||||
|
||||
def test_jwt_token(self):
|
||||
secret = "test-secret"
|
||||
jwt_tok = create_jwt("user1", frozenset({"read", "write"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(jwt_tok, cfg, jwt_secret=secret)
|
||||
assert result is not None
|
||||
assert result.user_id == "user1"
|
||||
assert result.token_source == "db"
|
||||
|
||||
def test_api_token_with_storage(self):
|
||||
"""API tokens are looked up by hash in storage."""
|
||||
raw = generate_token()
|
||||
|
||||
class MockStorage:
|
||||
def get_api_token_by_hash(self, token_hash):
|
||||
expected = hash_token(raw)
|
||||
if token_hash == expected:
|
||||
return {
|
||||
"token_id": "tid",
|
||||
"token_prefix": "ts_abcde",
|
||||
"user_id": "user1",
|
||||
"name": "test",
|
||||
"scopes": "read,write",
|
||||
"created": "2026-01-01T00:00:00",
|
||||
}
|
||||
return None
|
||||
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(raw, cfg, storage=MockStorage())
|
||||
assert result is not None
|
||||
assert result.user_id == "user1"
|
||||
assert result.has_scope("write")
|
||||
assert result.token_source == "database"
|
||||
|
||||
def test_api_token_expired(self):
|
||||
"""Expired API tokens are rejected."""
|
||||
raw = generate_token()
|
||||
|
||||
class MockStorage:
|
||||
def get_api_token_by_hash(self, token_hash):
|
||||
return {
|
||||
"token_id": "tid",
|
||||
"token_prefix": "ts_abcde",
|
||||
"user_id": "user1",
|
||||
"name": "test",
|
||||
"scopes": "read",
|
||||
"created": "2020-01-01T00:00:00",
|
||||
"expires": "2020-01-02T00:00:00",
|
||||
}
|
||||
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(raw, cfg, storage=MockStorage())
|
||||
assert result is None
|
||||
|
||||
def test_unknown_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok": "full"})
|
||||
result = _authenticate_token("unknown", cfg)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_request with scopes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckRequestScopes:
|
||||
def test_config_read_on_write_403(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
allowed, status, msg, _ = check_request(cfg, "POST", "/api/send", "Bearer tok_read")
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
assert "write" in msg
|
||||
|
||||
def test_config_read_on_approve_403(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
allowed, status, msg, _ = check_request(cfg, "POST", "/api/approve", "Bearer tok_read")
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
assert "approve" in msg
|
||||
|
||||
def test_config_full_on_approve_ok(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
allowed, status, msg, result = check_request(cfg, "POST", "/api/approve", "Bearer tok_full")
|
||||
assert allowed
|
||||
assert result is not None
|
||||
assert result.has_scope("approve")
|
||||
|
||||
def test_jwt_with_scopes(self):
|
||||
secret = "test"
|
||||
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
allowed, status, msg, result = check_request(
|
||||
cfg,
|
||||
"POST",
|
||||
"/api/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=secret,
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
assert result.user_id == "u1"
|
||||
|
||||
def test_jwt_insufficient_scope(self):
|
||||
secret = "test"
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
allowed, status, msg, _ = check_request(
|
||||
cfg,
|
||||
"POST",
|
||||
"/api/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=secret,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
|
||||
def test_admin_path_requires_approve(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
allowed, status, msg, _ = check_request(
|
||||
cfg,
|
||||
"GET",
|
||||
"/v1/api/admin/users",
|
||||
"Bearer tok_read",
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
|
||||
def test_backward_compat_role_full(self):
|
||||
"""Config tokens with role='full' get all scopes."""
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
allowed, _, _, result = check_request(
|
||||
cfg,
|
||||
"GET",
|
||||
"/v1/api/admin/users",
|
||||
"Bearer tok_full",
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
assert result.has_scope("approve")
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Tests for turnstone.core.bm25 — tokenizer and BM25 index."""
|
||||
|
||||
from turnstone.core.bm25 import BM25Index, _tokenize
|
||||
|
||||
|
||||
class TestTokenize:
|
||||
def test_simple_words(self):
|
||||
assert _tokenize("hello world") == ["hello", "world"]
|
||||
|
||||
def test_underscores(self):
|
||||
assert _tokenize("read_file") == ["read", "file"]
|
||||
|
||||
def test_hyphens(self):
|
||||
assert _tokenize("web-search") == ["web", "search"]
|
||||
|
||||
def test_dots(self):
|
||||
assert _tokenize("foo.bar.baz") == ["foo", "bar", "baz"]
|
||||
|
||||
def test_mixed_separators(self):
|
||||
assert _tokenize("mcp__server__read_file") == ["mcp", "server", "read", "file"]
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _tokenize("") == []
|
||||
|
||||
def test_case_folding(self):
|
||||
assert _tokenize("Hello World") == ["hello", "world"]
|
||||
|
||||
|
||||
class TestBM25Index:
|
||||
def test_search_returns_relevant(self):
|
||||
docs = ["read a file from disk", "search for file in directory", "execute a bash command"]
|
||||
index = BM25Index(docs)
|
||||
results = index.search("file", k=2)
|
||||
assert 0 in results
|
||||
assert 1 in results
|
||||
|
||||
def test_search_empty_query(self):
|
||||
docs = ["hello world"]
|
||||
index = BM25Index(docs)
|
||||
assert index.search("") == []
|
||||
|
||||
def test_search_no_match(self):
|
||||
docs = ["hello world", "foo bar"]
|
||||
index = BM25Index(docs)
|
||||
assert index.search("zzzznotfound") == []
|
||||
|
||||
def test_search_respects_k(self):
|
||||
docs = [f"document {i} with common word" for i in range(20)]
|
||||
index = BM25Index(docs)
|
||||
results = index.search("common", k=3)
|
||||
assert len(results) <= 3
|
||||
|
||||
def test_empty_corpus(self):
|
||||
index = BM25Index([])
|
||||
assert index.search("anything") == []
|
||||
|
||||
def test_single_document(self):
|
||||
index = BM25Index(["the only document about turnstone"])
|
||||
results = index.search("turnstone")
|
||||
assert results == [0]
|
||||
|
||||
def test_ordering_by_relevance(self):
|
||||
docs = [
|
||||
"unrelated content about cooking recipes",
|
||||
"python programming with file operations",
|
||||
"read file write file file operations disk io",
|
||||
]
|
||||
index = BM25Index(docs)
|
||||
results = index.search("file operations", k=3)
|
||||
# Doc 2 has more file/operations mentions, should rank higher
|
||||
assert results[0] == 2
|
||||
@@ -0,0 +1,630 @@
|
||||
"""Tests for the bootstrap wizard module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from turnstone.bootstrap import (
|
||||
SYSTEM_PROMPT,
|
||||
TOOLS,
|
||||
_BootstrapLLM,
|
||||
_FinishError,
|
||||
_mask_secrets,
|
||||
_tool_check_docker,
|
||||
_tool_check_port,
|
||||
_tool_finish,
|
||||
_tool_generate_secret,
|
||||
_tool_read_file,
|
||||
_tool_validate_api_key,
|
||||
_tool_write_file,
|
||||
execute_tool,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool function tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReadFile:
|
||||
def test_existing_file(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.txt"
|
||||
f.write_text("hello world")
|
||||
result = _tool_read_file(tmp_path, {"path": "test.txt"})
|
||||
assert result == "hello world"
|
||||
|
||||
def test_missing_file(self, tmp_path: Path) -> None:
|
||||
result = _tool_read_file(tmp_path, {"path": "nope.txt"})
|
||||
assert "Error: file not found" in result
|
||||
|
||||
def test_nested_path(self, tmp_path: Path) -> None:
|
||||
sub = tmp_path / "sub"
|
||||
sub.mkdir()
|
||||
f = sub / "nested.txt"
|
||||
f.write_text("nested content")
|
||||
result = _tool_read_file(tmp_path, {"path": "sub/nested.txt"})
|
||||
assert result == "nested content"
|
||||
|
||||
def test_path_traversal_blocked(self, tmp_path: Path) -> None:
|
||||
result = _tool_read_file(tmp_path, {"path": "../../etc/passwd"})
|
||||
assert "escapes project directory" in result
|
||||
|
||||
def test_absolute_path_blocked(self, tmp_path: Path) -> None:
|
||||
result = _tool_read_file(tmp_path, {"path": "/etc/passwd"})
|
||||
assert "escapes project directory" in result
|
||||
|
||||
|
||||
class TestWriteFile:
|
||||
def test_write_confirmed(self, tmp_path: Path) -> None:
|
||||
with patch("builtins.input", return_value="y"):
|
||||
result = _tool_write_file(tmp_path, {"path": "out.txt", "content": "data\n"})
|
||||
assert "written successfully" in result
|
||||
assert (tmp_path / "out.txt").read_text() == "data\n"
|
||||
|
||||
def test_write_declined(self, tmp_path: Path) -> None:
|
||||
with patch("builtins.input", return_value="n"):
|
||||
result = _tool_write_file(tmp_path, {"path": "out.txt", "content": "data\n"})
|
||||
assert "declined" in result
|
||||
assert not (tmp_path / "out.txt").exists()
|
||||
|
||||
def test_write_creates_parent_dirs(self, tmp_path: Path) -> None:
|
||||
with patch("builtins.input", return_value="y"):
|
||||
result = _tool_write_file(tmp_path, {"path": "a/b/c.txt", "content": "deep\n"})
|
||||
assert "written successfully" in result
|
||||
assert (tmp_path / "a" / "b" / "c.txt").read_text() == "deep\n"
|
||||
|
||||
def test_sh_files_are_executable(self, tmp_path: Path) -> None:
|
||||
with patch("builtins.input", return_value="y"):
|
||||
_tool_write_file(tmp_path, {"path": "setup.sh", "content": "#!/bin/bash\n"})
|
||||
mode = (tmp_path / "setup.sh").stat().st_mode
|
||||
assert mode & 0o110 # user + group executable, not world
|
||||
|
||||
def test_path_traversal_blocked(self, tmp_path: Path) -> None:
|
||||
result = _tool_write_file(tmp_path, {"path": "../../escape.txt", "content": "bad\n"})
|
||||
assert "escapes project directory" in result
|
||||
|
||||
def test_default_enter_confirms(self, tmp_path: Path) -> None:
|
||||
with patch("builtins.input", return_value=""):
|
||||
result = _tool_write_file(tmp_path, {"path": "ok.txt", "content": "ok\n"})
|
||||
assert "written successfully" in result
|
||||
|
||||
def test_duplicate_write_skipped(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "dup.txt").write_text("same\n")
|
||||
result = _tool_write_file(tmp_path, {"path": "dup.txt", "content": "same\n"})
|
||||
assert "already exists" in result
|
||||
|
||||
def test_different_content_still_prompts(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "changed.txt").write_text("old\n")
|
||||
with patch("builtins.input", return_value="y"):
|
||||
result = _tool_write_file(tmp_path, {"path": "changed.txt", "content": "new\n"})
|
||||
assert "written successfully" in result
|
||||
assert (tmp_path / "changed.txt").read_text() == "new\n"
|
||||
|
||||
|
||||
class TestGenerateSecret:
|
||||
def test_default_length(self) -> None:
|
||||
secret = _tool_generate_secret({})
|
||||
assert len(secret) == 64 # 32 bytes -> 64 hex chars
|
||||
|
||||
def test_custom_length(self) -> None:
|
||||
secret = _tool_generate_secret({"length": 16})
|
||||
assert len(secret) == 32
|
||||
|
||||
def test_uniqueness(self) -> None:
|
||||
s1 = _tool_generate_secret({})
|
||||
s2 = _tool_generate_secret({})
|
||||
assert s1 != s2
|
||||
|
||||
def test_invalid_length_fallback(self) -> None:
|
||||
secret = _tool_generate_secret({"length": -1})
|
||||
assert len(secret) == 64 # falls back to 32 bytes
|
||||
|
||||
def test_excessive_length_capped(self) -> None:
|
||||
secret = _tool_generate_secret({"length": 99999})
|
||||
assert len(secret) == 64 # falls back to 32 bytes
|
||||
|
||||
|
||||
class TestCheckPort:
|
||||
def test_available_port(self) -> None:
|
||||
# Pick a random high port that's likely free
|
||||
result = _tool_check_port({"port": 59123})
|
||||
assert "AVAILABLE" in result or "IN USE" in result
|
||||
|
||||
def test_in_use_port(self) -> None:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.listen(1)
|
||||
result = _tool_check_port({"port": port})
|
||||
assert "IN USE" in result
|
||||
|
||||
def test_invalid_port(self) -> None:
|
||||
result = _tool_check_port({"port": -1})
|
||||
assert "Error" in result
|
||||
|
||||
def test_port_zero(self) -> None:
|
||||
result = _tool_check_port({"port": 0})
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
class TestCheckDocker:
|
||||
def test_docker_installed(self) -> None:
|
||||
mock_docker = MagicMock()
|
||||
mock_docker.returncode = 0
|
||||
mock_docker.stdout = "24.0.7"
|
||||
|
||||
mock_compose = MagicMock()
|
||||
mock_compose.returncode = 0
|
||||
mock_compose.stdout = "2.24.5"
|
||||
|
||||
with patch("subprocess.run", side_effect=[mock_docker, mock_compose]):
|
||||
result = _tool_check_docker({})
|
||||
assert "Docker: installed" in result
|
||||
assert "Docker Compose: installed" in result
|
||||
|
||||
def test_docker_not_installed(self) -> None:
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError):
|
||||
result = _tool_check_docker({})
|
||||
assert "NOT installed" in result or "NOT available" in result
|
||||
|
||||
def test_docker_daemon_not_running(self) -> None:
|
||||
mock_docker = MagicMock()
|
||||
mock_docker.returncode = 1
|
||||
mock_docker.stderr = "Cannot connect to the Docker daemon"
|
||||
|
||||
mock_compose = MagicMock()
|
||||
mock_compose.returncode = 1
|
||||
|
||||
with patch("subprocess.run", side_effect=[mock_docker, mock_compose]):
|
||||
result = _tool_check_docker({})
|
||||
assert "NOT running" in result
|
||||
|
||||
|
||||
class TestValidateApiKey:
|
||||
def test_openai_success(self) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.models.list.return_value = []
|
||||
with patch("openai.OpenAI", return_value=mock_client):
|
||||
result = _tool_validate_api_key({"provider": "openai", "api_key": "sk-test"})
|
||||
assert "Success" in result
|
||||
|
||||
def test_openai_failure(self) -> None:
|
||||
with patch("openai.OpenAI") as mock_cls:
|
||||
mock_cls.return_value.models.list.side_effect = Exception("Invalid key")
|
||||
result = _tool_validate_api_key({"provider": "openai", "api_key": "bad"})
|
||||
assert "Failed" in result
|
||||
|
||||
def test_unknown_provider(self) -> None:
|
||||
result = _tool_validate_api_key({"provider": "unknown", "api_key": "x"})
|
||||
assert "unknown" in result
|
||||
|
||||
|
||||
class TestExecuteTool:
|
||||
def test_unknown_tool(self, tmp_path: Path) -> None:
|
||||
result = execute_tool("nonexistent", {}, tmp_path)
|
||||
assert "unknown tool" in result
|
||||
|
||||
def test_dispatches_correctly(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "hello.txt"
|
||||
f.write_text("hi")
|
||||
result = execute_tool("read_file", {"path": "hello.txt"}, tmp_path)
|
||||
assert result == "hi"
|
||||
|
||||
def test_finish_raises(self, tmp_path: Path) -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(_FinishError, match="All done"):
|
||||
execute_tool("finish", {"summary": "All done"}, tmp_path)
|
||||
|
||||
|
||||
class TestFinishTool:
|
||||
def test_raises_with_summary(self) -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(_FinishError) as exc_info:
|
||||
_tool_finish({"summary": "Configured production deployment."})
|
||||
assert exc_info.value.summary == "Configured production deployment."
|
||||
|
||||
def test_default_summary(self) -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(_FinishError) as exc_info:
|
||||
_tool_finish({})
|
||||
assert exc_info.value.summary == "Setup complete."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secret masking tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMaskSecrets:
|
||||
def test_masks_api_key(self) -> None:
|
||||
text = "OPENAI_API_KEY=sk-1234567890abcdef"
|
||||
result = _mask_secrets(text)
|
||||
assert "sk-1" in result
|
||||
assert "cdef" in result
|
||||
assert "1234567890abcde" not in result
|
||||
|
||||
def test_preserves_comments(self) -> None:
|
||||
text = "# OPENAI_API_KEY=sk-1234567890abcdef"
|
||||
result = _mask_secrets(text)
|
||||
assert result == text
|
||||
|
||||
def test_preserves_short_values(self) -> None:
|
||||
text = "TOKEN=short"
|
||||
result = _mask_secrets(text)
|
||||
assert result == text
|
||||
|
||||
def test_preserves_non_sensitive(self) -> None:
|
||||
text = "MODEL=gpt-5.4"
|
||||
result = _mask_secrets(text)
|
||||
assert result == text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message conversion tests (Anthropic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAnthropicConversion:
|
||||
"""Test the Anthropic message/tool conversion inside _BootstrapLLM."""
|
||||
|
||||
def _make_llm(self) -> _BootstrapLLM:
|
||||
return _BootstrapLLM("anthropic", MagicMock(), "test-model")
|
||||
|
||||
def test_tool_format_conversion(self) -> None:
|
||||
"""OpenAI tool format should convert to Anthropic format."""
|
||||
llm = self._make_llm()
|
||||
# The conversion happens inside _complete_anthropic; we test indirectly
|
||||
# by checking the tools passed to the mock client
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = [MagicMock(type="text", text="hello")]
|
||||
mock_response.stop_reason = "end_turn"
|
||||
llm.client.messages.create.return_value = mock_response
|
||||
|
||||
llm.complete(
|
||||
[{"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}],
|
||||
TOOLS[:1], # Just read_file
|
||||
)
|
||||
|
||||
call_kwargs = llm.client.messages.create.call_args[1]
|
||||
api_tools = call_kwargs["tools"]
|
||||
assert len(api_tools) == 1
|
||||
assert api_tools[0]["name"] == "read_file"
|
||||
assert "input_schema" in api_tools[0]
|
||||
assert "description" in api_tools[0]
|
||||
|
||||
def test_system_message_extraction(self) -> None:
|
||||
"""System message should be extracted to system parameter."""
|
||||
llm = self._make_llm()
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = [MagicMock(type="text", text="ok")]
|
||||
mock_response.stop_reason = "end_turn"
|
||||
llm.client.messages.create.return_value = mock_response
|
||||
|
||||
llm.complete(
|
||||
[{"role": "system", "content": "test system"}, {"role": "user", "content": "hi"}],
|
||||
[],
|
||||
)
|
||||
|
||||
call_kwargs = llm.client.messages.create.call_args[1]
|
||||
assert call_kwargs["system"] == "test system"
|
||||
# System should NOT appear in messages
|
||||
for msg in call_kwargs["messages"]:
|
||||
assert msg["role"] != "system"
|
||||
|
||||
def test_tool_result_conversion(self) -> None:
|
||||
"""OpenAI tool result messages should convert to Anthropic format."""
|
||||
llm = self._make_llm()
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = [MagicMock(type="text", text="got it")]
|
||||
mock_response.stop_reason = "end_turn"
|
||||
llm.client.messages.create.return_value = mock_response
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc_1",
|
||||
"type": "function",
|
||||
"function": {"name": "check_docker", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc_1",
|
||||
"content": "Docker: installed",
|
||||
},
|
||||
]
|
||||
llm.complete(messages, TOOLS)
|
||||
|
||||
call_kwargs = llm.client.messages.create.call_args[1]
|
||||
api_messages = call_kwargs["messages"]
|
||||
|
||||
# Find the tool_result message
|
||||
tool_result_found = False
|
||||
for msg in api_messages:
|
||||
if msg["role"] == "user" and isinstance(msg.get("content"), list):
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
assert block["tool_use_id"] == "tc_1"
|
||||
assert block["content"] == "Docker: installed"
|
||||
tool_result_found = True
|
||||
assert tool_result_found
|
||||
|
||||
def test_tool_use_blocks_in_assistant(self) -> None:
|
||||
"""Assistant messages with tool_calls should convert to content blocks."""
|
||||
llm = self._make_llm()
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = [MagicMock(type="text", text="ok")]
|
||||
mock_response.stop_reason = "end_turn"
|
||||
llm.client.messages.create.return_value = mock_response
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Let me check",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc_1",
|
||||
"type": "function",
|
||||
"function": {"name": "check_docker", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tc_1", "content": "ok"},
|
||||
]
|
||||
llm.complete(messages, TOOLS)
|
||||
|
||||
call_kwargs = llm.client.messages.create.call_args[1]
|
||||
api_messages = call_kwargs["messages"]
|
||||
|
||||
# First message should be user "hi"
|
||||
assert api_messages[0]["role"] == "user"
|
||||
# Second should be assistant with content blocks
|
||||
assistant_msg = api_messages[1]
|
||||
assert assistant_msg["role"] == "assistant"
|
||||
assert isinstance(assistant_msg["content"], list)
|
||||
# Should have text block + tool_use block
|
||||
types = [b["type"] for b in assistant_msg["content"]]
|
||||
assert "text" in types
|
||||
assert "tool_use" in types
|
||||
|
||||
|
||||
class TestOpenAICompletion:
|
||||
"""Test the OpenAI path of _BootstrapLLM."""
|
||||
|
||||
def test_text_response(self) -> None:
|
||||
llm = _BootstrapLLM("openai", MagicMock(), "gpt-5.4")
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = "Hello!"
|
||||
mock_choice.message.tool_calls = None
|
||||
mock_choice.finish_reason = "stop"
|
||||
llm.client.chat.completions.create.return_value = MagicMock(choices=[mock_choice])
|
||||
|
||||
content, tool_calls, reason = llm.complete([{"role": "user", "content": "hi"}], TOOLS)
|
||||
assert content == "Hello!"
|
||||
assert tool_calls is None
|
||||
assert reason == "stop"
|
||||
|
||||
def test_tool_call_response(self) -> None:
|
||||
llm = _BootstrapLLM("openai", MagicMock(), "gpt-5.4")
|
||||
|
||||
mock_tc = MagicMock()
|
||||
mock_tc.id = "call_123"
|
||||
mock_tc.function.name = "check_docker"
|
||||
mock_tc.function.arguments = "{}"
|
||||
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = ""
|
||||
mock_choice.message.tool_calls = [mock_tc]
|
||||
mock_choice.finish_reason = "tool_calls"
|
||||
llm.client.chat.completions.create.return_value = MagicMock(choices=[mock_choice])
|
||||
|
||||
content, tool_calls, reason = llm.complete(
|
||||
[{"role": "user", "content": "check docker"}], TOOLS
|
||||
)
|
||||
assert tool_calls is not None
|
||||
assert len(tool_calls) == 1
|
||||
assert tool_calls[0]["function"]["name"] == "check_docker"
|
||||
assert tool_calls[0]["id"] == "call_123"
|
||||
|
||||
def test_no_content(self) -> None:
|
||||
llm = _BootstrapLLM("openai", MagicMock(), "gpt-5.4")
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = None
|
||||
mock_choice.message.tool_calls = None
|
||||
mock_choice.finish_reason = "stop"
|
||||
llm.client.chat.completions.create.return_value = MagicMock(choices=[mock_choice])
|
||||
|
||||
content, tool_calls, reason = llm.complete([{"role": "user", "content": "hi"}], [])
|
||||
assert content == ""
|
||||
assert tool_calls is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conversation loop tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConversationLoop:
|
||||
def test_quit_exits(self) -> None:
|
||||
"""User typing 'quit' should exit the loop."""
|
||||
llm = MagicMock(spec=_BootstrapLLM)
|
||||
llm.complete.return_value = ("What would you like?", None, "stop")
|
||||
|
||||
with patch("builtins.input", return_value="quit"):
|
||||
from turnstone.bootstrap import _run_conversation
|
||||
|
||||
_run_conversation(llm, Path("/tmp"))
|
||||
|
||||
def test_tool_calls_executed(self, tmp_path: Path) -> None:
|
||||
"""Tool calls should be executed and results fed back."""
|
||||
llm = MagicMock(spec=_BootstrapLLM)
|
||||
# First call: LLM returns a tool call
|
||||
llm.complete.side_effect = [
|
||||
(
|
||||
"",
|
||||
[
|
||||
{
|
||||
"id": "tc_1",
|
||||
"type": "function",
|
||||
"function": {"name": "generate_secret", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
"tool_calls",
|
||||
),
|
||||
# Second call: LLM responds with text after seeing tool result
|
||||
("Here's your secret!", None, "stop"),
|
||||
]
|
||||
|
||||
with patch("builtins.input", return_value="quit"):
|
||||
from turnstone.bootstrap import _run_conversation
|
||||
|
||||
_run_conversation(llm, tmp_path)
|
||||
|
||||
# Verify two calls were made
|
||||
assert llm.complete.call_count == 2
|
||||
# Verify tool result was fed back in second call's messages
|
||||
second_call_messages = llm.complete.call_args_list[1][0][0]
|
||||
tool_results = [m for m in second_call_messages if m.get("role") == "tool"]
|
||||
assert len(tool_results) == 1
|
||||
assert tool_results[0]["tool_call_id"] == "tc_1"
|
||||
# Result should be a 64-char hex string
|
||||
assert len(tool_results[0]["content"]) == 64
|
||||
|
||||
def test_empty_input_skipped(self) -> None:
|
||||
"""Empty user input should be skipped."""
|
||||
llm = MagicMock(spec=_BootstrapLLM)
|
||||
llm.complete.return_value = ("Ask me something.", None, "stop")
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_input(prompt: str = "") -> str:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count <= 2:
|
||||
return "" # Empty inputs
|
||||
return "quit"
|
||||
|
||||
with patch("builtins.input", side_effect=mock_input):
|
||||
from turnstone.bootstrap import _run_conversation
|
||||
|
||||
_run_conversation(llm, Path("/tmp"))
|
||||
|
||||
def test_finish_tool_exits_loop(self, tmp_path: Path) -> None:
|
||||
"""LLM calling finish tool should exit the conversation cleanly."""
|
||||
llm = MagicMock(spec=_BootstrapLLM)
|
||||
llm.complete.return_value = (
|
||||
"",
|
||||
[
|
||||
{
|
||||
"id": "tc_fin",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "finish",
|
||||
"arguments": '{"summary": "All configured."}',
|
||||
},
|
||||
}
|
||||
],
|
||||
"tool_calls",
|
||||
)
|
||||
|
||||
from turnstone.bootstrap import _run_conversation
|
||||
|
||||
# Should return without needing user input
|
||||
_run_conversation(llm, tmp_path)
|
||||
assert llm.complete.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive startup tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProviderDefaults:
|
||||
def test_openai_default_model(self) -> None:
|
||||
from turnstone.bootstrap import _DEFAULT_MODELS
|
||||
|
||||
assert _DEFAULT_MODELS["openai"] == "gpt-5.4"
|
||||
|
||||
def test_anthropic_default_model(self) -> None:
|
||||
from turnstone.bootstrap import _DEFAULT_MODELS
|
||||
|
||||
assert _DEFAULT_MODELS["anthropic"] == "claude-sonnet-4-6"
|
||||
|
||||
|
||||
class TestSelectProvider:
|
||||
def test_openai_selection(self) -> None:
|
||||
"""Selecting '1' should set up OpenAI."""
|
||||
mock_client = MagicMock()
|
||||
with (
|
||||
patch("builtins.input", side_effect=["1", ""]),
|
||||
patch("getpass.getpass", return_value="sk-test"),
|
||||
patch("openai.OpenAI", return_value=mock_client),
|
||||
):
|
||||
from turnstone.bootstrap import _select_provider
|
||||
|
||||
provider, client, model = _select_provider()
|
||||
assert provider == "openai"
|
||||
assert model == "gpt-5.4"
|
||||
|
||||
def test_local_selection(self) -> None:
|
||||
"""Selecting '3' should set up local/vLLM."""
|
||||
mock_client = MagicMock()
|
||||
# Ensure OPENAI_API_KEY is not in env so we hit the getpass path
|
||||
env = {k: v for k, v in os.environ.items() if k != "OPENAI_API_KEY"}
|
||||
with (
|
||||
patch.dict("os.environ", env, clear=True),
|
||||
patch("builtins.input", side_effect=["3", "http://localhost:8000/v1", "my-model"]),
|
||||
patch("getpass.getpass", return_value="none"),
|
||||
patch("openai.OpenAI", return_value=mock_client),
|
||||
):
|
||||
from turnstone.bootstrap import _select_provider
|
||||
|
||||
provider, client, model = _select_provider()
|
||||
assert provider == "openai"
|
||||
assert model == "my-model"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System prompt and tools sanity checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_system_prompt_not_empty(self) -> None:
|
||||
assert len(SYSTEM_PROMPT) > 500
|
||||
|
||||
def test_system_prompt_mentions_turnstone(self) -> None:
|
||||
assert "Turnstone" in SYSTEM_PROMPT
|
||||
|
||||
def test_all_tools_have_required_fields(self) -> None:
|
||||
for tool in TOOLS:
|
||||
assert tool["type"] == "function"
|
||||
func = tool["function"]
|
||||
assert "name" in func
|
||||
assert "description" in func
|
||||
assert "parameters" in func
|
||||
assert func["parameters"]["type"] == "object"
|
||||
|
||||
def test_tool_count(self) -> None:
|
||||
assert len(TOOLS) == 7
|
||||
|
||||
def test_all_tools_have_implementations(self) -> None:
|
||||
from turnstone.bootstrap import TOOL_FUNCTIONS
|
||||
|
||||
for tool in TOOLS:
|
||||
name = tool["function"]["name"]
|
||||
assert name in TOOL_FUNCTIONS, f"Missing implementation for tool: {name}"
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for bridge event publishing — TurnCompleteEvent on idle transitions."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from turnstone.mq.bridge import Bridge
|
||||
from turnstone.mq.protocol import StateChangeEvent, TurnCompleteEvent
|
||||
|
||||
|
||||
def _make_bridge():
|
||||
"""Create a Bridge with a mock broker (no Redis or HTTP needed)."""
|
||||
broker = MagicMock()
|
||||
bridge = Bridge(server_url="http://localhost:8080", broker=broker, node_id="test-node")
|
||||
return bridge
|
||||
|
||||
|
||||
class TestIdleTurnComplete:
|
||||
"""TurnCompleteEvent should be emitted on every idle transition."""
|
||||
|
||||
def test_idle_emits_turn_complete_with_correlation_id(self):
|
||||
"""Bridge-initiated turn: TurnCompleteEvent has the correlation_id."""
|
||||
bridge = _make_bridge()
|
||||
bridge._active_sends["ws-1"] = "cid-abc"
|
||||
|
||||
published = []
|
||||
with patch.object(
|
||||
bridge, "_publish_ws", side_effect=lambda ws, ev: published.append((ws, ev))
|
||||
):
|
||||
bridge._handle_global_event({"type": "ws_state", "ws_id": "ws-1", "state": "idle"})
|
||||
|
||||
turn_completes = [(ws, ev) for ws, ev in published if isinstance(ev, TurnCompleteEvent)]
|
||||
assert len(turn_completes) == 1
|
||||
ws, ev = turn_completes[0]
|
||||
assert ws == "ws-1"
|
||||
assert ev.correlation_id == "cid-abc"
|
||||
# correlation_id should be removed from _active_sends
|
||||
assert "ws-1" not in bridge._active_sends
|
||||
|
||||
def test_idle_emits_turn_complete_without_correlation_id(self):
|
||||
"""Server-UI-initiated turn: TurnCompleteEvent has empty correlation_id."""
|
||||
bridge = _make_bridge()
|
||||
# No entry in _active_sends for this workstream
|
||||
|
||||
published = []
|
||||
with patch.object(
|
||||
bridge, "_publish_ws", side_effect=lambda ws, ev: published.append((ws, ev))
|
||||
):
|
||||
bridge._handle_global_event({"type": "ws_state", "ws_id": "ws-2", "state": "idle"})
|
||||
|
||||
turn_completes = [(ws, ev) for ws, ev in published if isinstance(ev, TurnCompleteEvent)]
|
||||
assert len(turn_completes) == 1
|
||||
ws, ev = turn_completes[0]
|
||||
assert ws == "ws-2"
|
||||
assert ev.correlation_id == ""
|
||||
|
||||
def test_non_idle_state_does_not_emit_turn_complete(self):
|
||||
"""Non-idle state transitions should emit StateChangeEvent but not TurnCompleteEvent."""
|
||||
bridge = _make_bridge()
|
||||
|
||||
published = []
|
||||
with patch.object(
|
||||
bridge, "_publish_ws", side_effect=lambda ws, ev: published.append((ws, ev))
|
||||
):
|
||||
bridge._handle_global_event({"type": "ws_state", "ws_id": "ws-3", "state": "thinking"})
|
||||
|
||||
state_changes = [ev for _, ev in published if isinstance(ev, StateChangeEvent)]
|
||||
turn_completes = [ev for _, ev in published if isinstance(ev, TurnCompleteEvent)]
|
||||
assert len(state_changes) == 1
|
||||
assert state_changes[0].state == "thinking"
|
||||
assert len(turn_completes) == 0
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Tests for generation cancellation (cooperative cancel via threading.Event)."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.session import ChatSession, GenerationCancelled
|
||||
|
||||
|
||||
class NullUI:
|
||||
"""UI adapter that records state changes and discards other output."""
|
||||
|
||||
def __init__(self):
|
||||
self.states = []
|
||||
self.infos = []
|
||||
self.stream_ends = 0
|
||||
|
||||
def on_thinking_start(self):
|
||||
pass
|
||||
|
||||
def on_thinking_stop(self):
|
||||
pass
|
||||
|
||||
def on_reasoning_token(self, text):
|
||||
pass
|
||||
|
||||
def on_content_token(self, text):
|
||||
pass
|
||||
|
||||
def on_stream_end(self):
|
||||
self.stream_ends += 1
|
||||
|
||||
def approve_tools(self, items):
|
||||
return True, None
|
||||
|
||||
def on_tool_result(self, call_id, name, output):
|
||||
pass
|
||||
|
||||
def on_tool_output_chunk(self, call_id, chunk):
|
||||
pass
|
||||
|
||||
def on_status(self, usage, context_window, effort):
|
||||
pass
|
||||
|
||||
def on_plan_review(self, content):
|
||||
return ""
|
||||
|
||||
def on_info(self, message):
|
||||
self.infos.append(message)
|
||||
|
||||
def on_error(self, message):
|
||||
pass
|
||||
|
||||
def on_state_change(self, state):
|
||||
self.states.append(state)
|
||||
|
||||
def on_rename(self, name):
|
||||
pass
|
||||
|
||||
|
||||
def _make_session(ui=None, **kwargs):
|
||||
"""Helper to construct a ChatSession with minimal setup."""
|
||||
defaults = dict(
|
||||
client=MagicMock(),
|
||||
model="test-model",
|
||||
ui=ui or NullUI(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=4096,
|
||||
tool_timeout=30,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return ChatSession(**defaults)
|
||||
|
||||
|
||||
class TestCancelEvent:
|
||||
"""Basic cancel event mechanics."""
|
||||
|
||||
def test_cancel_sets_event(self, tmp_db):
|
||||
session = _make_session()
|
||||
assert not session._cancel_event.is_set()
|
||||
session.cancel()
|
||||
assert session._cancel_event.is_set()
|
||||
|
||||
def test_check_cancelled_raises_when_set(self, tmp_db):
|
||||
session = _make_session()
|
||||
session.cancel()
|
||||
with pytest.raises(GenerationCancelled):
|
||||
session._check_cancelled()
|
||||
|
||||
def test_check_cancelled_noop_when_clear(self, tmp_db):
|
||||
session = _make_session()
|
||||
session._check_cancelled() # Should not raise
|
||||
|
||||
def test_cancel_is_idempotent(self, tmp_db):
|
||||
session = _make_session()
|
||||
session.cancel()
|
||||
session.cancel() # Double call is harmless
|
||||
assert session._cancel_event.is_set()
|
||||
|
||||
def test_cancel_event_cleared_on_send_start(self, tmp_db):
|
||||
"""send() clears a stale cancel flag before starting."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
session.cancel() # Set stale flag
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = "stop"
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
fake_stream = iter([FakeChunk(content_delta="Hello", finish_reason="stop")])
|
||||
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=fake_stream),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
# Should complete normally — cancel flag was cleared
|
||||
assert "idle" in ui.states
|
||||
|
||||
|
||||
class TestCancelDuringStreaming:
|
||||
"""Cancel while _stream_response is iterating chunks."""
|
||||
|
||||
def test_preserves_partial_content(self, tmp_db):
|
||||
"""Partial content already streamed should be preserved in messages."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = ""
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
def cancelling_stream():
|
||||
"""Yield a few chunks then cancel."""
|
||||
yield FakeChunk(content_delta="Hello ")
|
||||
yield FakeChunk(content_delta="world")
|
||||
session.cancel()
|
||||
yield FakeChunk(content_delta=" — this should not appear")
|
||||
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=cancelling_stream()),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
# Session should be idle (not error)
|
||||
assert ui.states[-1] == "idle"
|
||||
# Check that "[Generation cancelled]" was emitted
|
||||
assert any("cancelled" in i.lower() for i in ui.infos)
|
||||
# The partial content should be preserved as an assistant message
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
assert len(assistant_msgs) == 1
|
||||
assert assistant_msgs[0]["content"] == "Hello world"
|
||||
# No tool_calls in the partial message
|
||||
assert "tool_calls" not in assistant_msgs[0]
|
||||
|
||||
|
||||
class TestCancelDuringToolExecution:
|
||||
"""Cancel while tools are being executed."""
|
||||
|
||||
def test_rollback_incomplete_tool_results(self, tmp_db):
|
||||
"""When cancelled during tool execution, incomplete results are rolled back."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = ""
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
@dataclass
|
||||
class FakeToolDelta:
|
||||
index: int = 0
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
arguments_delta: str = ""
|
||||
|
||||
# First call: return content with a tool call
|
||||
def stream_with_tool():
|
||||
yield FakeChunk(
|
||||
tool_call_deltas=[FakeToolDelta(index=0, id="tc_1", name="bash")],
|
||||
finish_reason="",
|
||||
)
|
||||
yield FakeChunk(
|
||||
tool_call_deltas=[FakeToolDelta(index=0, arguments_delta='{"command":"echo hi"}')],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fake_create_stream(msgs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return stream_with_tool()
|
||||
# Should not be called a second time since cancel happens before phase 3
|
||||
raise AssertionError("Should not stream again after cancel")
|
||||
|
||||
def cancel_before_execute(tool_calls):
|
||||
"""Simulate cancel happening before tool execution."""
|
||||
session.cancel()
|
||||
raise GenerationCancelled()
|
||||
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", side_effect=fake_create_stream),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
patch.object(session, "_execute_tools", side_effect=cancel_before_execute),
|
||||
):
|
||||
session.send("run something")
|
||||
|
||||
# Session should be idle
|
||||
assert ui.states[-1] == "idle"
|
||||
# No tool result messages should remain (rolled back)
|
||||
roles = [m["role"] for m in session.messages]
|
||||
assert "tool" not in roles
|
||||
# The assistant message with tool_calls should also be rolled back
|
||||
for m in session.messages:
|
||||
if m["role"] == "assistant":
|
||||
assert "tool_calls" not in m or not m["tool_calls"]
|
||||
|
||||
|
||||
class TestCancelWhenIdle:
|
||||
"""Cancelling when no generation is active is harmless."""
|
||||
|
||||
def test_cancel_when_idle_is_noop(self, tmp_db):
|
||||
session = _make_session()
|
||||
session.cancel()
|
||||
# Next send should work normally (cancel cleared at start)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = "stop"
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
fake_stream = iter([FakeChunk(content_delta="ok", finish_reason="stop")])
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=fake_stream),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("hello")
|
||||
|
||||
# Should complete normally
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
assert len(assistant_msgs) == 1
|
||||
assert assistant_msgs[0]["content"] == "ok"
|
||||
|
||||
|
||||
class TestCancelThreadSafety:
|
||||
"""Cancel from a different thread while generation is running."""
|
||||
|
||||
def test_cancel_from_another_thread(self, tmp_db):
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = ""
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
barrier = threading.Event()
|
||||
|
||||
def slow_stream():
|
||||
yield FakeChunk(content_delta="Start")
|
||||
barrier.set() # Signal that streaming has started
|
||||
time.sleep(2) # Simulate slow streaming
|
||||
yield FakeChunk(content_delta=" end", finish_reason="stop")
|
||||
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=slow_stream()),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
# Run send() in a thread
|
||||
error = []
|
||||
|
||||
def run():
|
||||
try:
|
||||
session.send("test")
|
||||
except Exception as e:
|
||||
error.append(e)
|
||||
|
||||
t = threading.Thread(target=run)
|
||||
t.start()
|
||||
barrier.wait(timeout=5)
|
||||
# Cancel from main thread
|
||||
session.cancel()
|
||||
t.join(timeout=5)
|
||||
|
||||
assert not error
|
||||
assert ui.states[-1] == "idle"
|
||||
assert any("cancelled" in i.lower() for i in ui.infos)
|
||||
|
||||
|
||||
class TestGenerationCancelledException:
|
||||
"""GenerationCancelled is a BaseException, not Exception."""
|
||||
|
||||
def test_is_base_exception(self):
|
||||
assert issubclass(GenerationCancelled, BaseException)
|
||||
|
||||
def test_not_caught_by_except_exception(self):
|
||||
"""Verify GenerationCancelled is NOT caught by except Exception."""
|
||||
with pytest.raises(GenerationCancelled):
|
||||
try:
|
||||
raise GenerationCancelled()
|
||||
except Exception:
|
||||
pytest.fail("GenerationCancelled was caught by except Exception")
|
||||
|
||||
|
||||
class TestStreamFlushBeforeToolCalls:
|
||||
"""Content pending buffer must be flushed before tool call processing."""
|
||||
|
||||
def test_pending_content_flushed_before_tool_calls(self, tmp_db):
|
||||
"""All content tokens arrive via on_content_token before tool calls."""
|
||||
events: list[tuple[str, ...]] = []
|
||||
|
||||
class TrackingUI(NullUI):
|
||||
def on_content_token(self, text):
|
||||
events.append(("content", text))
|
||||
|
||||
def on_stream_end(self):
|
||||
events.append(("stream_end",))
|
||||
super().on_stream_end()
|
||||
|
||||
ui = TrackingUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = ""
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
@dataclass
|
||||
class FakeToolDelta:
|
||||
index: int = 0
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
arguments_delta: str = ""
|
||||
|
||||
def stream_content_then_tool():
|
||||
# Content long enough to leave chars in pending buffer
|
||||
# (_MAX_TAG_LEN = 13, so _drain_pending retains last 13 chars)
|
||||
yield FakeChunk(content_delta="Hello world, this is a test message")
|
||||
yield FakeChunk(
|
||||
tool_call_deltas=[FakeToolDelta(index=0, id="tc_1", name="bash")],
|
||||
)
|
||||
yield FakeChunk(
|
||||
tool_call_deltas=[FakeToolDelta(index=0, arguments_delta='{"command":"echo hi"}')],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
session,
|
||||
"_create_stream_with_retry",
|
||||
return_value=stream_content_then_tool(),
|
||||
),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
# Prevent real tool execution (e.g., bash) during this test.
|
||||
patch.object(session, "_execute_tools", return_value=([], None)),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
# All content should have been emitted
|
||||
total = "".join(e[1] for e in events if e[0] == "content")
|
||||
assert total == "Hello world, this is a test message"
|
||||
|
||||
# No content events after stream_end
|
||||
stream_end_idx = next(i for i, e in enumerate(events) if e[0] == "stream_end")
|
||||
late_content = [e for e in events[stream_end_idx + 1 :] if e[0] == "content"]
|
||||
assert late_content == [], f"Content after stream_end: {late_content}"
|
||||
@@ -0,0 +1,529 @@
|
||||
"""Tests for the Discord channel adapter (bot, cog, views, config, CLI)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
discord = pytest.importorskip("discord")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run(coro):
|
||||
"""Run an async coroutine in a fresh event loop (no pytest-asyncio needed)."""
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _make_message(*, bot=False, guild=True, content="hello", channel=None):
|
||||
"""Build a mock ``discord.Message``."""
|
||||
msg = MagicMock(spec=discord.Message)
|
||||
msg.author = MagicMock()
|
||||
msg.author.bot = bot
|
||||
msg.author.id = 12345
|
||||
msg.content = content
|
||||
msg.guild = MagicMock() if guild else None
|
||||
msg.channel = channel or MagicMock()
|
||||
msg.mentions = []
|
||||
return msg
|
||||
|
||||
|
||||
def _make_interaction(*, footer_text=None, has_embeds=True):
|
||||
"""Build a mock ``discord.Interaction``."""
|
||||
interaction = MagicMock(spec=discord.Interaction)
|
||||
interaction.user = MagicMock()
|
||||
interaction.user.id = 67890
|
||||
interaction.response = MagicMock()
|
||||
interaction.response.send_message = AsyncMock()
|
||||
|
||||
if has_embeds and footer_text is not None:
|
||||
embed = MagicMock()
|
||||
embed.footer.text = footer_text
|
||||
interaction.message = MagicMock()
|
||||
interaction.message.embeds = [embed]
|
||||
elif not has_embeds:
|
||||
interaction.message = MagicMock()
|
||||
interaction.message.embeds = []
|
||||
else:
|
||||
interaction.message = None
|
||||
|
||||
return interaction
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DiscordConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDiscordConfig:
|
||||
"""Tests for DiscordConfig default and custom values."""
|
||||
|
||||
def test_defaults(self):
|
||||
from turnstone.channels.discord.config import DiscordConfig
|
||||
|
||||
cfg = DiscordConfig()
|
||||
assert cfg.bot_token == ""
|
||||
assert cfg.guild_id == 0
|
||||
assert cfg.allowed_channels == []
|
||||
assert cfg.thread_auto_archive == 1440
|
||||
assert cfg.max_message_length == 2000
|
||||
assert cfg.streaming_edit_interval == 1.5
|
||||
# Inherited from ChannelConfig
|
||||
assert cfg.redis_host == "localhost"
|
||||
assert cfg.redis_port == 6379
|
||||
assert cfg.model == ""
|
||||
assert cfg.auto_approve is False
|
||||
|
||||
def test_custom_values(self):
|
||||
from turnstone.channels.discord.config import DiscordConfig
|
||||
|
||||
cfg = DiscordConfig(
|
||||
bot_token="tok_123",
|
||||
guild_id=999,
|
||||
allowed_channels=[1, 2, 3],
|
||||
thread_auto_archive=60,
|
||||
max_message_length=4000,
|
||||
streaming_edit_interval=0.5,
|
||||
model="gpt-5",
|
||||
auto_approve=True,
|
||||
)
|
||||
assert cfg.bot_token == "tok_123"
|
||||
assert cfg.guild_id == 999
|
||||
assert cfg.allowed_channels == [1, 2, 3]
|
||||
assert cfg.thread_auto_archive == 60
|
||||
assert cfg.max_message_length == 4000
|
||||
assert cfg.streaming_edit_interval == 0.5
|
||||
assert cfg.model == "gpt-5"
|
||||
assert cfg.auto_approve is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StreamingMessage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamingMessage:
|
||||
"""Tests for the StreamingMessage helper in bot.py."""
|
||||
|
||||
def test_append_accumulates(self):
|
||||
from turnstone.channels.discord.bot import StreamingMessage
|
||||
|
||||
channel = MagicMock()
|
||||
channel.send = AsyncMock()
|
||||
sm = StreamingMessage(channel=channel, edit_interval=999.0)
|
||||
|
||||
_run(sm.append("hello "))
|
||||
_run(sm.append("world"))
|
||||
|
||||
assert "".join(sm._buffer) == "hello world"
|
||||
|
||||
def test_finalize_sends_when_no_prior_message(self):
|
||||
from turnstone.channels.discord.bot import StreamingMessage
|
||||
|
||||
channel = MagicMock()
|
||||
channel.send = AsyncMock()
|
||||
sm = StreamingMessage(channel=channel, edit_interval=999.0)
|
||||
|
||||
_run(sm.append("hello"))
|
||||
_run(sm.finalize())
|
||||
|
||||
channel.send.assert_awaited_once_with("hello")
|
||||
|
||||
def test_finalize_edits_existing_message(self):
|
||||
from turnstone.channels.discord.bot import StreamingMessage
|
||||
|
||||
channel = MagicMock()
|
||||
sent_msg = MagicMock()
|
||||
sent_msg.edit = AsyncMock()
|
||||
channel.send = AsyncMock(return_value=sent_msg)
|
||||
sm = StreamingMessage(channel=channel, edit_interval=0.0)
|
||||
|
||||
# First append triggers flush (interval=0) which creates the message.
|
||||
_run(sm.append("hi"))
|
||||
assert sm._message is sent_msg
|
||||
|
||||
_run(sm.append(" there"))
|
||||
_run(sm.finalize())
|
||||
|
||||
# finalize edits the existing message with full content.
|
||||
sent_msg.edit.assert_awaited_with(content="hi there")
|
||||
|
||||
def test_finalize_chunks_long_content(self):
|
||||
from turnstone.channels.discord.bot import StreamingMessage
|
||||
|
||||
channel = MagicMock()
|
||||
channel.send = AsyncMock()
|
||||
sm = StreamingMessage(channel=channel, max_length=10, edit_interval=999.0)
|
||||
|
||||
# Content longer than max_length should be chunked on finalize.
|
||||
_run(sm.append("a" * 25))
|
||||
_run(sm.finalize())
|
||||
|
||||
# Should have sent multiple chunks via channel.send.
|
||||
assert channel.send.await_count >= 2
|
||||
|
||||
def test_finalize_empty_is_noop(self):
|
||||
from turnstone.channels.discord.bot import StreamingMessage
|
||||
|
||||
channel = MagicMock()
|
||||
channel.send = AsyncMock()
|
||||
sm = StreamingMessage(channel=channel)
|
||||
|
||||
_run(sm.finalize())
|
||||
channel.send.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MessageCog._on_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMessageCog:
|
||||
"""Tests for the MessageCog on_message filtering logic."""
|
||||
|
||||
def _make_cog(self):
|
||||
"""Build a MessageCog with a fully mocked bot and TurnstoneBot."""
|
||||
from turnstone.channels.discord.cog import MessageCog
|
||||
|
||||
bot = MagicMock()
|
||||
bot.user = MagicMock()
|
||||
bot.user.id = 99999
|
||||
bot.user.mentioned_in = MagicMock(return_value=False)
|
||||
|
||||
ts = MagicMock()
|
||||
ts._is_allowed_channel = MagicMock(return_value=True)
|
||||
ts.storage = MagicMock()
|
||||
ts.router = MagicMock()
|
||||
ts.router.resolve_user = AsyncMock(return_value="u_abc")
|
||||
ts.router.send_message = AsyncMock()
|
||||
ts.config = MagicMock()
|
||||
ts._ws_tasks = {}
|
||||
bot.turnstone = ts
|
||||
|
||||
cog = MessageCog(bot)
|
||||
return cog, ts, bot
|
||||
|
||||
def test_ignores_bot_messages(self):
|
||||
cog, ts, _bot = self._make_cog()
|
||||
msg = _make_message(bot=True)
|
||||
|
||||
_run(cog._on_message(msg))
|
||||
|
||||
# No router interaction means the message was ignored.
|
||||
ts.router.send_message.assert_not_awaited()
|
||||
|
||||
def test_ignores_own_messages(self):
|
||||
cog, ts, bot = self._make_cog()
|
||||
msg = _make_message(bot=False)
|
||||
msg.author = bot.user # message from ourselves
|
||||
|
||||
_run(cog._on_message(msg))
|
||||
|
||||
ts.router.send_message.assert_not_awaited()
|
||||
|
||||
def test_ignores_dms(self):
|
||||
cog, ts, _bot = self._make_cog()
|
||||
msg = _make_message(guild=False)
|
||||
|
||||
_run(cog._on_message(msg))
|
||||
|
||||
ts.router.send_message.assert_not_awaited()
|
||||
|
||||
def test_ignores_non_allowed_channels(self):
|
||||
cog, ts, _bot = self._make_cog()
|
||||
ts._is_allowed_channel = MagicMock(return_value=False)
|
||||
|
||||
thread = MagicMock(spec=discord.Thread)
|
||||
thread.id = 111
|
||||
thread.parent_id = 222
|
||||
msg = _make_message(channel=thread)
|
||||
|
||||
_run(cog._on_message(msg))
|
||||
|
||||
ts.router.send_message.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_footer (views.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseFooter:
|
||||
"""Tests for _parse_footer in views.py."""
|
||||
|
||||
def test_valid_footer(self):
|
||||
from turnstone.channels.discord.views import _parse_footer
|
||||
|
||||
interaction = _make_interaction(footer_text="ws_abc|corr_123")
|
||||
result = _parse_footer(interaction)
|
||||
assert result == ("ws_abc", "corr_123")
|
||||
|
||||
def test_footer_with_pipe_in_correlation(self):
|
||||
from turnstone.channels.discord.views import _parse_footer
|
||||
|
||||
interaction = _make_interaction(footer_text="ws_abc|corr|extra")
|
||||
result = _parse_footer(interaction)
|
||||
# split("|", 1) means the second part includes everything after first pipe.
|
||||
assert result == ("ws_abc", "corr|extra")
|
||||
|
||||
def test_no_message_returns_none(self):
|
||||
from turnstone.channels.discord.views import _parse_footer
|
||||
|
||||
interaction = MagicMock()
|
||||
interaction.message = None
|
||||
assert _parse_footer(interaction) is None
|
||||
|
||||
def test_no_embeds_returns_none(self):
|
||||
from turnstone.channels.discord.views import _parse_footer
|
||||
|
||||
interaction = _make_interaction(has_embeds=False)
|
||||
assert _parse_footer(interaction) is None
|
||||
|
||||
def test_empty_footer_returns_none(self):
|
||||
from turnstone.channels.discord.views import _parse_footer
|
||||
|
||||
# Build an interaction whose embed has footer.text = None.
|
||||
interaction = MagicMock(spec=discord.Interaction)
|
||||
embed = MagicMock()
|
||||
embed.footer.text = None
|
||||
interaction.message = MagicMock()
|
||||
interaction.message.embeds = [embed]
|
||||
assert _parse_footer(interaction) is None
|
||||
|
||||
def test_footer_without_pipe_returns_none(self):
|
||||
from turnstone.channels.discord.views import _parse_footer
|
||||
|
||||
interaction = _make_interaction(footer_text="no_pipe_here")
|
||||
# footer text has no "|" separator
|
||||
embed = MagicMock()
|
||||
embed.footer.text = "no_pipe_here"
|
||||
interaction.message.embeds = [embed]
|
||||
assert _parse_footer(interaction) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI main() — no adapter configured
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWsEventFinalization:
|
||||
"""TurnCompleteEvent should finalize streaming messages in the Discord bot."""
|
||||
|
||||
def test_turn_complete_finalizes_streaming(self):
|
||||
"""ContentEvent + TurnCompleteEvent(correlation_id='') finalizes the message."""
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
from turnstone.mq.protocol import ContentEvent, TurnCompleteEvent
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot.config = MagicMock()
|
||||
bot.config.max_message_length = 2000
|
||||
bot.config.streaming_edit_interval = 1.5
|
||||
bot.config.auto_approve = False
|
||||
bot.config.auto_approve_tools = []
|
||||
bot._streaming = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
|
||||
# Use the real _on_ws_event method
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
|
||||
thread = AsyncMock()
|
||||
|
||||
# Feed content event
|
||||
content_raw = ContentEvent(ws_id="ws-1", text="Hello world").to_json()
|
||||
_run(bot._on_ws_event("ws-1", thread, content_raw))
|
||||
|
||||
# StreamingMessage should exist
|
||||
assert "ws-1" in bot._streaming
|
||||
|
||||
# Feed turn complete with empty correlation_id (server-UI-initiated)
|
||||
complete_raw = TurnCompleteEvent(ws_id="ws-1", correlation_id="").to_json()
|
||||
_run(bot._on_ws_event("ws-1", thread, complete_raw))
|
||||
|
||||
# StreamingMessage should be removed and finalized
|
||||
assert "ws-1" not in bot._streaming
|
||||
|
||||
def test_turn_complete_no_streaming_is_noop(self):
|
||||
"""TurnCompleteEvent without prior content should not error."""
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
from turnstone.mq.protocol import TurnCompleteEvent
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot._streaming = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
|
||||
thread = AsyncMock()
|
||||
|
||||
complete_raw = TurnCompleteEvent(ws_id="ws-1", correlation_id="").to_json()
|
||||
_run(bot._on_ws_event("ws-1", thread, complete_raw))
|
||||
|
||||
# No error, no streaming message
|
||||
assert "ws-1" not in bot._streaming
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verdict display in approval embeds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApprovalVerdictDisplay:
|
||||
"""Approval requests should include verdict fields in the Discord embed."""
|
||||
|
||||
def _make_bot(self):
|
||||
"""Build a mock TurnstoneBot with _on_ws_event bound."""
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot.config = MagicMock()
|
||||
bot.config.max_message_length = 2000
|
||||
bot.config.streaming_edit_interval = 1.5
|
||||
bot.config.auto_approve = False
|
||||
bot.config.auto_approve_tools = []
|
||||
bot._streaming = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._should_auto_approve = MagicMock(return_value=False)
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
return bot
|
||||
|
||||
def test_approval_with_heuristic_verdict(self):
|
||||
"""ApprovalRequestEvent items with verdict dicts add embed fields."""
|
||||
from turnstone.mq.protocol import ApprovalRequestEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
sent_msg = MagicMock()
|
||||
thread.send = AsyncMock(return_value=sent_msg)
|
||||
|
||||
items = [
|
||||
{
|
||||
"func_name": "bash",
|
||||
"preview": "rm -rf /tmp",
|
||||
"needs_approval": True,
|
||||
"verdict": {
|
||||
"risk_level": "high",
|
||||
"recommendation": "deny",
|
||||
"confidence": 0.85,
|
||||
"intent_summary": "Deleting temp files",
|
||||
"tier": "heuristic",
|
||||
},
|
||||
}
|
||||
]
|
||||
raw = ApprovalRequestEvent(ws_id="ws-1", correlation_id="corr-1", items=items).to_json()
|
||||
_run(bot._on_ws_event("ws-1", thread, raw))
|
||||
|
||||
# thread.send was called with an embed containing a verdict field
|
||||
thread.send.assert_awaited_once()
|
||||
call_kwargs = thread.send.call_args[1]
|
||||
embed = call_kwargs["embed"]
|
||||
# discord.Embed.fields is a list of EmbedProxy objects
|
||||
assert len(embed.fields) == 1
|
||||
field = embed.fields[0]
|
||||
assert field.name == "Verdict: bash"
|
||||
assert "HIGH" in field.value
|
||||
assert "85%" in field.value
|
||||
|
||||
# Pending approval message tracked
|
||||
assert "ws-1" in bot._pending_approval_msgs
|
||||
|
||||
def test_approval_without_verdict(self):
|
||||
"""ApprovalRequestEvent items without verdict still work normally."""
|
||||
from turnstone.mq.protocol import ApprovalRequestEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
sent_msg = MagicMock()
|
||||
thread.send = AsyncMock(return_value=sent_msg)
|
||||
|
||||
items = [{"func_name": "read_file", "preview": "/etc/hosts", "needs_approval": True}]
|
||||
raw = ApprovalRequestEvent(ws_id="ws-1", correlation_id="corr-1", items=items).to_json()
|
||||
_run(bot._on_ws_event("ws-1", thread, raw))
|
||||
|
||||
thread.send.assert_awaited_once()
|
||||
call_kwargs = thread.send.call_args[1]
|
||||
embed = call_kwargs["embed"]
|
||||
# No verdict field added
|
||||
assert len(embed.fields) == 0
|
||||
|
||||
def test_intent_verdict_event_updates_embed(self):
|
||||
"""IntentVerdictEvent should update the pending approval embed."""
|
||||
from turnstone.mq.protocol import IntentVerdictEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
# Set up a pending approval message with a mock embed
|
||||
msg = MagicMock()
|
||||
embed = MagicMock()
|
||||
msg.embeds = [embed]
|
||||
msg.edit = AsyncMock()
|
||||
bot._pending_approval_msgs["ws-1"] = msg
|
||||
|
||||
raw = IntentVerdictEvent(
|
||||
ws_id="ws-1",
|
||||
func_name="bash",
|
||||
risk_level="high",
|
||||
recommendation="deny",
|
||||
confidence=0.9,
|
||||
intent_summary="Dangerous operation",
|
||||
tier="llm",
|
||||
).to_json()
|
||||
_run(bot._on_ws_event("ws-1", thread, raw))
|
||||
|
||||
# Embed should be updated with the judge verdict field
|
||||
embed.add_field.assert_called_once()
|
||||
field_kwargs = embed.add_field.call_args[1]
|
||||
assert field_kwargs["name"] == "Judge Verdict: bash"
|
||||
assert "HIGH" in field_kwargs["value"]
|
||||
assert "90%" in field_kwargs["value"]
|
||||
|
||||
# Message should be edited
|
||||
msg.edit.assert_awaited_once()
|
||||
|
||||
def test_intent_verdict_without_pending_approval_is_noop(self):
|
||||
"""IntentVerdictEvent without a pending approval message should not error."""
|
||||
from turnstone.mq.protocol import IntentVerdictEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
raw = IntentVerdictEvent(ws_id="ws-1", func_name="bash", risk_level="low").to_json()
|
||||
# Should not raise
|
||||
_run(bot._on_ws_event("ws-1", thread, raw))
|
||||
|
||||
def test_turn_complete_clears_pending_approval(self):
|
||||
"""TurnCompleteEvent should clean up the pending approval message tracking."""
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
from turnstone.mq.protocol import TurnCompleteEvent
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot._streaming = {}
|
||||
bot._pending_approval_msgs = {"ws-1": MagicMock()}
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
|
||||
thread = AsyncMock()
|
||||
raw = TurnCompleteEvent(ws_id="ws-1", correlation_id="").to_json()
|
||||
_run(bot._on_ws_event("ws-1", thread, raw))
|
||||
|
||||
assert "ws-1" not in bot._pending_approval_msgs
|
||||
|
||||
|
||||
class TestChannelCLI:
|
||||
"""Tests for the channel CLI entry point."""
|
||||
|
||||
def test_exits_without_adapter_token(self):
|
||||
from turnstone.channels.cli import main
|
||||
|
||||
with (
|
||||
patch.object(sys, "argv", ["turnstone-channel"]),
|
||||
patch.dict("os.environ", {}, clear=True),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Tests for turnstone.channels._protocol and turnstone.channels._formatter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from turnstone.channels._formatter import (
|
||||
chunk_message,
|
||||
format_approval_request,
|
||||
format_plan_review,
|
||||
format_verdict,
|
||||
truncate,
|
||||
)
|
||||
from turnstone.channels._protocol import ChannelEvent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChannelEvent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChannelEvent:
|
||||
def test_construction(self) -> None:
|
||||
evt = ChannelEvent(
|
||||
channel_type="discord",
|
||||
channel_id="ch-1",
|
||||
channel_user_id="u-42",
|
||||
message="hello",
|
||||
parent_channel_id="parent",
|
||||
metadata={"key": "val"},
|
||||
)
|
||||
assert evt.channel_type == "discord"
|
||||
assert evt.channel_id == "ch-1"
|
||||
assert evt.channel_user_id == "u-42"
|
||||
assert evt.message == "hello"
|
||||
assert evt.parent_channel_id == "parent"
|
||||
assert evt.metadata == {"key": "val"}
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
evt = ChannelEvent(
|
||||
channel_type="slack",
|
||||
channel_id="ch-2",
|
||||
channel_user_id="u-7",
|
||||
message="hi",
|
||||
)
|
||||
assert evt.parent_channel_id == ""
|
||||
assert evt.metadata == {}
|
||||
|
||||
def test_metadata_independence(self) -> None:
|
||||
"""Default metadata dicts are independent across instances."""
|
||||
a = ChannelEvent(channel_type="x", channel_id="1", channel_user_id="u", message="m")
|
||||
b = ChannelEvent(channel_type="x", channel_id="2", channel_user_id="u", message="m")
|
||||
a.metadata["key"] = "val"
|
||||
assert "key" not in b.metadata
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chunk_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChunkMessage:
|
||||
def test_empty_string(self) -> None:
|
||||
assert chunk_message("") == [""]
|
||||
|
||||
def test_under_limit(self) -> None:
|
||||
assert chunk_message("short text", max_length=100) == ["short text"]
|
||||
|
||||
def test_exactly_at_limit(self) -> None:
|
||||
text = "a" * 50
|
||||
assert chunk_message(text, max_length=50) == [text]
|
||||
|
||||
def test_splits_at_newline(self) -> None:
|
||||
text = "line one\nline two\nline three"
|
||||
chunks = chunk_message(text, max_length=18)
|
||||
assert len(chunks) >= 2
|
||||
# The split should happen at a newline boundary within the text.
|
||||
# Reassembled chunks (with newline separators) should cover all content.
|
||||
rejoined = "\n".join(chunks)
|
||||
assert "line one" in rejoined
|
||||
assert "line three" in rejoined
|
||||
|
||||
def test_splits_at_word_boundary(self) -> None:
|
||||
text = "word1 word2 word3 word4"
|
||||
chunks = chunk_message(text, max_length=12)
|
||||
assert len(chunks) >= 2
|
||||
# No chunk should start with a space (lstrip handles newlines).
|
||||
for chunk in chunks:
|
||||
assert not chunk.startswith("\n")
|
||||
|
||||
def test_hard_splits(self) -> None:
|
||||
text = "a" * 30
|
||||
chunks = chunk_message(text, max_length=10)
|
||||
assert len(chunks) == 3
|
||||
assert "".join(chunks) == text
|
||||
|
||||
def test_code_block_spanning_boundary(self) -> None:
|
||||
text = "before\n```\ncode line 1\ncode line 2\ncode line 3\n```\nafter"
|
||||
chunks = chunk_message(text, max_length=30)
|
||||
assert len(chunks) >= 2
|
||||
# If a chunk opens a code block without closing it, the chunker
|
||||
# should close it and reopen in the next chunk.
|
||||
for chunk in chunks:
|
||||
fence_count = chunk.count("```")
|
||||
assert fence_count % 2 == 0, f"Unmatched code fence in chunk: {chunk!r}"
|
||||
|
||||
def test_multiple_code_blocks(self) -> None:
|
||||
text = "```\nblock1\n```\ntext\n```\nblock2\n```"
|
||||
chunks = chunk_message(text, max_length=20)
|
||||
for chunk in chunks:
|
||||
fence_count = chunk.count("```")
|
||||
assert fence_count % 2 == 0, f"Unmatched code fence in chunk: {chunk!r}"
|
||||
|
||||
def test_custom_max_length(self) -> None:
|
||||
text = "hello world"
|
||||
chunks = chunk_message(text, max_length=5)
|
||||
assert len(chunks) >= 2
|
||||
assert chunks[0] == "hello"
|
||||
|
||||
def test_very_long_single_line(self) -> None:
|
||||
text = "x" * 5000
|
||||
chunks = chunk_message(text, max_length=2000)
|
||||
assert len(chunks) == 3
|
||||
total = "".join(chunks)
|
||||
assert total == text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_approval_request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatApprovalRequest:
|
||||
def test_single_tool(self) -> None:
|
||||
items = [{"function": {"name": "read_file", "arguments": "/etc/hosts"}}]
|
||||
result = format_approval_request(items)
|
||||
assert "Tool approval required" in result
|
||||
assert "`read_file`" in result
|
||||
|
||||
def test_multiple_tools(self) -> None:
|
||||
items = [
|
||||
{"function": {"name": "tool_a", "arguments": "arg1"}},
|
||||
{"function": {"name": "tool_b", "arguments": "arg2"}},
|
||||
]
|
||||
result = format_approval_request(items)
|
||||
assert "`tool_a`" in result
|
||||
assert "`tool_b`" in result
|
||||
|
||||
def test_long_arguments_truncated(self) -> None:
|
||||
long_args = "x" * 500
|
||||
items = [{"function": {"name": "fn", "arguments": long_args}}]
|
||||
result = format_approval_request(items)
|
||||
# The result should be shorter than the original args.
|
||||
assert len(result) < 500
|
||||
|
||||
def test_server_sse_format(self) -> None:
|
||||
"""Items from the server SSE use func_name/preview, not function.name."""
|
||||
items = [
|
||||
{
|
||||
"call_id": "c1",
|
||||
"func_name": "bash",
|
||||
"preview": "ls -la",
|
||||
"header": "Execute: ls -la",
|
||||
"needs_approval": True,
|
||||
}
|
||||
]
|
||||
result = format_approval_request(items)
|
||||
assert "`bash`" in result
|
||||
assert "Execute: ls -la" in result
|
||||
|
||||
def test_server_sse_format_no_header(self) -> None:
|
||||
items = [{"func_name": "read_file", "preview": "/etc/hosts"}]
|
||||
result = format_approval_request(items)
|
||||
assert "`read_file`" in result
|
||||
assert "/etc/hosts" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_plan_review
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatPlanReview:
|
||||
def test_format(self) -> None:
|
||||
result = format_plan_review("Step 1: do stuff")
|
||||
assert result.startswith("**Plan review requested:**")
|
||||
assert "Step 1: do stuff" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_verdict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatVerdict:
|
||||
def test_low_risk(self) -> None:
|
||||
verdict = {
|
||||
"risk_level": "low",
|
||||
"recommendation": "allow",
|
||||
"confidence": 0.95,
|
||||
"intent_summary": "Reading a config file",
|
||||
"tier": "heuristic",
|
||||
}
|
||||
result = format_verdict(verdict)
|
||||
assert "HEURISTIC" in result
|
||||
assert "LOW" in result
|
||||
assert "95%" in result
|
||||
assert "allow" in result
|
||||
assert "_Reading a config file_" in result
|
||||
# Green circle emoji
|
||||
assert "\U0001f7e2" in result
|
||||
|
||||
def test_high_risk(self) -> None:
|
||||
verdict = {
|
||||
"risk_level": "high",
|
||||
"recommendation": "deny",
|
||||
"confidence": 0.8,
|
||||
}
|
||||
result = format_verdict(verdict)
|
||||
assert "HIGH" in result
|
||||
assert "80%" in result
|
||||
assert "deny" in result
|
||||
# Red circle emoji
|
||||
assert "\U0001f534" in result
|
||||
|
||||
def test_critical_risk(self) -> None:
|
||||
verdict = {"risk_level": "critical", "confidence": 0.99}
|
||||
result = format_verdict(verdict)
|
||||
assert "CRITICAL" in result
|
||||
assert "\u26d4" in result
|
||||
|
||||
def test_medium_risk_default(self) -> None:
|
||||
"""Empty risk_level defaults to MEDIUM."""
|
||||
result = format_verdict({})
|
||||
assert "MEDIUM" in result
|
||||
assert "50%" in result
|
||||
assert "review" in result
|
||||
|
||||
def test_no_summary_omits_line(self) -> None:
|
||||
verdict = {"risk_level": "low", "confidence": 0.7}
|
||||
result = format_verdict(verdict)
|
||||
# Should be a single line (no summary italic line).
|
||||
assert "\n" not in result
|
||||
|
||||
def test_with_summary(self) -> None:
|
||||
verdict = {"risk_level": "low", "intent_summary": "Safe operation"}
|
||||
result = format_verdict(verdict)
|
||||
lines = result.split("\n")
|
||||
assert len(lines) == 2
|
||||
assert "_Safe operation_" in lines[1]
|
||||
|
||||
def test_tier_label(self) -> None:
|
||||
verdict = {"tier": "llm", "risk_level": "medium"}
|
||||
result = format_verdict(verdict)
|
||||
assert "LLM " in result
|
||||
|
||||
def test_no_tier_no_label(self) -> None:
|
||||
verdict = {"risk_level": "low"}
|
||||
result = format_verdict(verdict)
|
||||
assert "Risk: LOW" in result
|
||||
# No double space or extra label prefix.
|
||||
assert "** " not in result or "**Risk:" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# truncate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTruncate:
|
||||
def test_short_text_unchanged(self) -> None:
|
||||
assert truncate("hello", max_length=200) == "hello"
|
||||
|
||||
def test_long_text_truncated(self) -> None:
|
||||
text = "a" * 300
|
||||
result = truncate(text, max_length=200)
|
||||
assert len(result) == 200
|
||||
assert result.endswith("\u2026")
|
||||
|
||||
def test_exactly_at_limit(self) -> None:
|
||||
text = "b" * 200
|
||||
assert truncate(text, max_length=200) == text
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user