mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-14 07:52:25 -06:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1f448277f | |||
| 2f7f70825b | |||
| 4866c9873c | |||
| 8b2e2130fc | |||
| f81c06761d | |||
| be165c1971 | |||
| 3264fdefca | |||
| 28cb3a5c51 | |||
| 8b11e0a6f9 | |||
| 648ba477e1 | |||
| 7960784786 | |||
| e06554d1ec | |||
| 8eb8722346 | |||
| a2e2ffacd8 | |||
| c6ba8d59b0 | |||
| 087f5b49f6 | |||
| fd507c6a3c | |||
| 562c3c8ab7 | |||
| 4773535bb8 | |||
| 7492816ab2 | |||
| d6ba1d5e25 |
@@ -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
|
||||
@@ -17,6 +17,7 @@ Turnstone gives LLMs tools — shell, files, search, web, planning — and orche
|
||||
- **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)
|
||||
- **Governance & compliance** — role-based access control, tool policies, usage tracking, and append-only audit logs
|
||||
- **Cluster simulator** — test the stack at scale (up to 1000 nodes) without an LLM backend
|
||||
|
||||
<p align="center">
|
||||
@@ -127,6 +128,23 @@ 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 |
|
||||
|
||||
### 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 (10 tabs) and the full REST API. See [docs/governance.md](docs/governance.md) for setup and configuration.
|
||||
|
||||
## Multi-node routing
|
||||
|
||||
@@ -151,7 +169,7 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
|
||||
|
||||
## Tools
|
||||
|
||||
15 built-in tools, 2 agent tools, plus external tools via MCP:
|
||||
16 built-in tools, 2 agent tools, plus external tools via MCP:
|
||||
|
||||
| Tool | Description | Auto-approved |
|
||||
|------|-------------|:---:|
|
||||
@@ -168,6 +186,7 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
|
||||
| `recall` | Search memories and history | yes |
|
||||
| `forget` | Remove a memory | 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 | |
|
||||
|
||||
+56
-5
@@ -2,10 +2,11 @@
|
||||
# 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
|
||||
# =============================================================================
|
||||
|
||||
@@ -29,6 +30,7 @@ services:
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
- ddgCluster
|
||||
environment:
|
||||
POSTGRES_DB: turnstone
|
||||
POSTGRES_USER: ${POSTGRES_USER:-turnstone}
|
||||
@@ -88,6 +90,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
profiles:
|
||||
- production
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -99,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}
|
||||
@@ -112,6 +118,7 @@ services:
|
||||
- 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:-}
|
||||
@@ -125,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
|
||||
@@ -141,6 +151,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
profiles:
|
||||
- production
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server:8080
|
||||
@@ -208,6 +220,7 @@ services:
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
- ddgCluster
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -235,6 +248,39 @@ services:
|
||||
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
|
||||
@@ -288,7 +334,7 @@ services:
|
||||
|
||||
server-1: &cluster-server
|
||||
build: { context: ., dockerfile: Dockerfile }
|
||||
profiles: [cluster]
|
||||
profiles: [cluster, ddgCluster]
|
||||
command: &cluster-server-cmd
|
||||
- sh
|
||||
- -c
|
||||
@@ -300,7 +346,10 @@ services:
|
||||
--api-key "$${OPENAI_API_KEY}"
|
||||
$${MODEL:+--model $$MODEL}
|
||||
$${SKIP_PERMISSIONS:+--skip-permissions}
|
||||
volumes: [turnstone-data:/data]
|
||||
$${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}
|
||||
@@ -310,6 +359,7 @@ services:
|
||||
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
|
||||
@@ -318,6 +368,7 @@ services:
|
||||
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
|
||||
@@ -361,7 +412,7 @@ services:
|
||||
|
||||
bridge-1: &cluster-bridge
|
||||
build: { context: ., dockerfile: Dockerfile }
|
||||
profiles: [cluster]
|
||||
profiles: [cluster, ddgCluster]
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-1:8080
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ddg": {
|
||||
"url": "http://ddg-search:3000/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -448,6 +448,14 @@ 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"}
|
||||
```
|
||||
|
||||
#### Keepalive
|
||||
|
||||
The server sends an SSE comment every 5 seconds when no events are pending:
|
||||
@@ -701,6 +709,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.
|
||||
@@ -719,6 +764,7 @@ All fields are optional. The body can be empty or an empty JSON object.
|
||||
| `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)|
|
||||
|
||||
**Response (success):**
|
||||
|
||||
|
||||
+38
-1
@@ -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 18 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**.
|
||||
@@ -129,6 +129,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
|
||||
@@ -174,11 +175,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()
|
||||
@@ -209,6 +212,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]").
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1173,6 +1181,10 @@ 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.
|
||||
@@ -1340,3 +1352,28 @@ gateway validates the JWT, resolves the target (username lookup via
|
||||
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.
|
||||
|
||||
The console admin panel adds 5 governance tabs (Roles, Policies, Templates,
|
||||
Usage, Audit) for a total of 10 tabs, all permission-gated. Both Python
|
||||
and TypeScript SDKs expose governance methods on the console client.
|
||||
|
||||
@@ -96,7 +96,7 @@ package "turnstone/sdk/" <<Rectangle>> {
|
||||
|
||||
' Tool schemas
|
||||
package "turnstone/tools/" <<Rectangle>> {
|
||||
component [*.json\n15 tool schemas] as schemas <<artifact>>
|
||||
component [*.json\n18 tool schemas] as schemas <<artifact>>
|
||||
}
|
||||
|
||||
' Entry point dependencies
|
||||
|
||||
@@ -211,15 +211,23 @@ enum "WorkstreamState" as WsState {
|
||||
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)
|
||||
@@ -230,6 +238,8 @@ class "MCPClientManager" as MCPMgr {
|
||||
bridges async MCP SDK to
|
||||
sync ChatSession dispatch.
|
||||
Push + periodic + manual refresh.
|
||||
Resources + prompts discovered
|
||||
alongside tools at startup.
|
||||
--
|
||||
core/mcp_client.py
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,29 +24,31 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
||||
:Dispatch to _prepare_{func_name}();
|
||||
|
||||
note right
|
||||
**Dispatch table (16 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 │
|
||||
│ tool_search │ ✗ Auto-approve │
|
||||
│ task │ ✓ Yes │
|
||||
│ plan │ ✓ Yes │
|
||||
│ remember │ ✗ Auto-approve │
|
||||
│ recall │ ✗ Auto-approve │
|
||||
│ forget │ ✗ Auto-approve │
|
||||
│ notify │ ✗ Auto-approve │
|
||||
├──────────────┼──────────────────┤
|
||||
│ mcp__* │ ✓ Yes (external) │
|
||||
└──────────────┴──────────────────┘
|
||||
**Dispatch table (18 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 │
|
||||
│ remember │ ✗ Auto-approve │
|
||||
│ recall │ ✗ Auto-approve │
|
||||
│ forget │ ✗ Auto-approve │
|
||||
│ notify │ ✗ Auto-approve │
|
||||
│ read_resource │ ✓ Yes │
|
||||
│ use_prompt │ ✓ Yes │
|
||||
├───────────────┼──────────────────┤
|
||||
│ mcp__* │ ✓ Yes (external) │
|
||||
└───────────────┴──────────────────┘
|
||||
end note
|
||||
|
||||
:Build item dict:
|
||||
@@ -88,6 +90,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)
|
||||
@@ -115,6 +119,8 @@ partition "Phase 3: Execute" #E3F2FD {
|
||||
├─ _exec_remember: SQLite INSERT OR REPLACE
|
||||
├─ _exec_recall: SQLite FTS5/LIKE search
|
||||
├─ _exec_forget: SQLite DELETE
|
||||
├─ _exec_read_resource: MCPClientManager.read_resource_sync()
|
||||
├─ _exec_use_prompt: MCPClientManager.get_prompt_sync()
|
||||
└─ _exec_mcp_tool: MCPClientManager.call_tool_sync()
|
||||
end note
|
||||
|
||||
|
||||
@@ -79,6 +79,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 +94,7 @@ package "Inbound Messages (Client → Bridge)" #FFF3E0 {
|
||||
IM <|-- ListWorkstreamsMessage
|
||||
IM <|-- HealthMessage
|
||||
IM <|-- ListNodesMessage
|
||||
IM <|-- CancelMessage
|
||||
}
|
||||
|
||||
package "Outbound Events (Bridge → Client)" #E3F2FD {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -32,6 +32,7 @@ package "turnstone/sdk/ (Python)" {
|
||||
+ approve()
|
||||
+ plan_feedback()
|
||||
+ command()
|
||||
+ cancel(ws_id)
|
||||
+ stream_events(ws_id)
|
||||
+ stream_global_events()
|
||||
+ send_and_wait()
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
@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
|
||||
}
|
||||
|
||||
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 "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
|
||||
|
||||
auth -[hidden]-> mw
|
||||
mw -[hidden]-> approve
|
||||
@enduml
|
||||
@@ -0,0 +1,158 @@
|
||||
@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>>
|
||||
|
||||
== Startup: Connection & Discovery ==
|
||||
|
||||
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
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0ee0a9391bd19d92e9271bf6bd531e9c2e18baf8c5a11ead49b3c10db4d8939b
|
||||
size 329625
|
||||
oid sha256:c9daca81971ba7a8ed6736d23d5373c69435158fa6240b9880d14fc4759ab580
|
||||
size 329673
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:760c37e67736588dadee21d500419a48e9fc50f8bdc5667e686c580022bd40e2
|
||||
size 554869
|
||||
oid sha256:01fbb3338df6426cefc2811541a865f268673b4febf32f524c264d120bc068fa
|
||||
size 589546
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e3044c738d6d6853aab5c4990e6c67bab0165eba991a4f5bebdfc4d4a0b305ee
|
||||
size 289165
|
||||
oid sha256:da9d32000e3d92d92ce621661ced60f276f9b5be652f5ed6123b400505415f4a
|
||||
size 319702
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:282820fe416961e735d050f86ecdc079e29824d2b3c4d5c8c174d0533d41f211
|
||||
size 258045
|
||||
oid sha256:6dd3c923d1e1c49b5f91d8d342fb4b0d49a46d432460379ad146a9e3b075a05a
|
||||
size 277234
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:32a0665cceffcc0517265bde12cfb227688aa8585284b5e946ab23bcc52daee6
|
||||
size 187650
|
||||
oid sha256:d17f3feacf7bc9f64dfea19464143bc9b6ef0da5d55e6d57c0bc5a73d5724eba
|
||||
size 184466
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e0a3f48cca1b8408862dc4ba04fd340703346f44d84048c99e9900f48e9c7e22
|
||||
size 158867
|
||||
oid sha256:7896c6e041b6dbb89d034468fa980c8fe645df5eb969d45ef966ccc6399edac2
|
||||
size 200083
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:435a58aa09d0e6615e78c0be62e5fd9aa6d7329b1e96619744355c42ade649c9
|
||||
size 196502
|
||||
oid sha256:e7c3e40c10425d721f833390ae3531c09af501157fd3142531ba4eba86ff719d
|
||||
size 197112
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a889d4bb84c4afa3c822c3acb7021395a9463462f7aeae5382e583b783412814
|
||||
size 144960
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8e6dc5142c7908314ce01229b3c4f13bf9450adcbb62a178838bd4cf81d9f4da
|
||||
size 250417
|
||||
@@ -0,0 +1,184 @@
|
||||
# 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.
|
||||
|
||||
### 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, 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` |
|
||||
| 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
|
||||
|
||||
5 new tabs added to the admin panel (10 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
|
||||
- **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()`
|
||||
- `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`
|
||||
@@ -75,6 +75,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|
||||
| | `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` |
|
||||
@@ -129,6 +130,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()`):
|
||||
|
||||
|
||||
@@ -92,6 +92,34 @@ 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
|
||||
|
||||
+149
-6
@@ -1,6 +1,6 @@
|
||||
# Tools Reference
|
||||
|
||||
turnstone exposes 16 built-in tools plus any number of external MCP tools to the
|
||||
turnstone exposes 18 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,12 +46,12 @@ schema plus turnstone-specific metadata keys:
|
||||
|
||||
| Name | Description |
|
||||
|---------------------|-------------|
|
||||
| `TOOLS` | All 16 tool definitions (sent to the model). |
|
||||
| `TOOLS` | All 18 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 16 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
|
||||
| `BUILTIN_TOOL_NAMES`| Frozenset of all 18 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. |
|
||||
|
||||
---
|
||||
@@ -69,7 +69,7 @@ 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 15
|
||||
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 18
|
||||
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:
|
||||
@@ -168,6 +168,8 @@ Every tool defines a `primary_key`. The mapping is:
|
||||
| `recall` | `query` |
|
||||
| `forget` | `key` |
|
||||
| `notify` | `message` |
|
||||
| `read_resource` | `uri` |
|
||||
| `use_prompt` | `name` |
|
||||
|
||||
---
|
||||
|
||||
@@ -517,6 +519,8 @@ data.get("mergedAt") is not None
|
||||
| `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` |
|
||||
|
||||
---
|
||||
@@ -569,7 +573,7 @@ CLI flags override the config file:
|
||||
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 15 built-in tools (members of `BUILTIN_TOOL_NAMES`).
|
||||
- **Always-on** -- the 18 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.
|
||||
@@ -593,6 +597,8 @@ 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.
|
||||
@@ -610,7 +616,7 @@ MCP-compatible service.
|
||||
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 15 built-in tools via
|
||||
4. **Merging**: MCP tools are appended after the 18 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
|
||||
@@ -729,3 +735,140 @@ MCP refresh complete:
|
||||
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.
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.5.3"
|
||||
version = "0.5.6"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
@@ -62,6 +62,7 @@ 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 = [
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
"get": {
|
||||
"summary": "Cluster state summary",
|
||||
"operationId": "v1_api_cluster_overview_get",
|
||||
"tags": [
|
||||
"Cluster"
|
||||
],
|
||||
"tags": ["Cluster"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -31,9 +29,7 @@
|
||||
"get": {
|
||||
"summary": "Paginated node list",
|
||||
"operationId": "v1_api_cluster_nodes_get",
|
||||
"tags": [
|
||||
"Cluster"
|
||||
],
|
||||
"tags": ["Cluster"],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sort",
|
||||
@@ -42,11 +38,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "activity",
|
||||
"enum": [
|
||||
"activity",
|
||||
"tokens",
|
||||
"name"
|
||||
]
|
||||
"enum": ["activity", "tokens", "name"]
|
||||
},
|
||||
"description": "Sort field"
|
||||
},
|
||||
@@ -89,9 +81,7 @@
|
||||
"get": {
|
||||
"summary": "Filtered workstream list",
|
||||
"operationId": "v1_api_cluster_workstreams_get",
|
||||
"tags": [
|
||||
"Cluster"
|
||||
],
|
||||
"tags": ["Cluster"],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "state",
|
||||
@@ -99,13 +89,7 @@
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"running",
|
||||
"thinking",
|
||||
"attention",
|
||||
"idle",
|
||||
"error"
|
||||
]
|
||||
"enum": ["running", "thinking", "attention", "idle", "error"]
|
||||
},
|
||||
"description": "Filter by state"
|
||||
},
|
||||
@@ -134,11 +118,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "state",
|
||||
"enum": [
|
||||
"state",
|
||||
"tokens",
|
||||
"name"
|
||||
]
|
||||
"enum": ["state", "tokens", "name"]
|
||||
},
|
||||
"description": "Sort field"
|
||||
},
|
||||
@@ -181,9 +161,7 @@
|
||||
"get": {
|
||||
"summary": "Single node detail",
|
||||
"operationId": "v1_api_cluster_node_{node_id}_get",
|
||||
"tags": [
|
||||
"Cluster"
|
||||
],
|
||||
"tags": ["Cluster"],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "node_id",
|
||||
@@ -222,9 +200,7 @@
|
||||
"post": {
|
||||
"summary": "Create workstream via MQ dispatch",
|
||||
"operationId": "v1_api_cluster_workstreams_new_post",
|
||||
"tags": [
|
||||
"Cluster"
|
||||
],
|
||||
"tags": ["Cluster"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -283,9 +259,7 @@
|
||||
"get": {
|
||||
"summary": "Cluster SSE event stream",
|
||||
"operationId": "v1_api_cluster_events_get",
|
||||
"tags": [
|
||||
"Streaming"
|
||||
],
|
||||
"tags": ["Streaming"],
|
||||
"description": "Server-Sent Events stream for real-time cluster updates. Returns text/event-stream with node_joined, node_lost, cluster_state, ws_created, ws_closed, ws_rename events.",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -298,9 +272,7 @@
|
||||
"post": {
|
||||
"summary": "Authenticate with a token",
|
||||
"operationId": "v1_api_auth_login_post",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"tags": ["Auth"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -339,9 +311,7 @@
|
||||
"post": {
|
||||
"summary": "Clear auth cookie",
|
||||
"operationId": "v1_api_auth_logout_post",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -360,9 +330,7 @@
|
||||
"get": {
|
||||
"summary": "Console health check",
|
||||
"operationId": "health_get",
|
||||
"tags": [
|
||||
"Observability"
|
||||
],
|
||||
"tags": ["Observability"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -389,9 +357,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"required": ["error"],
|
||||
"title": "ErrorResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -400,9 +366,7 @@
|
||||
"properties": {
|
||||
"status": {
|
||||
"default": "ok",
|
||||
"examples": [
|
||||
"ok"
|
||||
],
|
||||
"examples": ["ok"],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
}
|
||||
@@ -419,9 +383,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"token"
|
||||
],
|
||||
"required": ["token"],
|
||||
"title": "AuthLoginRequest",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -435,17 +397,12 @@
|
||||
},
|
||||
"role": {
|
||||
"description": "Assigned role",
|
||||
"examples": [
|
||||
"full",
|
||||
"read"
|
||||
],
|
||||
"examples": ["full", "read"],
|
||||
"title": "Role",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"role"
|
||||
],
|
||||
"required": ["role"],
|
||||
"title": "AuthLoginResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -557,9 +514,7 @@
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"nodes"
|
||||
],
|
||||
"required": ["nodes"],
|
||||
"title": "ClusterNodesResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -632,9 +587,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"node_id"
|
||||
],
|
||||
"required": ["node_id"],
|
||||
"title": "ClusterNodeInfo",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -668,9 +621,7 @@
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workstreams"
|
||||
],
|
||||
"required": ["workstreams"],
|
||||
"title": "ClusterWorkstreamsResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -726,9 +677,7 @@
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"required": ["id"],
|
||||
"title": "ClusterWorkstreamInfo",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -771,9 +720,7 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"node_id"
|
||||
],
|
||||
"required": ["node_id"],
|
||||
"title": "NodeDetailResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -802,6 +749,12 @@
|
||||
"description": "Optional first message sent after creation",
|
||||
"title": "Initial Message",
|
||||
"type": "string"
|
||||
},
|
||||
"template": {
|
||||
"default": "",
|
||||
"description": "Prompt template name (replaces default templates)",
|
||||
"title": "Template",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "ConsoleCreateWsRequest",
|
||||
@@ -832,9 +785,7 @@
|
||||
"properties": {
|
||||
"status": {
|
||||
"default": "ok",
|
||||
"examples": [
|
||||
"ok"
|
||||
],
|
||||
"examples": ["ok"],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
+142
-154
@@ -10,9 +10,7 @@
|
||||
"get": {
|
||||
"summary": "List active workstreams",
|
||||
"operationId": "v1_api_workstreams_get",
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"tags": ["Workstreams"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -31,9 +29,7 @@
|
||||
"get": {
|
||||
"summary": "Dashboard with workstream details and aggregates",
|
||||
"operationId": "v1_api_dashboard_get",
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"tags": ["Workstreams"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -52,9 +48,7 @@
|
||||
"post": {
|
||||
"summary": "Create a new workstream",
|
||||
"operationId": "v1_api_workstreams_new_post",
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"tags": ["Workstreams"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -93,9 +87,7 @@
|
||||
"post": {
|
||||
"summary": "Close a workstream",
|
||||
"operationId": "v1_api_workstreams_close_post",
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"tags": ["Workstreams"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -134,9 +126,7 @@
|
||||
"post": {
|
||||
"summary": "Send a user message",
|
||||
"operationId": "v1_api_send_post",
|
||||
"tags": [
|
||||
"Chat"
|
||||
],
|
||||
"tags": ["Chat"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -185,9 +175,7 @@
|
||||
"post": {
|
||||
"summary": "Approve or deny a tool call",
|
||||
"operationId": "v1_api_approve_post",
|
||||
"tags": [
|
||||
"Chat"
|
||||
],
|
||||
"tags": ["Chat"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -226,9 +214,7 @@
|
||||
"post": {
|
||||
"summary": "Respond to a plan review",
|
||||
"operationId": "v1_api_plan_post",
|
||||
"tags": [
|
||||
"Chat"
|
||||
],
|
||||
"tags": ["Chat"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -267,9 +253,7 @@
|
||||
"post": {
|
||||
"summary": "Execute a slash command",
|
||||
"operationId": "v1_api_command_post",
|
||||
"tags": [
|
||||
"Chat"
|
||||
],
|
||||
"tags": ["Chat"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -314,13 +298,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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",
|
||||
"operationId": "v1_api_events_get",
|
||||
"tags": [
|
||||
"Streaming"
|
||||
],
|
||||
"tags": ["Streaming"],
|
||||
"description": "Opens a Server-Sent Events stream scoped to a single workstream. Returns text/event-stream. See API reference for event types.",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -354,9 +385,7 @@
|
||||
"get": {
|
||||
"summary": "Global SSE event stream",
|
||||
"operationId": "v1_api_events_global_get",
|
||||
"tags": [
|
||||
"Streaming"
|
||||
],
|
||||
"tags": ["Streaming"],
|
||||
"description": "Global Server-Sent Events stream for state-change broadcasts across all workstreams. Returns text/event-stream.",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -369,9 +398,7 @@
|
||||
"get": {
|
||||
"summary": "List saved workstreams",
|
||||
"operationId": "v1_api_workstreams_saved_get",
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"tags": ["Workstreams"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -390,9 +417,7 @@
|
||||
"post": {
|
||||
"summary": "Authenticate with a token",
|
||||
"operationId": "v1_api_auth_login_post",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"tags": ["Auth"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -431,9 +456,7 @@
|
||||
"post": {
|
||||
"summary": "Create first admin user",
|
||||
"operationId": "v1_api_auth_setup_post",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"tags": ["Auth"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -492,9 +515,7 @@
|
||||
"get": {
|
||||
"summary": "Return auth state",
|
||||
"operationId": "v1_api_auth_status_get",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -513,9 +534,7 @@
|
||||
"post": {
|
||||
"summary": "Clear auth cookie",
|
||||
"operationId": "v1_api_auth_logout_post",
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -534,9 +553,7 @@
|
||||
"get": {
|
||||
"summary": "Server health check",
|
||||
"operationId": "health_get",
|
||||
"tags": [
|
||||
"Observability"
|
||||
],
|
||||
"tags": ["Observability"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -563,9 +580,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"required": ["error"],
|
||||
"title": "ErrorResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -574,9 +589,7 @@
|
||||
"properties": {
|
||||
"status": {
|
||||
"default": "ok",
|
||||
"examples": [
|
||||
"ok"
|
||||
],
|
||||
"examples": ["ok"],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
}
|
||||
@@ -625,19 +638,14 @@
|
||||
},
|
||||
"role": {
|
||||
"description": "Legacy role",
|
||||
"examples": [
|
||||
"full",
|
||||
"read"
|
||||
],
|
||||
"examples": ["full", "read"],
|
||||
"title": "Role",
|
||||
"type": "string"
|
||||
},
|
||||
"scopes": {
|
||||
"default": "",
|
||||
"description": "Comma-separated scopes",
|
||||
"examples": [
|
||||
"read,write,approve"
|
||||
],
|
||||
"examples": ["read,write,approve"],
|
||||
"title": "Scopes",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -648,9 +656,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"role"
|
||||
],
|
||||
"required": ["role"],
|
||||
"title": "AuthLoginResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -673,11 +679,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"username",
|
||||
"display_name",
|
||||
"password"
|
||||
],
|
||||
"required": ["username", "display_name", "password"],
|
||||
"title": "AuthSetupRequest",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -714,10 +716,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"user_id",
|
||||
"username"
|
||||
],
|
||||
"required": ["user_id", "username"],
|
||||
"title": "AuthSetupResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -737,11 +736,7 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"auth_enabled",
|
||||
"has_users",
|
||||
"setup_required"
|
||||
],
|
||||
"required": ["auth_enabled", "has_users", "setup_required"],
|
||||
"title": "AuthStatusResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -758,10 +753,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message",
|
||||
"ws_id"
|
||||
],
|
||||
"required": ["message", "ws_id"],
|
||||
"title": "SendRequest",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -769,17 +761,12 @@
|
||||
"properties": {
|
||||
"status": {
|
||||
"description": "'ok' or 'busy'",
|
||||
"examples": [
|
||||
"ok",
|
||||
"busy"
|
||||
],
|
||||
"examples": ["ok", "busy"],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"required": ["status"],
|
||||
"title": "SendResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -815,10 +802,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"approved",
|
||||
"ws_id"
|
||||
],
|
||||
"required": ["approved", "ws_id"],
|
||||
"title": "ApproveRequest",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -835,10 +819,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"feedback",
|
||||
"ws_id"
|
||||
],
|
||||
"required": ["feedback", "ws_id"],
|
||||
"title": "PlanFeedbackRequest",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -855,13 +836,22 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"ws_id"
|
||||
],
|
||||
"required": ["command", "ws_id"],
|
||||
"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": {
|
||||
@@ -887,6 +877,12 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"title": "CreateWorkstreamRequest",
|
||||
@@ -917,10 +913,7 @@
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ws_id",
|
||||
"name"
|
||||
],
|
||||
"required": ["ws_id", "name"],
|
||||
"title": "CreateWorkstreamResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -932,9 +925,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ws_id"
|
||||
],
|
||||
"required": ["ws_id"],
|
||||
"title": "CloseWorkstreamRequest",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -948,9 +939,7 @@
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workstreams"
|
||||
],
|
||||
"required": ["workstreams"],
|
||||
"title": "ListWorkstreamsResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -969,11 +958,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"state"
|
||||
],
|
||||
"required": ["id", "name", "state"],
|
||||
"title": "WorkstreamInfo",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -990,10 +975,7 @@
|
||||
"$ref": "#/components/schemas/DashboardAggregate"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workstreams",
|
||||
"aggregate"
|
||||
],
|
||||
"required": ["workstreams", "aggregate"],
|
||||
"title": "DashboardResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -1093,11 +1075,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"state"
|
||||
],
|
||||
"required": ["id", "name", "state"],
|
||||
"title": "DashboardWorkstream",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -1111,9 +1089,7 @@
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workstreams"
|
||||
],
|
||||
"required": ["workstreams"],
|
||||
"title": "ListSavedWorkstreamsResponse",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -1160,22 +1136,14 @@
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ws_id",
|
||||
"created",
|
||||
"updated",
|
||||
"message_count"
|
||||
],
|
||||
"required": ["ws_id", "created", "updated", "message_count"],
|
||||
"title": "SavedWorkstreamInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"HealthResponse": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"examples": [
|
||||
"ok",
|
||||
"degraded"
|
||||
],
|
||||
"examples": ["ok", "degraded"],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1215,38 +1183,58 @@
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"mcp": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/McpStatus"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"required": ["status"],
|
||||
"title": "HealthResponse",
|
||||
"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"
|
||||
},
|
||||
"BackendStatus": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"examples": [
|
||||
"up",
|
||||
"down"
|
||||
],
|
||||
"examples": ["up", "down"],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"circuit_state": {
|
||||
"examples": [
|
||||
"closed",
|
||||
"open",
|
||||
"half_open"
|
||||
],
|
||||
"examples": ["closed", "open", "half_open"],
|
||||
"title": "Circuit State",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"circuit_state"
|
||||
],
|
||||
"required": ["status", "circuit_state"],
|
||||
"title": "BackendStatus",
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BaseClient, type ClientOptions } from "./base.js";
|
||||
import type { ClusterEvent } from "./events.js";
|
||||
import type {
|
||||
AuditQueryOptions,
|
||||
AuditResponse,
|
||||
AuthLoginResponse,
|
||||
AuthSetupResponse,
|
||||
AuthStatusResponse,
|
||||
@@ -11,14 +13,28 @@ import type {
|
||||
ConsoleCreateWsRequest,
|
||||
ConsoleCreateWsResponse,
|
||||
ConsoleHealthResponse,
|
||||
CreatePolicyOptions,
|
||||
CreateRoleOptions,
|
||||
CreateScheduleRequest,
|
||||
CreateTemplateOptions,
|
||||
ListScheduleRunsResponse,
|
||||
ListSchedulesResponse,
|
||||
NodeDetailResponse,
|
||||
NodesOptions,
|
||||
OrgInfo,
|
||||
PromptTemplateInfo,
|
||||
RoleInfo,
|
||||
ScheduleInfo,
|
||||
StatusResponse,
|
||||
ToolPolicyInfo,
|
||||
UpdateOrgOptions,
|
||||
UpdatePolicyOptions,
|
||||
UpdateRoleOptions,
|
||||
UpdateScheduleRequest,
|
||||
UpdateTemplateOptions,
|
||||
UsageQueryOptions,
|
||||
UsageResponse,
|
||||
UserRoleInfo,
|
||||
WorkstreamsOptions,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -157,4 +173,125 @@ export class TurnstoneConsole extends BaseClient {
|
||||
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: 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,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;
|
||||
@@ -95,6 +101,10 @@ export interface ClearUiEvent {
|
||||
type: "clear_ui";
|
||||
}
|
||||
|
||||
export interface CancelledEvent {
|
||||
type: "cancelled";
|
||||
}
|
||||
|
||||
// Global events
|
||||
|
||||
export interface WsStateEvent {
|
||||
@@ -137,6 +147,7 @@ export type ServerEvent =
|
||||
| StreamEndEvent
|
||||
| ToolInfoEvent
|
||||
| ApproveRequestEvent
|
||||
| ApprovalResolvedEvent
|
||||
| ToolResultEvent
|
||||
| ToolOutputChunkEvent
|
||||
| StatusEvent
|
||||
@@ -145,6 +156,7 @@ export type ServerEvent =
|
||||
| ErrorEvent
|
||||
| BusyErrorEvent
|
||||
| ClearUiEvent
|
||||
| CancelledEvent
|
||||
| WsStateEvent
|
||||
| WsActivityEvent
|
||||
| WsRenameEvent
|
||||
@@ -244,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,
|
||||
@@ -66,7 +68,9 @@ export {
|
||||
isToolResultEvent,
|
||||
isWsStateEvent,
|
||||
isApproveRequestEvent,
|
||||
isApprovalResolvedEvent,
|
||||
isPlanReviewEvent,
|
||||
isCancelledEvent,
|
||||
} from "./events.js";
|
||||
|
||||
// Request/response types
|
||||
@@ -87,6 +91,7 @@ export type {
|
||||
SavedWorkstreamInfo,
|
||||
ListSavedWorkstreamsResponse,
|
||||
BackendStatus,
|
||||
McpStatus,
|
||||
WorkstreamCounts,
|
||||
HealthResponse,
|
||||
AuthLoginRequest,
|
||||
@@ -112,6 +117,24 @@ export type {
|
||||
ScheduleRunInfo,
|
||||
ListSchedulesResponse,
|
||||
ListScheduleRunsResponse,
|
||||
RoleInfo,
|
||||
CreateRoleOptions,
|
||||
UpdateRoleOptions,
|
||||
UserRoleInfo,
|
||||
OrgInfo,
|
||||
UpdateOrgOptions,
|
||||
ToolPolicyInfo,
|
||||
CreatePolicyOptions,
|
||||
UpdatePolicyOptions,
|
||||
PromptTemplateInfo,
|
||||
CreateTemplateOptions,
|
||||
UpdateTemplateOptions,
|
||||
UsageBreakdownItem,
|
||||
UsageResponse,
|
||||
UsageQueryOptions,
|
||||
AuditEventInfo,
|
||||
AuditQueryOptions,
|
||||
AuditResponse,
|
||||
TurnResult,
|
||||
SendAndWaitOptions,
|
||||
NodesOptions,
|
||||
|
||||
@@ -86,6 +86,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> {
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface CreateWorkstreamRequest {
|
||||
model?: string;
|
||||
auto_approve?: boolean;
|
||||
resume_ws?: string;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkstreamResponse {
|
||||
@@ -159,6 +160,12 @@ export interface WorkstreamCounts {
|
||||
error?: number;
|
||||
}
|
||||
|
||||
export interface McpStatus {
|
||||
servers: number;
|
||||
resources: number;
|
||||
prompts: number;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
version?: string;
|
||||
@@ -166,6 +173,7 @@ export interface HealthResponse {
|
||||
model?: string;
|
||||
workstreams?: WorkstreamCounts;
|
||||
backend?: BackendStatus | null;
|
||||
mcp?: McpStatus | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -266,6 +274,7 @@ export interface ConsoleCreateWsRequest {
|
||||
name?: string;
|
||||
model?: string;
|
||||
initial_message?: string;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export interface ConsoleCreateWsResponse {
|
||||
@@ -354,6 +363,176 @@ 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: 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]
|
||||
@@ -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}"
|
||||
@@ -312,6 +312,59 @@ class TestParseFooter:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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 = {}
|
||||
|
||||
# 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._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
|
||||
|
||||
|
||||
class TestChannelCLI:
|
||||
"""Tests for the channel CLI entry point."""
|
||||
|
||||
|
||||
@@ -1609,3 +1609,69 @@ class TestSSEProxy:
|
||||
assert b"chunk3" not in body
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collector — MCP aggregation in get_overview()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCollectorMCPAggregation:
|
||||
"""Verify MCP server/resource/prompt aggregation in overview and snapshot."""
|
||||
|
||||
def test_overview_mcp_aggregation(self):
|
||||
"""Two nodes with MCP data produce correct sums in the overview."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
health={"mcp": {"servers": 2, "resources": 5, "prompts": 3}},
|
||||
)
|
||||
c._nodes["node-b"] = NodeSnapshot(
|
||||
node_id="node-b",
|
||||
server_url="http://b:8080",
|
||||
health={"mcp": {"servers": 1, "resources": 4, "prompts": 2}},
|
||||
)
|
||||
|
||||
overview = c.get_overview()
|
||||
assert overview["mcp_servers"] == 3
|
||||
assert overview["mcp_resources"] == 9
|
||||
assert overview["mcp_prompts"] == 5
|
||||
|
||||
def test_overview_mcp_absent_when_zero(self):
|
||||
"""Nodes without MCP data produce no mcp_servers key in the overview."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
health={"status": "ok"},
|
||||
)
|
||||
c._nodes["node-b"] = NodeSnapshot(
|
||||
node_id="node-b",
|
||||
server_url="http://b:8080",
|
||||
health={},
|
||||
)
|
||||
|
||||
overview = c.get_overview()
|
||||
assert "mcp_servers" not in overview
|
||||
assert "mcp_resources" not in overview
|
||||
assert "mcp_prompts" not in overview
|
||||
|
||||
def test_overview_mcp_mixed_nodes(self):
|
||||
"""One node with MCP, one without — only the MCP node contributes."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
health={"mcp": {"servers": 3, "resources": 10, "prompts": 7}},
|
||||
)
|
||||
c._nodes["node-b"] = NodeSnapshot(
|
||||
node_id="node-b",
|
||||
server_url="http://b:8080",
|
||||
health={"status": "ok"},
|
||||
)
|
||||
|
||||
overview = c.get_overview()
|
||||
assert overview["mcp_servers"] == 3
|
||||
assert overview["mcp_resources"] == 10
|
||||
assert overview["mcp_prompts"] == 7
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
"""Tests for governance admin API endpoints (roles, orgs, policies, templates, usage, audit)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from turnstone.console.server import (
|
||||
admin_assign_role,
|
||||
admin_audit,
|
||||
admin_create_policy,
|
||||
admin_create_role,
|
||||
admin_create_template,
|
||||
admin_delete_policy,
|
||||
admin_delete_role,
|
||||
admin_delete_template,
|
||||
admin_delete_user,
|
||||
admin_get_org,
|
||||
admin_list_orgs,
|
||||
admin_list_policies,
|
||||
admin_list_roles,
|
||||
admin_list_templates,
|
||||
admin_list_user_roles,
|
||||
admin_unassign_role,
|
||||
admin_update_org,
|
||||
admin_update_policy,
|
||||
admin_update_role,
|
||||
admin_update_template,
|
||||
admin_usage,
|
||||
)
|
||||
from turnstone.core.auth import AuthResult
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth bypass middleware — injects a full-access AuthResult on every request.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _InjectAuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="test-admin",
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="config",
|
||||
permissions=frozenset(
|
||||
{
|
||||
"read",
|
||||
"write",
|
||||
"approve",
|
||||
"admin.roles",
|
||||
"admin.users",
|
||||
"admin.orgs",
|
||||
"admin.policies",
|
||||
"admin.templates",
|
||||
"admin.usage",
|
||||
"admin.audit",
|
||||
"admin.schedules",
|
||||
"admin.watches",
|
||||
"tools.approve",
|
||||
"workstreams.create",
|
||||
"workstreams.close",
|
||||
}
|
||||
),
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path):
|
||||
"""Fresh SQLite backend for each test, seeded with test users."""
|
||||
backend = SQLiteBackend(str(tmp_path / "test.db"))
|
||||
# Seed users required by role assignment tests
|
||||
backend.create_user("test-admin", "testadmin", "Test Admin", "hash")
|
||||
backend.create_user("user-1", "user1", "User One", "hash")
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(storage):
|
||||
"""TestClient with storage and auth bypassed."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Mount(
|
||||
"/v1",
|
||||
routes=[
|
||||
# Roles
|
||||
Route("/api/admin/roles", admin_list_roles),
|
||||
Route("/api/admin/roles", admin_create_role, methods=["POST"]),
|
||||
Route("/api/admin/roles/{role_id}", admin_update_role, methods=["PUT"]),
|
||||
Route("/api/admin/roles/{role_id}", admin_delete_role, methods=["DELETE"]),
|
||||
# Users
|
||||
Route(
|
||||
"/api/admin/users/{user_id}",
|
||||
admin_delete_user,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
# User-role assignments
|
||||
Route("/api/admin/users/{user_id}/roles", admin_list_user_roles),
|
||||
Route(
|
||||
"/api/admin/users/{user_id}/roles",
|
||||
admin_assign_role,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/users/{user_id}/roles/{role_id}",
|
||||
admin_unassign_role,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
# Orgs
|
||||
Route("/api/admin/orgs", admin_list_orgs),
|
||||
Route("/api/admin/orgs/{org_id}", admin_get_org),
|
||||
Route("/api/admin/orgs/{org_id}", admin_update_org, methods=["PUT"]),
|
||||
# Policies
|
||||
Route("/api/admin/policies", admin_list_policies),
|
||||
Route("/api/admin/policies", admin_create_policy, methods=["POST"]),
|
||||
Route(
|
||||
"/api/admin/policies/{policy_id}",
|
||||
admin_update_policy,
|
||||
methods=["PUT"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/policies/{policy_id}",
|
||||
admin_delete_policy,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
# Templates
|
||||
Route("/api/admin/templates", admin_list_templates),
|
||||
Route("/api/admin/templates", admin_create_template, methods=["POST"]),
|
||||
Route(
|
||||
"/api/admin/templates/{template_id}",
|
||||
admin_update_template,
|
||||
methods=["PUT"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/templates/{template_id}",
|
||||
admin_delete_template,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
# Usage & Audit
|
||||
Route("/api/admin/usage", admin_usage),
|
||||
Route("/api/admin/audit", admin_audit),
|
||||
],
|
||||
),
|
||||
],
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _role_payload(**overrides: Any) -> dict[str, Any]:
|
||||
defaults: dict[str, Any] = {
|
||||
"name": "analyst",
|
||||
"display_name": "Data Analyst",
|
||||
"permissions": "read,write",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return defaults
|
||||
|
||||
|
||||
def _policy_payload(**overrides: Any) -> dict[str, Any]:
|
||||
defaults: dict[str, Any] = {
|
||||
"name": "Allow bash",
|
||||
"tool_pattern": "bash_*",
|
||||
"action": "allow",
|
||||
"priority": 10,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return defaults
|
||||
|
||||
|
||||
def _template_payload(**overrides: Any) -> dict[str, Any]:
|
||||
defaults: dict[str, Any] = {
|
||||
"name": "Greeting",
|
||||
"content": "Hello {{user}}, how can I help?",
|
||||
"category": "system",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return defaults
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Roles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRoles:
|
||||
def test_list_empty(self, client):
|
||||
resp = client.get("/v1/api/admin/roles")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["roles"] == []
|
||||
|
||||
def test_create_role(self, client):
|
||||
resp = client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
assert resp.status_code == 200
|
||||
role = resp.json()
|
||||
assert role["name"] == "analyst"
|
||||
assert role["display_name"] == "Data Analyst"
|
||||
assert role["permissions"] == "read,write"
|
||||
assert role["builtin"] is False
|
||||
assert "role_id" in role
|
||||
assert "created" in role
|
||||
|
||||
def test_create_role_missing_name(self, client):
|
||||
resp = client.post("/v1/api/admin/roles", json=_role_payload(name=""))
|
||||
assert resp.status_code == 400
|
||||
assert "name" in resp.json()["error"].lower()
|
||||
|
||||
def test_create_role_invalid_name(self, client):
|
||||
resp = client.post("/v1/api/admin/roles", json=_role_payload(name="bad name!@#"))
|
||||
assert resp.status_code == 400
|
||||
assert "name" in resp.json()["error"].lower()
|
||||
|
||||
def test_create_role_default_display_name(self, client):
|
||||
resp = client.post(
|
||||
"/v1/api/admin/roles",
|
||||
json={"name": "ops", "permissions": ""},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
role = resp.json()
|
||||
# display_name defaults to name when not provided
|
||||
assert role["display_name"] == "ops"
|
||||
|
||||
def test_list_after_create(self, client):
|
||||
client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
resp = client.get("/v1/api/admin/roles")
|
||||
assert resp.status_code == 200
|
||||
roles = resp.json()["roles"]
|
||||
assert len(roles) == 1
|
||||
assert roles[0]["name"] == "analyst"
|
||||
|
||||
def test_update_role(self, client):
|
||||
create_resp = client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
role_id = create_resp.json()["role_id"]
|
||||
|
||||
resp = client.put(
|
||||
f"/v1/api/admin/roles/{role_id}",
|
||||
json={"display_name": "Senior Analyst", "permissions": "read,write,approve"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
role = resp.json()
|
||||
assert role["display_name"] == "Senior Analyst"
|
||||
assert role["permissions"] == "read,write,approve"
|
||||
|
||||
def test_update_nonexistent_role(self, client):
|
||||
resp = client.put(
|
||||
"/v1/api/admin/roles/nonexistent",
|
||||
json={"display_name": "Nope"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_builtin_role_rejected(self, client, storage):
|
||||
# Seed a builtin role directly via storage
|
||||
storage.create_role(
|
||||
role_id="builtin-admin",
|
||||
name="admin",
|
||||
display_name="Administrator",
|
||||
permissions="*",
|
||||
builtin=True,
|
||||
)
|
||||
resp = client.put(
|
||||
"/v1/api/admin/roles/builtin-admin",
|
||||
json={"display_name": "Hacked"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "builtin" in resp.json()["error"].lower()
|
||||
|
||||
def test_delete_role(self, client):
|
||||
create_resp = client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
role_id = create_resp.json()["role_id"]
|
||||
|
||||
resp = client.delete(f"/v1/api/admin/roles/{role_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
# Verify gone from listing
|
||||
list_resp = client.get("/v1/api/admin/roles")
|
||||
assert list_resp.json()["roles"] == []
|
||||
|
||||
def test_delete_nonexistent_role(self, client):
|
||||
resp = client.delete("/v1/api/admin/roles/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_builtin_role_rejected(self, client, storage):
|
||||
storage.create_role(
|
||||
role_id="builtin-viewer",
|
||||
name="viewer",
|
||||
display_name="Viewer",
|
||||
permissions="read",
|
||||
builtin=True,
|
||||
)
|
||||
resp = client.delete("/v1/api/admin/roles/builtin-viewer")
|
||||
assert resp.status_code == 400
|
||||
assert "builtin" in resp.json()["error"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Role assignments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRoleAssignments:
|
||||
def test_list_user_roles_empty(self, client):
|
||||
resp = client.get("/v1/api/admin/users/user-1/roles")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["roles"] == []
|
||||
|
||||
def test_assign_role(self, client):
|
||||
create_resp = client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
role_id = create_resp.json()["role_id"]
|
||||
|
||||
resp = client.post(
|
||||
"/v1/api/admin/users/user-1/roles",
|
||||
json={"role_id": role_id},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
# Verify listed
|
||||
list_resp = client.get("/v1/api/admin/users/user-1/roles")
|
||||
roles = list_resp.json()["roles"]
|
||||
assert len(roles) >= 1
|
||||
|
||||
def test_assign_role_missing_role_id(self, client):
|
||||
resp = client.post(
|
||||
"/v1/api/admin/users/user-1/roles",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "role_id" in resp.json()["error"].lower()
|
||||
|
||||
def test_unassign_role(self, client):
|
||||
create_resp = client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
role_id = create_resp.json()["role_id"]
|
||||
|
||||
# Assign first
|
||||
client.post(
|
||||
"/v1/api/admin/users/user-1/roles",
|
||||
json={"role_id": role_id},
|
||||
)
|
||||
|
||||
# Now unassign
|
||||
resp = client.delete(f"/v1/api/admin/users/user-1/roles/{role_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
# Verify removed
|
||||
list_resp = client.get("/v1/api/admin/users/user-1/roles")
|
||||
assert list_resp.json()["roles"] == []
|
||||
|
||||
def test_unassign_nonexistent(self, client):
|
||||
resp = client.delete("/v1/api/admin/users/user-1/roles/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Orgs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOrgs:
|
||||
def test_list_empty(self, client):
|
||||
resp = client.get("/v1/api/admin/orgs")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["orgs"] == []
|
||||
|
||||
def test_get_org(self, client, storage):
|
||||
storage.create_org(
|
||||
org_id="org-1",
|
||||
name="acme",
|
||||
display_name="Acme Corp",
|
||||
settings='{"theme": "dark"}',
|
||||
)
|
||||
resp = client.get("/v1/api/admin/orgs/org-1")
|
||||
assert resp.status_code == 200
|
||||
org = resp.json()
|
||||
assert org["org_id"] == "org-1"
|
||||
assert org["name"] == "acme"
|
||||
assert org["display_name"] == "Acme Corp"
|
||||
|
||||
def test_get_org_not_found(self, client):
|
||||
resp = client.get("/v1/api/admin/orgs/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_org(self, client, storage):
|
||||
storage.create_org(org_id="org-1", name="acme", display_name="Acme Corp")
|
||||
|
||||
resp = client.put(
|
||||
"/v1/api/admin/orgs/org-1",
|
||||
json={"display_name": "Acme Inc.", "settings": '{"theme": "light"}'},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
org = resp.json()
|
||||
assert org["display_name"] == "Acme Inc."
|
||||
assert org["settings"] == '{"theme": "light"}'
|
||||
|
||||
def test_update_org_not_found(self, client):
|
||||
resp = client.put(
|
||||
"/v1/api/admin/orgs/nonexistent",
|
||||
json={"display_name": "Nope"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Tool policies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPolicies:
|
||||
def test_list_empty(self, client):
|
||||
resp = client.get("/v1/api/admin/policies")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["policies"] == []
|
||||
|
||||
def test_create_policy(self, client):
|
||||
resp = client.post("/v1/api/admin/policies", json=_policy_payload())
|
||||
assert resp.status_code == 200
|
||||
policy = resp.json()
|
||||
assert policy["name"] == "Allow bash"
|
||||
assert policy["tool_pattern"] == "bash_*"
|
||||
assert policy["action"] == "allow"
|
||||
assert policy["priority"] == 10
|
||||
assert "policy_id" in policy
|
||||
assert "created" in policy
|
||||
|
||||
def test_create_policy_missing_name(self, client):
|
||||
resp = client.post("/v1/api/admin/policies", json=_policy_payload(name=""))
|
||||
assert resp.status_code == 400
|
||||
assert "name" in resp.json()["error"].lower()
|
||||
|
||||
def test_create_policy_missing_tool_pattern(self, client):
|
||||
resp = client.post(
|
||||
"/v1/api/admin/policies",
|
||||
json=_policy_payload(tool_pattern=""),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "tool_pattern" in resp.json()["error"].lower()
|
||||
|
||||
def test_create_policy_invalid_action(self, client):
|
||||
resp = client.post(
|
||||
"/v1/api/admin/policies",
|
||||
json=_policy_payload(action="yolo"),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "action" in resp.json()["error"].lower()
|
||||
|
||||
def test_list_after_create(self, client):
|
||||
client.post("/v1/api/admin/policies", json=_policy_payload())
|
||||
resp = client.get("/v1/api/admin/policies")
|
||||
assert resp.status_code == 200
|
||||
policies = resp.json()["policies"]
|
||||
assert len(policies) == 1
|
||||
assert policies[0]["name"] == "Allow bash"
|
||||
|
||||
def test_update_policy(self, client):
|
||||
create_resp = client.post("/v1/api/admin/policies", json=_policy_payload())
|
||||
policy_id = create_resp.json()["policy_id"]
|
||||
|
||||
resp = client.put(
|
||||
f"/v1/api/admin/policies/{policy_id}",
|
||||
json={"name": "Deny bash", "action": "deny", "priority": 20},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
policy = resp.json()
|
||||
assert policy["name"] == "Deny bash"
|
||||
assert policy["action"] == "deny"
|
||||
assert policy["priority"] == 20
|
||||
|
||||
def test_update_policy_invalid_action(self, client):
|
||||
create_resp = client.post("/v1/api/admin/policies", json=_policy_payload())
|
||||
policy_id = create_resp.json()["policy_id"]
|
||||
|
||||
resp = client.put(
|
||||
f"/v1/api/admin/policies/{policy_id}",
|
||||
json={"action": "nope"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "action" in resp.json()["error"].lower()
|
||||
|
||||
def test_update_policy_not_found(self, client):
|
||||
resp = client.put(
|
||||
"/v1/api/admin/policies/nonexistent",
|
||||
json={"name": "Nope"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_policy(self, client):
|
||||
create_resp = client.post("/v1/api/admin/policies", json=_policy_payload())
|
||||
policy_id = create_resp.json()["policy_id"]
|
||||
|
||||
resp = client.delete(f"/v1/api/admin/policies/{policy_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
# Verify gone
|
||||
list_resp = client.get("/v1/api/admin/policies")
|
||||
assert list_resp.json()["policies"] == []
|
||||
|
||||
def test_delete_policy_not_found(self, client):
|
||||
resp = client.delete("/v1/api/admin/policies/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Prompt templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTemplates:
|
||||
def test_list_empty(self, client):
|
||||
resp = client.get("/v1/api/admin/templates")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["templates"] == []
|
||||
|
||||
def test_create_template(self, client):
|
||||
resp = client.post("/v1/api/admin/templates", json=_template_payload())
|
||||
assert resp.status_code == 200
|
||||
tmpl = resp.json()
|
||||
assert tmpl["name"] == "Greeting"
|
||||
assert "{{user}}" in tmpl["content"]
|
||||
assert tmpl["category"] == "system"
|
||||
assert "template_id" in tmpl
|
||||
assert "created" in tmpl
|
||||
|
||||
def test_create_template_missing_name(self, client):
|
||||
resp = client.post(
|
||||
"/v1/api/admin/templates",
|
||||
json=_template_payload(name=""),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "name" in resp.json()["error"].lower()
|
||||
|
||||
def test_create_template_missing_content(self, client):
|
||||
resp = client.post(
|
||||
"/v1/api/admin/templates",
|
||||
json=_template_payload(content=""),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "content" in resp.json()["error"].lower()
|
||||
|
||||
def test_list_after_create(self, client):
|
||||
client.post("/v1/api/admin/templates", json=_template_payload())
|
||||
resp = client.get("/v1/api/admin/templates")
|
||||
assert resp.status_code == 200
|
||||
templates = resp.json()["templates"]
|
||||
assert len(templates) == 1
|
||||
assert templates[0]["name"] == "Greeting"
|
||||
|
||||
def test_update_template(self, client):
|
||||
create_resp = client.post("/v1/api/admin/templates", json=_template_payload())
|
||||
template_id = create_resp.json()["template_id"]
|
||||
|
||||
resp = client.put(
|
||||
f"/v1/api/admin/templates/{template_id}",
|
||||
json={"name": "Welcome", "content": "Welcome, {{user}}!", "is_default": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
tmpl = resp.json()
|
||||
assert tmpl["name"] == "Welcome"
|
||||
assert tmpl["content"] == "Welcome, {{user}}!"
|
||||
assert tmpl["is_default"] is True
|
||||
|
||||
def test_update_template_not_found(self, client):
|
||||
resp = client.put(
|
||||
"/v1/api/admin/templates/nonexistent",
|
||||
json={"name": "Nope"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_template(self, client):
|
||||
create_resp = client.post("/v1/api/admin/templates", json=_template_payload())
|
||||
template_id = create_resp.json()["template_id"]
|
||||
|
||||
resp = client.delete(f"/v1/api/admin/templates/{template_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
# Verify gone
|
||||
list_resp = client.get("/v1/api/admin/templates")
|
||||
assert list_resp.json()["templates"] == []
|
||||
|
||||
def test_delete_template_not_found(self, client):
|
||||
resp = client.delete("/v1/api/admin/templates/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Usage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUsage:
|
||||
def test_usage_defaults(self, client):
|
||||
"""Query usage with no params — should return summary and breakdown."""
|
||||
resp = client.get("/v1/api/admin/usage")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "summary" in data
|
||||
assert "breakdown" in data
|
||||
# Summary is a list with at least one row
|
||||
assert isinstance(data["summary"], list)
|
||||
assert len(data["summary"]) >= 1
|
||||
# All-zeros when no data
|
||||
assert data["summary"][0]["prompt_tokens"] == 0
|
||||
|
||||
def test_usage_with_data(self, client, storage):
|
||||
"""Seed usage events and verify they appear in the query."""
|
||||
storage.record_usage_event(
|
||||
event_id="evt-1",
|
||||
user_id="user-1",
|
||||
model="gpt-5",
|
||||
prompt_tokens=100,
|
||||
completion_tokens=50,
|
||||
tool_calls_count=2,
|
||||
)
|
||||
storage.record_usage_event(
|
||||
event_id="evt-2",
|
||||
user_id="user-1",
|
||||
model="gpt-5",
|
||||
prompt_tokens=200,
|
||||
completion_tokens=75,
|
||||
tool_calls_count=1,
|
||||
)
|
||||
resp = client.get("/v1/api/admin/usage")
|
||||
assert resp.status_code == 200
|
||||
summary = resp.json()["summary"]
|
||||
assert summary[0]["prompt_tokens"] == 300
|
||||
assert summary[0]["completion_tokens"] == 125
|
||||
assert summary[0]["tool_calls_count"] == 3
|
||||
|
||||
def test_usage_with_filters(self, client, storage):
|
||||
storage.record_usage_event(
|
||||
event_id="evt-f1",
|
||||
user_id="user-a",
|
||||
model="gpt-5",
|
||||
prompt_tokens=100,
|
||||
completion_tokens=10,
|
||||
)
|
||||
storage.record_usage_event(
|
||||
event_id="evt-f2",
|
||||
user_id="user-b",
|
||||
model="claude-4",
|
||||
prompt_tokens=200,
|
||||
completion_tokens=20,
|
||||
)
|
||||
resp = client.get("/v1/api/admin/usage?user_id=user-a")
|
||||
assert resp.status_code == 200
|
||||
summary = resp.json()["summary"]
|
||||
assert summary[0]["prompt_tokens"] == 100
|
||||
|
||||
resp2 = client.get("/v1/api/admin/usage?model=claude-4")
|
||||
assert resp2.status_code == 200
|
||||
summary2 = resp2.json()["summary"]
|
||||
assert summary2[0]["prompt_tokens"] == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Audit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAudit:
|
||||
def test_audit_empty(self, client):
|
||||
resp = client.get("/v1/api/admin/audit")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["events"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_audit_populated_by_mutations(self, client):
|
||||
"""Creating a role should produce an audit event."""
|
||||
client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
|
||||
resp = client.get("/v1/api/admin/audit")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
actions = [e["action"] for e in data["events"]]
|
||||
assert "role.create" in actions
|
||||
|
||||
def test_audit_filter_by_action(self, client):
|
||||
# Create a role and a policy to produce different audit actions
|
||||
client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
client.post("/v1/api/admin/policies", json=_policy_payload())
|
||||
|
||||
resp = client.get("/v1/api/admin/audit?action=policy.create")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
assert all(e["action"] == "policy.create" for e in data["events"])
|
||||
|
||||
def test_audit_filter_by_user_id(self, client):
|
||||
client.post("/v1/api/admin/roles", json=_role_payload())
|
||||
|
||||
resp = client.get("/v1/api/admin/audit?user_id=test-admin")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
assert all(e["user_id"] == "test-admin" for e in data["events"])
|
||||
|
||||
def test_audit_pagination(self, client):
|
||||
# Create several resources to produce multiple audit events
|
||||
for i in range(5):
|
||||
client.post(
|
||||
"/v1/api/admin/roles",
|
||||
json=_role_payload(name=f"role-{i}"),
|
||||
)
|
||||
|
||||
resp = client.get("/v1/api/admin/audit?limit=2&offset=0")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["events"]) == 2
|
||||
assert data["total"] >= 5
|
||||
|
||||
resp2 = client.get("/v1/api/admin/audit?limit=2&offset=2")
|
||||
assert resp2.status_code == 200
|
||||
data2 = resp2.json()
|
||||
assert len(data2["events"]) == 2
|
||||
# The two pages should not overlap
|
||||
ids_page1 = {e["event_id"] for e in data["events"]}
|
||||
ids_page2 = {e["event_id"] for e in data2["events"]}
|
||||
assert ids_page1.isdisjoint(ids_page2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — User self-deletion guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUserSelfDeletion:
|
||||
def test_cannot_delete_self(self, client):
|
||||
"""Admin should not be able to delete their own account."""
|
||||
resp = client.delete("/v1/api/admin/users/test-admin")
|
||||
assert resp.status_code == 400
|
||||
assert "own account" in resp.json()["error"].lower()
|
||||
|
||||
def test_can_delete_other_user(self, client):
|
||||
resp = client.delete("/v1/api/admin/users/user-1")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
@@ -0,0 +1,808 @@
|
||||
"""Tests for governance storage operations (SQLite backend).
|
||||
|
||||
Covers RBAC roles, organizations, tool policies, prompt templates,
|
||||
usage events, and audit events.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
"""Create a fresh SQLite backend for each test."""
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Roles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRoleCRUD:
|
||||
def test_create_role(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
role = db.get_role("r1")
|
||||
assert role is not None
|
||||
assert role["role_id"] == "r1"
|
||||
assert role["name"] == "editor"
|
||||
assert role["display_name"] == "Editor"
|
||||
assert role["permissions"] == "read,write"
|
||||
assert role["builtin"] is False
|
||||
assert role["org_id"] == ""
|
||||
assert "created" in role
|
||||
assert "updated" in role
|
||||
|
||||
def test_create_role_idempotent(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
# Second insert with same role_id should be silently ignored.
|
||||
db.create_role("r1", "editor2", "Editor 2", "read", builtin=True, org_id="org1")
|
||||
role = db.get_role("r1")
|
||||
assert role is not None
|
||||
# Original values preserved.
|
||||
assert role["name"] == "editor"
|
||||
assert role["display_name"] == "Editor"
|
||||
|
||||
def test_get_role_by_name(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
role = db.get_role_by_name("editor")
|
||||
assert role is not None
|
||||
assert role["role_id"] == "r1"
|
||||
|
||||
def test_get_role_by_name_nonexistent(self, db):
|
||||
assert db.get_role_by_name("nope") is None
|
||||
|
||||
def test_list_roles(self, db):
|
||||
db.create_role("r2", "beta", "Beta Role", "read", builtin=False, org_id="")
|
||||
db.create_role("r1", "alpha", "Alpha Role", "write", builtin=False, org_id="")
|
||||
roles = db.list_roles()
|
||||
assert len(roles) == 2
|
||||
# Ordered by name ascending.
|
||||
assert roles[0]["name"] == "alpha"
|
||||
assert roles[1]["name"] == "beta"
|
||||
|
||||
def test_list_roles_filter_org(self, db):
|
||||
db.create_role("r1", "role_a", "A", "read", builtin=False, org_id="org1")
|
||||
db.create_role("r2", "role_b", "B", "read", builtin=False, org_id="org2")
|
||||
db.create_role("r3", "role_c", "C", "read", builtin=False, org_id="org1")
|
||||
result = db.list_roles(org_id="org1")
|
||||
assert len(result) == 2
|
||||
assert {r["role_id"] for r in result} == {"r1", "r3"}
|
||||
|
||||
def test_update_role(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
ok = db.update_role("r1", permissions="read,write,approve", display_name="Senior Editor")
|
||||
assert ok is True
|
||||
role = db.get_role("r1")
|
||||
assert role is not None
|
||||
assert role["permissions"] == "read,write,approve"
|
||||
assert role["display_name"] == "Senior Editor"
|
||||
|
||||
def test_update_role_nonexistent(self, db):
|
||||
assert db.update_role("missing", permissions="read") is False
|
||||
|
||||
def test_delete_role(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read", builtin=False, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.assign_role("u1", "r1")
|
||||
# Verify assignment exists.
|
||||
assert len(db.list_user_roles("u1")) == 1
|
||||
ok = db.delete_role("r1")
|
||||
assert ok is True
|
||||
assert db.get_role("r1") is None
|
||||
# Cascade: user_roles for this role should be gone.
|
||||
assert len(db.list_user_roles("u1")) == 0
|
||||
|
||||
def test_delete_role_nonexistent(self, db):
|
||||
assert db.delete_role("missing") is False
|
||||
|
||||
def test_assign_role(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.assign_role("u1", "r1", assigned_by="admin")
|
||||
roles = db.list_user_roles("u1")
|
||||
assert len(roles) == 1
|
||||
assert roles[0]["role_id"] == "r1"
|
||||
assert roles[0]["assigned_by"] == "admin"
|
||||
|
||||
def test_assign_role_idempotent(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.assign_role("u1", "r1")
|
||||
# Second assign should not raise.
|
||||
db.assign_role("u1", "r1")
|
||||
roles = db.list_user_roles("u1")
|
||||
assert len(roles) == 1
|
||||
|
||||
def test_unassign_role(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.assign_role("u1", "r1")
|
||||
ok = db.unassign_role("u1", "r1")
|
||||
assert ok is True
|
||||
assert len(db.list_user_roles("u1")) == 0
|
||||
|
||||
def test_unassign_role_nonexistent(self, db):
|
||||
assert db.unassign_role("u1", "r1") is False
|
||||
|
||||
def test_list_user_roles(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
db.create_role("r2", "viewer", "Viewer", "read", builtin=True, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.assign_role("u1", "r1", assigned_by="admin")
|
||||
db.assign_role("u1", "r2", assigned_by="system")
|
||||
roles = db.list_user_roles("u1")
|
||||
assert len(roles) == 2
|
||||
# Each entry should have joined role fields plus assignment metadata.
|
||||
for r in roles:
|
||||
assert "role_id" in r
|
||||
assert "name" in r
|
||||
assert "permissions" in r
|
||||
assert "assigned_by" in r
|
||||
assert "assignment_created" in r
|
||||
|
||||
def test_get_user_permissions(self, db):
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
db.create_role("r2", "approver", "Approver", "approve,read", builtin=False, org_id="")
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
db.assign_role("u1", "r1")
|
||||
db.assign_role("u1", "r2")
|
||||
perms = db.get_user_permissions("u1")
|
||||
assert perms == {"read", "write", "approve"}
|
||||
|
||||
def test_get_user_permissions_no_roles(self, db):
|
||||
db.create_user("u1", "alice", "Alice", "$2b$hash")
|
||||
assert db.get_user_permissions("u1") == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Organizations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOrgCRUD:
|
||||
def test_create_org(self, db):
|
||||
db.create_org("org1", "acme", "Acme Corp", '{"plan":"pro"}')
|
||||
org = db.get_org("org1")
|
||||
assert org is not None
|
||||
assert org["org_id"] == "org1"
|
||||
assert org["name"] == "acme"
|
||||
assert org["display_name"] == "Acme Corp"
|
||||
assert org["settings"] == '{"plan":"pro"}'
|
||||
assert "created" in org
|
||||
assert "updated" in org
|
||||
|
||||
def test_get_org_nonexistent(self, db):
|
||||
assert db.get_org("nope") is None
|
||||
|
||||
def test_create_org_idempotent(self, db):
|
||||
db.create_org("org1", "acme", "Acme Corp")
|
||||
db.create_org("org1", "acme2", "Acme 2")
|
||||
org = db.get_org("org1")
|
||||
assert org is not None
|
||||
assert org["name"] == "acme"
|
||||
|
||||
def test_list_orgs(self, db):
|
||||
db.create_org("o2", "beta", "Beta Inc")
|
||||
db.create_org("o1", "alpha", "Alpha LLC")
|
||||
orgs = db.list_orgs()
|
||||
assert len(orgs) == 2
|
||||
# Ordered by name ascending.
|
||||
assert orgs[0]["name"] == "alpha"
|
||||
assert orgs[1]["name"] == "beta"
|
||||
|
||||
def test_update_org(self, db):
|
||||
db.create_org("org1", "acme", "Acme Corp")
|
||||
ok = db.update_org(
|
||||
"org1", display_name="Acme Corp Global", settings='{"plan":"enterprise"}'
|
||||
)
|
||||
assert ok is True
|
||||
org = db.get_org("org1")
|
||||
assert org is not None
|
||||
assert org["display_name"] == "Acme Corp Global"
|
||||
assert org["settings"] == '{"plan":"enterprise"}'
|
||||
|
||||
def test_update_org_nonexistent(self, db):
|
||||
assert db.update_org("missing", display_name="X") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool Policies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolPolicyCRUD:
|
||||
def test_create_tool_policy(self, db):
|
||||
db.create_tool_policy(
|
||||
"p1",
|
||||
"deny-bash",
|
||||
"bash*",
|
||||
"deny",
|
||||
priority=100,
|
||||
org_id="org1",
|
||||
enabled=True,
|
||||
created_by="admin",
|
||||
)
|
||||
pol = db.get_tool_policy("p1")
|
||||
assert pol is not None
|
||||
assert pol["policy_id"] == "p1"
|
||||
assert pol["name"] == "deny-bash"
|
||||
assert pol["tool_pattern"] == "bash*"
|
||||
assert pol["action"] == "deny"
|
||||
assert pol["priority"] == 100
|
||||
assert pol["org_id"] == "org1"
|
||||
assert pol["enabled"] is True
|
||||
assert pol["created_by"] == "admin"
|
||||
|
||||
def test_get_tool_policy_nonexistent(self, db):
|
||||
assert db.get_tool_policy("missing") is None
|
||||
|
||||
def test_list_tool_policies_ordered_by_priority(self, db):
|
||||
db.create_tool_policy("p1", "low", "*", "allow", priority=10)
|
||||
db.create_tool_policy("p2", "high", "*", "deny", priority=100)
|
||||
db.create_tool_policy("p3", "mid", "*", "ask", priority=50)
|
||||
policies = db.list_tool_policies()
|
||||
assert len(policies) == 3
|
||||
# DESC priority order.
|
||||
assert policies[0]["priority"] == 100
|
||||
assert policies[1]["priority"] == 50
|
||||
assert policies[2]["priority"] == 10
|
||||
|
||||
def test_update_tool_policy(self, db):
|
||||
db.create_tool_policy("p1", "deny-bash", "bash*", "deny", priority=100)
|
||||
ok = db.update_tool_policy("p1", action="allow", priority=50)
|
||||
assert ok is True
|
||||
pol = db.get_tool_policy("p1")
|
||||
assert pol is not None
|
||||
assert pol["action"] == "allow"
|
||||
assert pol["priority"] == 50
|
||||
|
||||
def test_update_tool_policy_nonexistent(self, db):
|
||||
assert db.update_tool_policy("missing", action="deny") is False
|
||||
|
||||
def test_delete_tool_policy(self, db):
|
||||
db.create_tool_policy("p1", "deny-bash", "bash*", "deny", priority=100)
|
||||
ok = db.delete_tool_policy("p1")
|
||||
assert ok is True
|
||||
assert db.get_tool_policy("p1") is None
|
||||
|
||||
def test_delete_tool_policy_nonexistent(self, db):
|
||||
assert db.delete_tool_policy("missing") is False
|
||||
|
||||
def test_enabled_as_bool(self, db):
|
||||
db.create_tool_policy("p1", "on", "*", "allow", priority=0, enabled=True)
|
||||
db.create_tool_policy("p2", "off", "*", "deny", priority=0, enabled=False)
|
||||
p1 = db.get_tool_policy("p1")
|
||||
p2 = db.get_tool_policy("p2")
|
||||
assert p1 is not None
|
||||
assert p2 is not None
|
||||
assert p1["enabled"] is True
|
||||
assert isinstance(p1["enabled"], bool)
|
||||
assert p2["enabled"] is False
|
||||
assert isinstance(p2["enabled"], bool)
|
||||
|
||||
def test_list_policies_filter_org(self, db):
|
||||
db.create_tool_policy("p1", "a", "*", "allow", priority=0, org_id="org1")
|
||||
db.create_tool_policy("p2", "b", "*", "deny", priority=0, org_id="org2")
|
||||
db.create_tool_policy("p3", "c", "*", "ask", priority=0, org_id="org1")
|
||||
result = db.list_tool_policies(org_id="org1")
|
||||
assert len(result) == 2
|
||||
assert {r["policy_id"] for r in result} == {"p1", "p3"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt Templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPromptTemplateCRUD:
|
||||
def test_create_prompt_template(self, db):
|
||||
db.create_prompt_template(
|
||||
"t1",
|
||||
"greeting",
|
||||
"general",
|
||||
"Hello {{name}}!",
|
||||
variables='["name"]',
|
||||
is_default=True,
|
||||
org_id="org1",
|
||||
created_by="admin",
|
||||
)
|
||||
tpl = db.get_prompt_template("t1")
|
||||
assert tpl is not None
|
||||
assert tpl["template_id"] == "t1"
|
||||
assert tpl["name"] == "greeting"
|
||||
assert tpl["category"] == "general"
|
||||
assert tpl["content"] == "Hello {{name}}!"
|
||||
assert tpl["variables"] == '["name"]'
|
||||
assert tpl["is_default"] is True
|
||||
assert tpl["org_id"] == "org1"
|
||||
assert tpl["created_by"] == "admin"
|
||||
|
||||
def test_get_prompt_template_nonexistent(self, db):
|
||||
assert db.get_prompt_template("missing") is None
|
||||
|
||||
def test_list_prompt_templates_ordered_by_name(self, db):
|
||||
db.create_prompt_template("t2", "beta", "general", "B")
|
||||
db.create_prompt_template("t1", "alpha", "general", "A")
|
||||
templates = db.list_prompt_templates()
|
||||
assert len(templates) == 2
|
||||
assert templates[0]["name"] == "alpha"
|
||||
assert templates[1]["name"] == "beta"
|
||||
|
||||
def test_list_prompt_templates_filter_org(self, db):
|
||||
db.create_prompt_template("t1", "a", "general", "A", org_id="org1")
|
||||
db.create_prompt_template("t2", "b", "general", "B", org_id="org2")
|
||||
result = db.list_prompt_templates(org_id="org1")
|
||||
assert len(result) == 1
|
||||
assert result[0]["template_id"] == "t1"
|
||||
|
||||
def test_update_prompt_template(self, db):
|
||||
db.create_prompt_template("t1", "greeting", "general", "Hello!")
|
||||
ok = db.update_prompt_template("t1", content="Hi there!", category="custom")
|
||||
assert ok is True
|
||||
tpl = db.get_prompt_template("t1")
|
||||
assert tpl is not None
|
||||
assert tpl["content"] == "Hi there!"
|
||||
assert tpl["category"] == "custom"
|
||||
|
||||
def test_update_prompt_template_nonexistent(self, db):
|
||||
assert db.update_prompt_template("missing", content="x") is False
|
||||
|
||||
def test_delete_prompt_template(self, db):
|
||||
db.create_prompt_template("t1", "greeting", "general", "Hello!")
|
||||
ok = db.delete_prompt_template("t1")
|
||||
assert ok is True
|
||||
assert db.get_prompt_template("t1") is None
|
||||
|
||||
def test_delete_prompt_template_nonexistent(self, db):
|
||||
assert db.delete_prompt_template("missing") is False
|
||||
|
||||
def test_is_default_as_bool(self, db):
|
||||
db.create_prompt_template("t1", "default_one", "general", "D", is_default=True)
|
||||
db.create_prompt_template("t2", "not_default", "general", "N", is_default=False)
|
||||
t1 = db.get_prompt_template("t1")
|
||||
t2 = db.get_prompt_template("t2")
|
||||
assert t1 is not None
|
||||
assert t2 is not None
|
||||
assert t1["is_default"] is True
|
||||
assert isinstance(t1["is_default"], bool)
|
||||
assert t2["is_default"] is False
|
||||
assert isinstance(t2["is_default"], bool)
|
||||
|
||||
def test_create_with_mcp_origin(self, db):
|
||||
db.create_prompt_template(
|
||||
"t1",
|
||||
"mcp__srv__prompt",
|
||||
"mcp",
|
||||
"content",
|
||||
variables="[]",
|
||||
is_default=False,
|
||||
org_id="",
|
||||
created_by="",
|
||||
origin="mcp",
|
||||
mcp_server="srv",
|
||||
readonly=True,
|
||||
)
|
||||
tpl = db.get_prompt_template("t1")
|
||||
assert tpl is not None
|
||||
assert tpl["origin"] == "mcp"
|
||||
assert tpl["mcp_server"] == "srv"
|
||||
assert tpl["readonly"] is True
|
||||
assert isinstance(tpl["readonly"], bool)
|
||||
|
||||
def test_default_origin_values(self, db):
|
||||
db.create_prompt_template("t1", "basic", "general", "Hello")
|
||||
tpl = db.get_prompt_template("t1")
|
||||
assert tpl is not None
|
||||
assert tpl["origin"] == "manual"
|
||||
assert tpl["mcp_server"] == ""
|
||||
assert tpl["readonly"] is False
|
||||
|
||||
def test_get_prompt_template_by_name(self, db):
|
||||
db.create_prompt_template("t1", "greeting", "general", "Hello!")
|
||||
tpl = db.get_prompt_template_by_name("greeting")
|
||||
assert tpl is not None
|
||||
assert tpl["template_id"] == "t1"
|
||||
assert tpl["name"] == "greeting"
|
||||
|
||||
def test_get_prompt_template_by_name_nonexistent(self, db):
|
||||
assert db.get_prompt_template_by_name("nope") is None
|
||||
|
||||
def test_list_default_templates(self, db):
|
||||
db.create_prompt_template("t1", "alpha", "general", "A", is_default=True)
|
||||
db.create_prompt_template("t2", "beta", "general", "B", is_default=False)
|
||||
db.create_prompt_template("t3", "gamma", "general", "C", is_default=True)
|
||||
result = db.list_default_templates()
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "alpha"
|
||||
assert result[1]["name"] == "gamma"
|
||||
|
||||
def test_list_default_templates_empty(self, db):
|
||||
db.create_prompt_template("t1", "alpha", "general", "A", is_default=False)
|
||||
assert db.list_default_templates() == []
|
||||
|
||||
def test_list_prompt_templates_by_origin(self, db):
|
||||
db.create_prompt_template("t1", "manual_one", "general", "A", origin="manual")
|
||||
db.create_prompt_template("t2", "mcp_one", "mcp", "B", origin="mcp", mcp_server="srv1")
|
||||
db.create_prompt_template("t3", "mcp_two", "mcp", "C", origin="mcp", mcp_server="srv2")
|
||||
result = db.list_prompt_templates_by_origin("mcp")
|
||||
assert len(result) == 2
|
||||
names = [r["name"] for r in result]
|
||||
assert "mcp_one" in names
|
||||
assert "mcp_two" in names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Usage Events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUsageEvents:
|
||||
def test_record_usage_event(self, db):
|
||||
db.record_usage_event(
|
||||
"ev1",
|
||||
user_id="u1",
|
||||
ws_id="ws1",
|
||||
node_id="n1",
|
||||
model="gpt-5",
|
||||
prompt_tokens=100,
|
||||
completion_tokens=50,
|
||||
tool_calls_count=2,
|
||||
)
|
||||
# Verify via query_usage (no group_by returns summary).
|
||||
result = db.query_usage(since="2000-01-01T00:00:00")
|
||||
assert len(result) == 1
|
||||
assert result[0]["prompt_tokens"] == 100
|
||||
assert result[0]["completion_tokens"] == 50
|
||||
assert result[0]["tool_calls_count"] == 2
|
||||
|
||||
def test_query_usage_summary(self, db):
|
||||
db.record_usage_event("ev1", model="gpt-5", prompt_tokens=100, completion_tokens=50)
|
||||
db.record_usage_event("ev2", model="gpt-5", prompt_tokens=200, completion_tokens=75)
|
||||
result = db.query_usage(since="2000-01-01T00:00:00")
|
||||
assert len(result) == 1
|
||||
assert result[0]["prompt_tokens"] == 300
|
||||
assert result[0]["completion_tokens"] == 125
|
||||
|
||||
def test_query_usage_by_day(self, db):
|
||||
# Insert events with known timestamps by directly inserting rows.
|
||||
from turnstone.core.storage._schema import usage_events
|
||||
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(usage_events),
|
||||
[
|
||||
{
|
||||
"event_id": "e1",
|
||||
"timestamp": "2026-03-01T10:00:00",
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "gpt-5",
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"tool_calls_count": 0,
|
||||
"created": "2026-03-01T10:00:00",
|
||||
},
|
||||
{
|
||||
"event_id": "e2",
|
||||
"timestamp": "2026-03-01T14:00:00",
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "gpt-5",
|
||||
"prompt_tokens": 50,
|
||||
"completion_tokens": 25,
|
||||
"tool_calls_count": 0,
|
||||
"created": "2026-03-01T14:00:00",
|
||||
},
|
||||
{
|
||||
"event_id": "e3",
|
||||
"timestamp": "2026-03-02T08:00:00",
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "gpt-5",
|
||||
"prompt_tokens": 200,
|
||||
"completion_tokens": 100,
|
||||
"tool_calls_count": 0,
|
||||
"created": "2026-03-02T08:00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
result = db.query_usage(since="2026-03-01T00:00:00", group_by="day")
|
||||
assert len(result) == 2
|
||||
assert result[0]["key"] == "2026-03-01"
|
||||
assert result[0]["prompt_tokens"] == 150
|
||||
assert result[1]["key"] == "2026-03-02"
|
||||
assert result[1]["prompt_tokens"] == 200
|
||||
|
||||
def test_query_usage_by_model(self, db):
|
||||
from turnstone.core.storage._schema import usage_events
|
||||
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(usage_events),
|
||||
[
|
||||
{
|
||||
"event_id": "e1",
|
||||
"timestamp": "2026-03-01T10:00:00",
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "gpt-5",
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"tool_calls_count": 0,
|
||||
"created": "2026-03-01T10:00:00",
|
||||
},
|
||||
{
|
||||
"event_id": "e2",
|
||||
"timestamp": "2026-03-01T10:00:00",
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "claude-4",
|
||||
"prompt_tokens": 200,
|
||||
"completion_tokens": 100,
|
||||
"tool_calls_count": 1,
|
||||
"created": "2026-03-01T10:00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
result = db.query_usage(since="2026-03-01T00:00:00", group_by="model")
|
||||
assert len(result) == 2
|
||||
keys = [r["key"] for r in result]
|
||||
assert "gpt-5" in keys
|
||||
assert "claude-4" in keys
|
||||
|
||||
def test_query_usage_by_user(self, db):
|
||||
from turnstone.core.storage._schema import usage_events
|
||||
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(usage_events),
|
||||
[
|
||||
{
|
||||
"event_id": "e1",
|
||||
"timestamp": "2026-03-01T10:00:00",
|
||||
"user_id": "u1",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "",
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"tool_calls_count": 0,
|
||||
"created": "2026-03-01T10:00:00",
|
||||
},
|
||||
{
|
||||
"event_id": "e2",
|
||||
"timestamp": "2026-03-01T10:00:00",
|
||||
"user_id": "u2",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "",
|
||||
"prompt_tokens": 300,
|
||||
"completion_tokens": 150,
|
||||
"tool_calls_count": 2,
|
||||
"created": "2026-03-01T10:00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
result = db.query_usage(since="2026-03-01T00:00:00", group_by="user")
|
||||
assert len(result) == 2
|
||||
by_key = {r["key"]: r for r in result}
|
||||
assert by_key["u1"]["prompt_tokens"] == 100
|
||||
assert by_key["u2"]["prompt_tokens"] == 300
|
||||
|
||||
def test_query_usage_filter_model(self, db):
|
||||
from turnstone.core.storage._schema import usage_events
|
||||
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(usage_events),
|
||||
[
|
||||
{
|
||||
"event_id": "e1",
|
||||
"timestamp": "2026-03-01T10:00:00",
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "gpt-5",
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"tool_calls_count": 0,
|
||||
"created": "2026-03-01T10:00:00",
|
||||
},
|
||||
{
|
||||
"event_id": "e2",
|
||||
"timestamp": "2026-03-01T10:00:00",
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "claude-4",
|
||||
"prompt_tokens": 200,
|
||||
"completion_tokens": 100,
|
||||
"tool_calls_count": 0,
|
||||
"created": "2026-03-01T10:00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
result = db.query_usage(since="2026-03-01T00:00:00", model="gpt-5")
|
||||
assert len(result) == 1
|
||||
assert result[0]["prompt_tokens"] == 100
|
||||
|
||||
def test_prune_usage_events(self, db):
|
||||
from turnstone.core.storage._schema import usage_events
|
||||
|
||||
old_ts = "2020-01-01T00:00:00"
|
||||
now_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(usage_events),
|
||||
[
|
||||
{
|
||||
"event_id": "old",
|
||||
"timestamp": old_ts,
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "",
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 5,
|
||||
"tool_calls_count": 0,
|
||||
"created": old_ts,
|
||||
},
|
||||
{
|
||||
"event_id": "new",
|
||||
"timestamp": now_ts,
|
||||
"user_id": "",
|
||||
"ws_id": "",
|
||||
"node_id": "",
|
||||
"model": "",
|
||||
"prompt_tokens": 20,
|
||||
"completion_tokens": 10,
|
||||
"tool_calls_count": 0,
|
||||
"created": now_ts,
|
||||
},
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
pruned = db.prune_usage_events(retention_days=30)
|
||||
assert pruned == 1
|
||||
# Only the recent event should remain.
|
||||
result = db.query_usage(since="2000-01-01T00:00:00")
|
||||
assert result[0]["prompt_tokens"] == 20
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit Events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuditEvents:
|
||||
def test_record_audit_event(self, db):
|
||||
db.record_audit_event(
|
||||
"a1",
|
||||
user_id="u1",
|
||||
action="role.create",
|
||||
resource_type="role",
|
||||
resource_id="r1",
|
||||
detail='{"name":"editor"}',
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
events = db.list_audit_events()
|
||||
assert len(events) == 1
|
||||
ev = events[0]
|
||||
assert ev["event_id"] == "a1"
|
||||
assert ev["user_id"] == "u1"
|
||||
assert ev["action"] == "role.create"
|
||||
assert ev["resource_type"] == "role"
|
||||
assert ev["resource_id"] == "r1"
|
||||
assert ev["detail"] == '{"name":"editor"}'
|
||||
assert ev["ip_address"] == "127.0.0.1"
|
||||
|
||||
def test_list_audit_events(self, db):
|
||||
db.record_audit_event("a1", action="login")
|
||||
db.record_audit_event("a2", action="logout")
|
||||
events = db.list_audit_events()
|
||||
assert len(events) == 2
|
||||
# Ordered by timestamp DESC — most recent first.
|
||||
# Both created in quick succession with same-second granularity,
|
||||
# but the order should still be deterministic (DESC).
|
||||
assert {e["event_id"] for e in events} == {"a1", "a2"}
|
||||
|
||||
def test_list_audit_events_filter_action(self, db):
|
||||
db.record_audit_event("a1", action="login")
|
||||
db.record_audit_event("a2", action="logout")
|
||||
db.record_audit_event("a3", action="login")
|
||||
events = db.list_audit_events(action="login")
|
||||
assert len(events) == 2
|
||||
assert all(e["action"] == "login" for e in events)
|
||||
|
||||
def test_list_audit_events_filter_user(self, db):
|
||||
db.record_audit_event("a1", user_id="u1", action="login")
|
||||
db.record_audit_event("a2", user_id="u2", action="login")
|
||||
events = db.list_audit_events(user_id="u1")
|
||||
assert len(events) == 1
|
||||
assert events[0]["user_id"] == "u1"
|
||||
|
||||
def test_list_audit_events_pagination(self, db):
|
||||
for i in range(5):
|
||||
db.record_audit_event(f"a{i}", action="test")
|
||||
page1 = db.list_audit_events(limit=2, offset=0)
|
||||
page2 = db.list_audit_events(limit=2, offset=2)
|
||||
page3 = db.list_audit_events(limit=2, offset=4)
|
||||
assert len(page1) == 2
|
||||
assert len(page2) == 2
|
||||
assert len(page3) == 1
|
||||
# No overlap.
|
||||
ids = [e["event_id"] for e in page1 + page2 + page3]
|
||||
assert len(set(ids)) == 5
|
||||
|
||||
def test_count_audit_events(self, db):
|
||||
db.record_audit_event("a1", action="login")
|
||||
db.record_audit_event("a2", action="logout")
|
||||
db.record_audit_event("a3", action="login")
|
||||
assert db.count_audit_events() == 3
|
||||
assert db.count_audit_events(action="login") == 2
|
||||
assert db.count_audit_events(action="logout") == 1
|
||||
|
||||
def test_count_audit_events_filter_user(self, db):
|
||||
db.record_audit_event("a1", user_id="u1", action="login")
|
||||
db.record_audit_event("a2", user_id="u2", action="login")
|
||||
assert db.count_audit_events(user_id="u1") == 1
|
||||
|
||||
def test_prune_audit_events(self, db):
|
||||
from turnstone.core.storage._schema import audit_events
|
||||
|
||||
old_ts = "2020-01-01T00:00:00"
|
||||
now_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with db._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(audit_events),
|
||||
[
|
||||
{
|
||||
"event_id": "old",
|
||||
"timestamp": old_ts,
|
||||
"user_id": "",
|
||||
"action": "test",
|
||||
"resource_type": "",
|
||||
"resource_id": "",
|
||||
"detail": "{}",
|
||||
"ip_address": "",
|
||||
"created": old_ts,
|
||||
},
|
||||
{
|
||||
"event_id": "new",
|
||||
"timestamp": now_ts,
|
||||
"user_id": "",
|
||||
"action": "test",
|
||||
"resource_type": "",
|
||||
"resource_id": "",
|
||||
"detail": "{}",
|
||||
"ip_address": "",
|
||||
"created": now_ts,
|
||||
},
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
pruned = db.prune_audit_events(retention_days=30)
|
||||
assert pruned == 1
|
||||
assert db.count_audit_events() == 1
|
||||
+660
-1
@@ -51,6 +51,83 @@ def _fake_openai_tool(name: str = "mcp__test__search") -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _fake_mcp_resource(
|
||||
uri: str = "file:///README.md",
|
||||
name: str = "readme",
|
||||
description: str = "Project readme",
|
||||
mime_type: str = "text/plain",
|
||||
) -> MagicMock:
|
||||
"""Create a mock MCP Resource object matching the SDK's Resource type."""
|
||||
res = MagicMock()
|
||||
res.uri = uri
|
||||
res.name = name
|
||||
res.description = description
|
||||
res.mimeType = mime_type
|
||||
return res
|
||||
|
||||
|
||||
def _fake_resource_dict(
|
||||
uri: str = "file:///README.md",
|
||||
name: str = "readme",
|
||||
description: str = "Project readme",
|
||||
mime_type: str = "text/plain",
|
||||
server: str = "test",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a fake resource dict as stored in per-server state."""
|
||||
return {
|
||||
"uri": uri,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"mimeType": mime_type,
|
||||
"server": server,
|
||||
}
|
||||
|
||||
|
||||
def _fake_mcp_prompt(
|
||||
name: str = "code_review",
|
||||
description: str = "Generate a code review",
|
||||
arguments: list[dict[str, Any]] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Create a mock MCP Prompt object matching the SDK's Prompt type."""
|
||||
prompt = MagicMock()
|
||||
prompt.name = name
|
||||
prompt.description = description
|
||||
if arguments is None:
|
||||
arg = MagicMock()
|
||||
arg.name = "language"
|
||||
arg.description = "Programming language"
|
||||
arg.required = True
|
||||
prompt.arguments = [arg]
|
||||
else:
|
||||
mock_args = []
|
||||
for a in arguments:
|
||||
arg = MagicMock()
|
||||
arg.name = a["name"]
|
||||
arg.description = a.get("description", "")
|
||||
arg.required = a.get("required", False)
|
||||
mock_args.append(arg)
|
||||
prompt.arguments = mock_args
|
||||
return prompt
|
||||
|
||||
|
||||
def _fake_prompt_dict(
|
||||
name: str = "mcp__test__code_review",
|
||||
original_name: str = "code_review",
|
||||
server: str = "test",
|
||||
description: str = "Generate a code review",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a fake prompt dict as stored in per-server state."""
|
||||
return {
|
||||
"name": name,
|
||||
"original_name": original_name,
|
||||
"server": server,
|
||||
"description": description,
|
||||
"arguments": [
|
||||
{"name": "language", "description": "Programming language", "required": True}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -453,6 +530,23 @@ class TestRebuildTools:
|
||||
|
||||
|
||||
class TestRefreshServer:
|
||||
@staticmethod
|
||||
def _add_empty_resource_prompt_mocks(
|
||||
mgr: MCPClientManager, server_name: str, mock_session: MagicMock
|
||||
) -> None:
|
||||
"""Add empty list_resources/list_prompts mocks so _refresh_server works."""
|
||||
mgr._supports_resources[server_name] = True
|
||||
mgr._supports_prompts[server_name] = True
|
||||
empty_res = MagicMock()
|
||||
empty_res.resources = []
|
||||
mock_session.list_resources = AsyncMock(return_value=empty_res)
|
||||
empty_tmpl = MagicMock()
|
||||
empty_tmpl.resourceTemplates = []
|
||||
mock_session.list_resource_templates = AsyncMock(return_value=empty_tmpl)
|
||||
empty_prompts = MagicMock()
|
||||
empty_prompts.prompts = []
|
||||
mock_session.list_prompts = AsyncMock(return_value=empty_prompts)
|
||||
|
||||
def test_refresh_detects_added_tools(self):
|
||||
async def _run() -> None:
|
||||
mgr = MCPClientManager({})
|
||||
@@ -463,6 +557,7 @@ class TestRefreshServer:
|
||||
_fake_mcp_tool("create"), # new tool
|
||||
]
|
||||
mock_session.list_tools = AsyncMock(return_value=mock_result)
|
||||
self._add_empty_resource_prompt_mocks(mgr, "github", mock_session)
|
||||
mgr._sessions["github"] = mock_session
|
||||
mgr._per_server_tools["github"] = [_fake_openai_tool("mcp__github__search")]
|
||||
mgr._rebuild_tools()
|
||||
@@ -481,6 +576,7 @@ class TestRefreshServer:
|
||||
mock_result = MagicMock()
|
||||
mock_result.tools = [] # all tools removed
|
||||
mock_session.list_tools = AsyncMock(return_value=mock_result)
|
||||
self._add_empty_resource_prompt_mocks(mgr, "github", mock_session)
|
||||
mgr._sessions["github"] = mock_session
|
||||
mgr._per_server_tools["github"] = [_fake_openai_tool("mcp__github__search")]
|
||||
mgr._rebuild_tools()
|
||||
@@ -499,6 +595,7 @@ class TestRefreshServer:
|
||||
mock_result = MagicMock()
|
||||
mock_result.tools = [_fake_mcp_tool("search")]
|
||||
mock_session.list_tools = AsyncMock(return_value=mock_result)
|
||||
self._add_empty_resource_prompt_mocks(mgr, "github", mock_session)
|
||||
mgr._sessions["github"] = mock_session
|
||||
mgr._per_server_tools["github"] = [_fake_openai_tool("mcp__github__search")]
|
||||
mgr._rebuild_tools()
|
||||
@@ -513,7 +610,7 @@ class TestRefreshServer:
|
||||
async def _run() -> None:
|
||||
mgr = MCPClientManager({})
|
||||
with pytest.raises(RuntimeError, match="not connected"):
|
||||
await mgr._refresh_server("ghost")
|
||||
await mgr._refresh_server_tools("ghost")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
@@ -709,3 +806,565 @@ class TestSessionRefresh:
|
||||
session.handle_command("/mcp refresh")
|
||||
session.ui.on_error.assert_called_once()
|
||||
assert "MCP refresh failed" in session.ui.on_error.call_args[0][0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP Resources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMCPResources:
|
||||
def test_resource_discovery(self):
|
||||
"""Mock list_resources() returning 2 resources, verify get_resources()."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_resources = {
|
||||
"fs": [
|
||||
_fake_resource_dict("file:///a.txt", "a", "File A", "text/plain", "fs"),
|
||||
_fake_resource_dict("file:///b.txt", "b", "File B", "text/plain", "fs"),
|
||||
],
|
||||
}
|
||||
mgr._rebuild_resources()
|
||||
resources = mgr.get_resources()
|
||||
assert len(resources) == 2
|
||||
uris = {r["uri"] for r in resources}
|
||||
assert uris == {"file:///a.txt", "file:///b.txt"}
|
||||
assert all(r["server"] == "fs" for r in resources)
|
||||
|
||||
def test_rebuild_resources_copy_on_write(self):
|
||||
"""Verify mutation safety — get_resources() returns independent copy."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_resources = {
|
||||
"a": [_fake_resource_dict("file:///x", "x", "", "", "a")],
|
||||
}
|
||||
mgr._rebuild_resources()
|
||||
old_resources = mgr._resources
|
||||
old_map = mgr._resource_map
|
||||
mgr._per_server_resources["b"] = [_fake_resource_dict("file:///y", "y", "", "", "b")]
|
||||
mgr._rebuild_resources()
|
||||
assert mgr._resources is not old_resources
|
||||
assert mgr._resource_map is not old_map
|
||||
|
||||
def test_get_resources_returns_copy(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_resources = {
|
||||
"a": [_fake_resource_dict("file:///x", "x", "", "", "a")],
|
||||
}
|
||||
mgr._rebuild_resources()
|
||||
resources = mgr.get_resources()
|
||||
assert len(resources) == 1
|
||||
resources.clear()
|
||||
assert len(mgr.get_resources()) == 1
|
||||
|
||||
def test_read_resource_sync(self):
|
||||
"""Mock session.read_resource(), verify text extraction."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._resource_map = {"file:///readme": ("fs", "file:///readme")}
|
||||
mock_session = MagicMock()
|
||||
mgr._sessions["fs"] = mock_session
|
||||
mgr._loop = asyncio.new_event_loop()
|
||||
|
||||
# Mock the read_resource result
|
||||
text_content = MagicMock(spec=["text"])
|
||||
text_content.text = "Hello, world!"
|
||||
mock_result = MagicMock()
|
||||
mock_result.contents = [text_content]
|
||||
mock_session.read_resource = AsyncMock(return_value=mock_result)
|
||||
|
||||
thread = None
|
||||
try:
|
||||
thread = __import__("threading").Thread(target=mgr._loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
output = mgr.read_resource_sync("file:///readme", timeout=5)
|
||||
assert output == "Hello, world!"
|
||||
mock_session.read_resource.assert_awaited_once_with("file:///readme")
|
||||
finally:
|
||||
mgr._loop.call_soon_threadsafe(mgr._loop.stop)
|
||||
if thread:
|
||||
thread.join(timeout=5)
|
||||
mgr._loop.close()
|
||||
|
||||
def test_read_resource_sync_blob(self):
|
||||
"""Verify base64 blob extraction."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._resource_map = {"file:///img.png": ("fs", "file:///img.png")}
|
||||
mock_session = MagicMock()
|
||||
mgr._sessions["fs"] = mock_session
|
||||
mgr._loop = asyncio.new_event_loop()
|
||||
|
||||
blob_content = MagicMock(spec=["blob"])
|
||||
blob_content.blob = "aGVsbG8="
|
||||
mock_result = MagicMock()
|
||||
mock_result.contents = [blob_content]
|
||||
mock_session.read_resource = AsyncMock(return_value=mock_result)
|
||||
|
||||
thread = None
|
||||
try:
|
||||
thread = __import__("threading").Thread(target=mgr._loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
output = mgr.read_resource_sync("file:///img.png", timeout=5)
|
||||
assert output == "aGVsbG8="
|
||||
finally:
|
||||
mgr._loop.call_soon_threadsafe(mgr._loop.stop)
|
||||
if thread:
|
||||
thread.join(timeout=5)
|
||||
mgr._loop.close()
|
||||
|
||||
def test_read_resource_sync_unknown_uri(self):
|
||||
mgr = MCPClientManager({})
|
||||
with pytest.raises(ValueError, match="Unknown MCP resource"):
|
||||
mgr.read_resource_sync("file:///nonexistent")
|
||||
|
||||
def test_read_resource_sync_disconnected(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr._resource_map = {"file:///x": ("dead", "file:///x")}
|
||||
with pytest.raises(RuntimeError, match="not connected"):
|
||||
mgr.read_resource_sync("file:///x")
|
||||
|
||||
def test_read_resource_sync_timeout(self):
|
||||
"""Verify timeout handling."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._resource_map = {"file:///x": ("fs", "file:///x")}
|
||||
mock_session = MagicMock()
|
||||
mgr._sessions["fs"] = mock_session
|
||||
mgr._loop = asyncio.new_event_loop()
|
||||
|
||||
async def _slow_read(_uri: str) -> None:
|
||||
await asyncio.sleep(10)
|
||||
|
||||
mock_session.read_resource = _slow_read
|
||||
|
||||
thread = None
|
||||
try:
|
||||
thread = __import__("threading").Thread(target=mgr._loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
with pytest.raises(TimeoutError):
|
||||
mgr.read_resource_sync("file:///x", timeout=1)
|
||||
finally:
|
||||
mgr._loop.call_soon_threadsafe(mgr._loop.stop)
|
||||
if thread:
|
||||
thread.join(timeout=5)
|
||||
mgr._loop.close()
|
||||
|
||||
def test_resource_listener_notification(self):
|
||||
"""Verify callback fires on rebuild."""
|
||||
mgr = MCPClientManager({})
|
||||
calls: list[int] = []
|
||||
mgr.add_resource_listener(lambda: calls.append(1))
|
||||
mgr._per_server_resources = {"a": [_fake_resource_dict()]}
|
||||
mgr._rebuild_resources()
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_resource_listener_remove(self):
|
||||
mgr = MCPClientManager({})
|
||||
calls: list[int] = []
|
||||
cb = lambda: calls.append(1) # noqa: E731
|
||||
mgr.add_resource_listener(cb)
|
||||
mgr.remove_resource_listener(cb)
|
||||
mgr._rebuild_resources()
|
||||
assert calls == []
|
||||
|
||||
def test_resource_listener_error_does_not_propagate(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr.add_resource_listener(lambda: 1 / 0)
|
||||
mgr._rebuild_resources() # should not raise
|
||||
|
||||
def test_resource_refresh_on_notification(self):
|
||||
"""Mock notification, verify re-fetch of resources."""
|
||||
|
||||
async def _run() -> None:
|
||||
mgr = MCPClientManager({})
|
||||
mock_session = MagicMock()
|
||||
mgr._sessions["fs"] = mock_session
|
||||
mgr._supports_resources["fs"] = True
|
||||
|
||||
# Initial state
|
||||
mgr._per_server_resources["fs"] = [
|
||||
_fake_resource_dict("file:///old", server="fs"),
|
||||
]
|
||||
mgr._rebuild_resources()
|
||||
assert len(mgr.get_resources()) == 1
|
||||
|
||||
# Mock the re-fetch returning a new resource
|
||||
new_res = _fake_mcp_resource("file:///new", "new")
|
||||
mock_res_result = MagicMock()
|
||||
mock_res_result.resources = [new_res]
|
||||
mock_session.list_resources = AsyncMock(return_value=mock_res_result)
|
||||
mock_tmpl_result = MagicMock()
|
||||
mock_tmpl_result.resourceTemplates = []
|
||||
mock_session.list_resource_templates = AsyncMock(return_value=mock_tmpl_result)
|
||||
|
||||
await mgr._refresh_server_resources("fs")
|
||||
resources = mgr.get_resources()
|
||||
assert len(resources) == 1
|
||||
assert resources[0]["uri"] == "file:///new"
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_rebuild_resources_empty(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_resources = {}
|
||||
mgr._rebuild_resources()
|
||||
assert mgr._resources == []
|
||||
assert mgr._resource_map == {}
|
||||
|
||||
def test_rebuild_resources_multi_server(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_resources = {
|
||||
"fs": [_fake_resource_dict("file:///a", server="fs")],
|
||||
"db": [_fake_resource_dict("db://table", name="table", server="db")],
|
||||
}
|
||||
mgr._rebuild_resources()
|
||||
assert len(mgr._resources) == 2
|
||||
assert mgr._resource_map["file:///a"] == ("fs", "file:///a")
|
||||
assert mgr._resource_map["db://table"] == ("db", "db://table")
|
||||
|
||||
def test_template_prefix_matching(self):
|
||||
"""Expanded URI matches template by prefix."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_resources = {
|
||||
"db": [
|
||||
{
|
||||
"uri": "db://tables/{table}/rows/{id}",
|
||||
"name": "row",
|
||||
"description": "A row",
|
||||
"mimeType": "application/json",
|
||||
"server": "db",
|
||||
"template": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
mgr._rebuild_resources()
|
||||
# Template should not be in resource_map
|
||||
assert "db://tables/{table}/rows/{id}" not in mgr._resource_map
|
||||
# But prefix matching should find it
|
||||
result = mgr._match_template("db://tables/users/rows/1")
|
||||
assert result is not None
|
||||
server, template_uri = result
|
||||
assert server == "db"
|
||||
assert template_uri == "db://tables/{table}/rows/{id}"
|
||||
|
||||
def test_template_longest_prefix_wins(self):
|
||||
"""When two templates have overlapping prefixes, the longer one wins."""
|
||||
mgr = MCPClientManager({})
|
||||
# Use templates with genuinely different prefix lengths:
|
||||
# "db://data/" (6 chars after scheme) vs "db://data/tables/" (13 chars after scheme)
|
||||
mgr._per_server_resources = {
|
||||
"short": [
|
||||
{
|
||||
"uri": "db://data/{collection}",
|
||||
"name": "collection",
|
||||
"description": "",
|
||||
"mimeType": "",
|
||||
"server": "short",
|
||||
"template": True,
|
||||
},
|
||||
],
|
||||
"long": [
|
||||
{
|
||||
"uri": "db://data/tables/{table}",
|
||||
"name": "table",
|
||||
"description": "",
|
||||
"mimeType": "",
|
||||
"server": "long",
|
||||
"template": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
mgr._rebuild_resources()
|
||||
# "db://data/tables/users" matches both prefixes ("db://data/" and
|
||||
# "db://data/tables/") — the longer one should win
|
||||
result = mgr._match_template("db://data/tables/users")
|
||||
assert result is not None
|
||||
server, template_uri = result
|
||||
assert server == "long"
|
||||
assert template_uri == "db://data/tables/{table}"
|
||||
# URI that only matches the short prefix
|
||||
result2 = mgr._match_template("db://data/views/active")
|
||||
assert result2 is not None
|
||||
assert result2[0] == "short"
|
||||
|
||||
def test_template_no_match_raises(self):
|
||||
"""Completely unrelated URI still raises ValueError."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_resources = {
|
||||
"db": [
|
||||
{
|
||||
"uri": "db://tables/{table}",
|
||||
"name": "table",
|
||||
"description": "",
|
||||
"mimeType": "",
|
||||
"server": "db",
|
||||
"template": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
mgr._rebuild_resources()
|
||||
assert mgr._match_template("file:///something") is None
|
||||
with pytest.raises(ValueError, match="Unknown MCP resource"):
|
||||
mgr.read_resource_sync("file:///something")
|
||||
|
||||
def test_read_resource_sync_with_template_uri(self):
|
||||
"""End-to-end: template discovered, expanded URI dispatched to correct server."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_resources = {
|
||||
"db": [
|
||||
{
|
||||
"uri": "db://tables/{table}/rows/{id}",
|
||||
"name": "row",
|
||||
"description": "A row",
|
||||
"mimeType": "application/json",
|
||||
"server": "db",
|
||||
"template": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
mgr._rebuild_resources()
|
||||
|
||||
mock_session = MagicMock()
|
||||
mgr._sessions["db"] = mock_session
|
||||
mgr._loop = asyncio.new_event_loop()
|
||||
|
||||
text_content = MagicMock(spec=["text"])
|
||||
text_content.text = '{"name": "Alice"}'
|
||||
mock_result = MagicMock()
|
||||
mock_result.contents = [text_content]
|
||||
mock_session.read_resource = AsyncMock(return_value=mock_result)
|
||||
|
||||
thread = None
|
||||
try:
|
||||
thread = __import__("threading").Thread(target=mgr._loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
output = mgr.read_resource_sync("db://tables/users/rows/1", timeout=5)
|
||||
assert output == '{"name": "Alice"}'
|
||||
mock_session.read_resource.assert_awaited_once_with("db://tables/users/rows/1")
|
||||
finally:
|
||||
mgr._loop.call_soon_threadsafe(mgr._loop.stop)
|
||||
if thread:
|
||||
thread.join(timeout=5)
|
||||
mgr._loop.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP Prompts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMCPPrompts:
|
||||
def test_prompt_discovery(self):
|
||||
"""Mock list_prompts(), verify get_prompts() with correct prefixed names."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_prompts = {
|
||||
"tmpl": [
|
||||
_fake_prompt_dict("mcp__tmpl__code_review", "code_review", "tmpl"),
|
||||
_fake_prompt_dict("mcp__tmpl__summarize", "summarize", "tmpl"),
|
||||
],
|
||||
}
|
||||
mgr._rebuild_prompts()
|
||||
prompts = mgr.get_prompts()
|
||||
assert len(prompts) == 2
|
||||
names = {p["name"] for p in prompts}
|
||||
assert names == {"mcp__tmpl__code_review", "mcp__tmpl__summarize"}
|
||||
# Verify map entries
|
||||
assert mgr._prompt_map["mcp__tmpl__code_review"] == ("tmpl", "code_review")
|
||||
assert mgr._prompt_map["mcp__tmpl__summarize"] == ("tmpl", "summarize")
|
||||
|
||||
def test_rebuild_prompts_copy_on_write(self):
|
||||
"""Verify mutation safety."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_prompts = {
|
||||
"a": [_fake_prompt_dict("mcp__a__p1", "p1", "a")],
|
||||
}
|
||||
mgr._rebuild_prompts()
|
||||
old_prompts = mgr._prompts
|
||||
old_map = mgr._prompt_map
|
||||
mgr._per_server_prompts["b"] = [_fake_prompt_dict("mcp__b__p2", "p2", "b")]
|
||||
mgr._rebuild_prompts()
|
||||
assert mgr._prompts is not old_prompts
|
||||
assert mgr._prompt_map is not old_map
|
||||
|
||||
def test_get_prompts_returns_copy(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_prompts = {
|
||||
"a": [_fake_prompt_dict("mcp__a__p1", "p1", "a")],
|
||||
}
|
||||
mgr._rebuild_prompts()
|
||||
prompts = mgr.get_prompts()
|
||||
assert len(prompts) == 1
|
||||
prompts.clear()
|
||||
assert len(mgr.get_prompts()) == 1
|
||||
|
||||
def test_get_prompt_sync(self):
|
||||
"""Mock session.get_prompt(), verify message conversion."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._prompt_map = {"mcp__tmpl__review": ("tmpl", "review")}
|
||||
mock_session = MagicMock()
|
||||
mgr._sessions["tmpl"] = mock_session
|
||||
mgr._loop = asyncio.new_event_loop()
|
||||
|
||||
# Build mock PromptMessage
|
||||
msg1 = MagicMock()
|
||||
msg1.role = "user"
|
||||
msg1.content = MagicMock()
|
||||
msg1.content.text = "Review this code"
|
||||
msg2 = MagicMock()
|
||||
msg2.role = "assistant"
|
||||
msg2.content = MagicMock()
|
||||
msg2.content.text = "Looks good!"
|
||||
mock_result = MagicMock()
|
||||
mock_result.messages = [msg1, msg2]
|
||||
mock_session.get_prompt = AsyncMock(return_value=mock_result)
|
||||
|
||||
thread = None
|
||||
try:
|
||||
thread = __import__("threading").Thread(target=mgr._loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
messages = mgr.get_prompt_sync(
|
||||
"mcp__tmpl__review", arguments={"language": "python"}, timeout=5
|
||||
)
|
||||
assert len(messages) == 2
|
||||
assert messages[0] == {"role": "user", "content": "Review this code"}
|
||||
assert messages[1] == {"role": "assistant", "content": "Looks good!"}
|
||||
mock_session.get_prompt.assert_awaited_once_with(
|
||||
"review", arguments={"language": "python"}
|
||||
)
|
||||
finally:
|
||||
mgr._loop.call_soon_threadsafe(mgr._loop.stop)
|
||||
if thread:
|
||||
thread.join(timeout=5)
|
||||
mgr._loop.close()
|
||||
|
||||
def test_get_prompt_sync_unknown(self):
|
||||
mgr = MCPClientManager({})
|
||||
with pytest.raises(ValueError, match="Unknown MCP prompt"):
|
||||
mgr.get_prompt_sync("mcp__no__such")
|
||||
|
||||
def test_get_prompt_sync_disconnected(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr._prompt_map = {"mcp__dead__p": ("dead", "p")}
|
||||
with pytest.raises(RuntimeError, match="not connected"):
|
||||
mgr.get_prompt_sync("mcp__dead__p")
|
||||
|
||||
def test_get_prompt_sync_timeout(self):
|
||||
"""Verify timeout handling."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._prompt_map = {"mcp__tmpl__slow": ("tmpl", "slow")}
|
||||
mock_session = MagicMock()
|
||||
mgr._sessions["tmpl"] = mock_session
|
||||
mgr._loop = asyncio.new_event_loop()
|
||||
|
||||
async def _slow_prompt(_name: str, *, arguments: dict[str, str] | None = None) -> None:
|
||||
await asyncio.sleep(10)
|
||||
|
||||
mock_session.get_prompt = _slow_prompt
|
||||
|
||||
thread = None
|
||||
try:
|
||||
thread = __import__("threading").Thread(target=mgr._loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
with pytest.raises(TimeoutError):
|
||||
mgr.get_prompt_sync("mcp__tmpl__slow", timeout=1)
|
||||
finally:
|
||||
mgr._loop.call_soon_threadsafe(mgr._loop.stop)
|
||||
if thread:
|
||||
thread.join(timeout=5)
|
||||
mgr._loop.close()
|
||||
|
||||
def test_prompt_listener_notification(self):
|
||||
"""Verify callback fires on rebuild."""
|
||||
mgr = MCPClientManager({})
|
||||
calls: list[int] = []
|
||||
mgr.add_prompt_listener(lambda: calls.append(1))
|
||||
mgr._per_server_prompts = {"a": [_fake_prompt_dict()]}
|
||||
mgr._rebuild_prompts()
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_prompt_listener_remove(self):
|
||||
mgr = MCPClientManager({})
|
||||
calls: list[int] = []
|
||||
cb = lambda: calls.append(1) # noqa: E731
|
||||
mgr.add_prompt_listener(cb)
|
||||
mgr.remove_prompt_listener(cb)
|
||||
mgr._rebuild_prompts()
|
||||
assert calls == []
|
||||
|
||||
def test_prompt_listener_error_does_not_propagate(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr.add_prompt_listener(lambda: 1 / 0)
|
||||
mgr._rebuild_prompts() # should not raise
|
||||
|
||||
def test_is_mcp_prompt(self):
|
||||
"""Verify name lookup."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._prompt_map["mcp__tmpl__review"] = ("tmpl", "review")
|
||||
assert mgr.is_mcp_prompt("mcp__tmpl__review") is True
|
||||
assert mgr.is_mcp_prompt("nonexistent") is False
|
||||
|
||||
def test_prompt_refresh_on_notification(self):
|
||||
"""Mock notification, verify re-fetch of prompts."""
|
||||
|
||||
async def _run() -> None:
|
||||
mgr = MCPClientManager({})
|
||||
mock_session = MagicMock()
|
||||
mgr._sessions["tmpl"] = mock_session
|
||||
mgr._supports_prompts["tmpl"] = True
|
||||
|
||||
# Initial state
|
||||
mgr._per_server_prompts["tmpl"] = [
|
||||
_fake_prompt_dict("mcp__tmpl__old", "old", "tmpl"),
|
||||
]
|
||||
mgr._rebuild_prompts()
|
||||
assert len(mgr.get_prompts()) == 1
|
||||
|
||||
# Mock re-fetch returning a new prompt
|
||||
new_prompt = _fake_mcp_prompt("new_prompt", "A new prompt")
|
||||
mock_prompt_result = MagicMock()
|
||||
mock_prompt_result.prompts = [new_prompt]
|
||||
mock_session.list_prompts = AsyncMock(return_value=mock_prompt_result)
|
||||
|
||||
await mgr._refresh_server_prompts("tmpl")
|
||||
prompts = mgr.get_prompts()
|
||||
assert len(prompts) == 1
|
||||
assert prompts[0]["name"] == "mcp__tmpl__new_prompt"
|
||||
assert prompts[0]["original_name"] == "new_prompt"
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_rebuild_prompts_empty(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_prompts = {}
|
||||
mgr._rebuild_prompts()
|
||||
assert mgr._prompts == []
|
||||
assert mgr._prompt_map == {}
|
||||
|
||||
def test_rebuild_prompts_multi_server(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_prompts = {
|
||||
"a": [_fake_prompt_dict("mcp__a__p1", "p1", "a")],
|
||||
"b": [_fake_prompt_dict("mcp__b__p2", "p2", "b")],
|
||||
}
|
||||
mgr._rebuild_prompts()
|
||||
assert len(mgr._prompts) == 2
|
||||
assert mgr._prompt_map["mcp__a__p1"] == ("a", "p1")
|
||||
assert mgr._prompt_map["mcp__b__p2"] == ("b", "p2")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shutdown cleans up new state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestShutdownCleanup:
|
||||
def test_shutdown_clears_resources_and_prompts(self):
|
||||
mgr = MCPClientManager({})
|
||||
mgr._per_server_resources = {"a": [_fake_resource_dict()]}
|
||||
mgr._rebuild_resources()
|
||||
mgr._per_server_prompts = {"a": [_fake_prompt_dict()]}
|
||||
mgr._rebuild_prompts()
|
||||
assert mgr.get_resources() != []
|
||||
assert mgr.get_prompts() != []
|
||||
|
||||
mgr.shutdown()
|
||||
assert mgr.get_resources() == []
|
||||
assert mgr.get_prompts() == []
|
||||
assert mgr._resource_map == {}
|
||||
assert mgr._prompt_map == {}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Integration tests for MCPClientManager data flow.
|
||||
|
||||
Uses real storage (SQLite) and real MCPClientManager state manipulation,
|
||||
but mock MCP sessions instead of wire-protocol connections. This validates
|
||||
the full data pipeline: per-server data -> rebuild -> merged state ->
|
||||
query methods -> storage sync -> shutdown cleanup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.mcp_client import MCPClientManager
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_resource(
|
||||
uri: str, name: str, server: str, description: str = "", mime: str = "text/plain"
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"uri": uri,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"mimeType": mime,
|
||||
"server": server,
|
||||
}
|
||||
|
||||
|
||||
def _make_prompt(
|
||||
prefixed_name: str,
|
||||
original_name: str,
|
||||
server: str,
|
||||
description: str = "",
|
||||
arguments: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"name": prefixed_name,
|
||||
"original_name": original_name,
|
||||
"server": server,
|
||||
"description": description,
|
||||
"arguments": arguments or [],
|
||||
}
|
||||
|
||||
|
||||
def _make_mock_session(
|
||||
read_resource_result: Any = None,
|
||||
get_prompt_result: Any = None,
|
||||
) -> AsyncMock:
|
||||
"""Build a mock ClientSession with configurable async return values."""
|
||||
session = AsyncMock()
|
||||
|
||||
if read_resource_result is not None:
|
||||
session.read_resource.return_value = read_resource_result
|
||||
else:
|
||||
# Default: single text content
|
||||
content_item = MagicMock()
|
||||
content_item.text = "resource content"
|
||||
result = MagicMock()
|
||||
result.contents = [content_item]
|
||||
session.read_resource.return_value = result
|
||||
|
||||
if get_prompt_result is not None:
|
||||
session.get_prompt.return_value = get_prompt_result
|
||||
else:
|
||||
msg = MagicMock()
|
||||
msg.role = "user"
|
||||
msg.content = MagicMock()
|
||||
msg.content.text = "Hello, World!"
|
||||
result = MagicMock()
|
||||
result.messages = [msg]
|
||||
session.get_prompt.return_value = result
|
||||
|
||||
return session
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration test class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFullLifecycleResourcesPrompts:
|
||||
"""Integration test exercising real code paths with real SQLite storage
|
||||
but mock MCP sessions.
|
||||
|
||||
Validates the complete data flow: per-server data population, rebuild
|
||||
merging, query methods, resource/prompt dispatch through asyncio, storage
|
||||
sync, and shutdown cleanup.
|
||||
"""
|
||||
|
||||
@pytest.fixture()
|
||||
def mgr(self) -> MCPClientManager:
|
||||
"""Create an MCPClientManager with no server configs (no start())."""
|
||||
return MCPClientManager({})
|
||||
|
||||
@pytest.fixture()
|
||||
def db(self, tmp_path) -> SQLiteBackend:
|
||||
"""Create a fresh SQLite backend for each test."""
|
||||
backend = SQLiteBackend(str(tmp_path / "test.db"))
|
||||
yield backend
|
||||
backend.close()
|
||||
|
||||
def test_rebuild_resources_produces_merged_state(self, mgr: MCPClientManager) -> None:
|
||||
"""_rebuild_resources merges per-server resources into a unified list."""
|
||||
mgr._per_server_resources["alpha"] = [
|
||||
_make_resource("file:///a.txt", "a", "alpha"),
|
||||
_make_resource("file:///b.txt", "b", "alpha"),
|
||||
]
|
||||
mgr._per_server_resources["beta"] = [
|
||||
_make_resource("file:///c.txt", "c", "beta"),
|
||||
]
|
||||
|
||||
mgr._rebuild_resources()
|
||||
|
||||
resources = mgr.get_resources()
|
||||
assert len(resources) == 3
|
||||
uris = {r["uri"] for r in resources}
|
||||
assert uris == {"file:///a.txt", "file:///b.txt", "file:///c.txt"}
|
||||
# resource_map should have entries for all non-template resources
|
||||
assert "file:///a.txt" in mgr._resource_map
|
||||
assert "file:///c.txt" in mgr._resource_map
|
||||
assert mgr.resource_count == 3
|
||||
|
||||
def test_rebuild_prompts_produces_merged_state(self, mgr: MCPClientManager) -> None:
|
||||
"""_rebuild_prompts merges per-server prompts into a unified list."""
|
||||
mgr._per_server_prompts["alpha"] = [
|
||||
_make_prompt("mcp__alpha__greet", "greet", "alpha", "Say hello"),
|
||||
]
|
||||
mgr._per_server_prompts["beta"] = [
|
||||
_make_prompt("mcp__beta__summarize", "summarize", "beta", "Summarize text"),
|
||||
_make_prompt("mcp__beta__translate", "translate", "beta", "Translate text"),
|
||||
]
|
||||
|
||||
mgr._rebuild_prompts()
|
||||
|
||||
prompts = mgr.get_prompts()
|
||||
assert len(prompts) == 3
|
||||
names = {p["name"] for p in prompts}
|
||||
assert names == {"mcp__alpha__greet", "mcp__beta__summarize", "mcp__beta__translate"}
|
||||
# prompt_map should map prefixed -> (server, original)
|
||||
assert mgr._prompt_map["mcp__alpha__greet"] == ("alpha", "greet")
|
||||
assert mgr._prompt_map["mcp__beta__summarize"] == ("beta", "summarize")
|
||||
assert mgr.prompt_count == 3
|
||||
assert mgr.is_mcp_prompt("mcp__alpha__greet") is True
|
||||
assert mgr.is_mcp_prompt("nonexistent") is False
|
||||
|
||||
def test_read_resource_sync_dispatches_correctly(self, mgr: MCPClientManager) -> None:
|
||||
"""read_resource_sync dispatches to the correct session via a real asyncio loop."""
|
||||
# Set up a real event loop in a thread (simulating start())
|
||||
loop = asyncio.new_event_loop()
|
||||
import threading
|
||||
|
||||
thread = threading.Thread(target=loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
mgr._loop = loop
|
||||
|
||||
try:
|
||||
# Populate session and resource map
|
||||
session = _make_mock_session()
|
||||
mgr._sessions["alpha"] = session
|
||||
mgr._per_server_resources["alpha"] = [
|
||||
_make_resource("file:///readme.md", "readme", "alpha"),
|
||||
]
|
||||
mgr._rebuild_resources()
|
||||
|
||||
result = mgr.read_resource_sync("file:///readme.md", timeout=5)
|
||||
assert result == "resource content"
|
||||
session.read_resource.assert_awaited_once_with("file:///readme.md")
|
||||
finally:
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=5)
|
||||
loop.close()
|
||||
|
||||
def test_read_resource_sync_unknown_uri_raises(self, mgr: MCPClientManager) -> None:
|
||||
"""read_resource_sync raises ValueError for an unknown URI."""
|
||||
with pytest.raises(ValueError, match="Unknown MCP resource"):
|
||||
mgr.read_resource_sync("file:///nonexistent")
|
||||
|
||||
def test_read_resource_via_template(self, mgr: MCPClientManager) -> None:
|
||||
"""Expanded template URI dispatched to correct server via real asyncio loop."""
|
||||
loop = asyncio.new_event_loop()
|
||||
import threading
|
||||
|
||||
thread = threading.Thread(target=loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
mgr._loop = loop
|
||||
|
||||
try:
|
||||
session = _make_mock_session()
|
||||
mgr._sessions["alpha"] = session
|
||||
# Register a template resource (no concrete resources)
|
||||
mgr._per_server_resources["alpha"] = [
|
||||
{
|
||||
"uri": "db://tables/{table}/rows/{id}",
|
||||
"name": "row",
|
||||
"description": "Fetch a row",
|
||||
"mimeType": "application/json",
|
||||
"server": "alpha",
|
||||
"template": True,
|
||||
},
|
||||
]
|
||||
mgr._rebuild_resources()
|
||||
|
||||
# Template should not be in _resource_map
|
||||
assert "db://tables/{table}/rows/{id}" not in mgr._resource_map
|
||||
# But expanded URI should resolve via prefix matching
|
||||
result = mgr.read_resource_sync("db://tables/users/rows/42", timeout=5)
|
||||
assert result == "resource content"
|
||||
session.read_resource.assert_awaited_once_with("db://tables/users/rows/42")
|
||||
finally:
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=5)
|
||||
loop.close()
|
||||
|
||||
def test_get_prompt_sync_dispatches_correctly(self, mgr: MCPClientManager) -> None:
|
||||
"""get_prompt_sync dispatches to the correct session via a real asyncio loop."""
|
||||
loop = asyncio.new_event_loop()
|
||||
import threading
|
||||
|
||||
thread = threading.Thread(target=loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
mgr._loop = loop
|
||||
|
||||
try:
|
||||
session = _make_mock_session()
|
||||
mgr._sessions["alpha"] = session
|
||||
mgr._per_server_prompts["alpha"] = [
|
||||
_make_prompt("mcp__alpha__greet", "greet", "alpha", "Say hello"),
|
||||
]
|
||||
mgr._rebuild_prompts()
|
||||
|
||||
messages = mgr.get_prompt_sync(
|
||||
"mcp__alpha__greet", arguments={"name": "World"}, timeout=5
|
||||
)
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "user"
|
||||
assert messages[0]["content"] == "Hello, World!"
|
||||
session.get_prompt.assert_awaited_once_with("greet", arguments={"name": "World"})
|
||||
finally:
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=5)
|
||||
loop.close()
|
||||
|
||||
def test_get_prompt_sync_unknown_name_raises(self, mgr: MCPClientManager) -> None:
|
||||
"""get_prompt_sync raises ValueError for an unknown prompt name."""
|
||||
with pytest.raises(ValueError, match="Unknown MCP prompt"):
|
||||
mgr.get_prompt_sync("mcp__nosrv__nope")
|
||||
|
||||
def test_sync_prompts_to_storage_creates_templates(
|
||||
self, mgr: MCPClientManager, db: SQLiteBackend
|
||||
) -> None:
|
||||
"""sync_prompts_to_storage creates governance templates in real SQLite."""
|
||||
mgr.set_storage(db)
|
||||
mgr._prompts = [
|
||||
_make_prompt(
|
||||
"mcp__alpha__greet",
|
||||
"greet",
|
||||
"alpha",
|
||||
"Say hello",
|
||||
[{"name": "user", "description": "Who to greet", "required": True}],
|
||||
),
|
||||
_make_prompt(
|
||||
"mcp__beta__summarize",
|
||||
"summarize",
|
||||
"beta",
|
||||
"Summarize text",
|
||||
),
|
||||
]
|
||||
# Mark connected so set_storage triggers sync
|
||||
mgr._connected.set()
|
||||
# Re-set storage to trigger auto-sync
|
||||
mgr.set_storage(db)
|
||||
|
||||
templates = db.list_prompt_templates()
|
||||
assert len(templates) == 2
|
||||
names = {t["name"] for t in templates}
|
||||
assert names == {"mcp__alpha__greet", "mcp__beta__summarize"}
|
||||
|
||||
# Verify details on first template
|
||||
tpl = db.get_prompt_template_by_name("mcp__alpha__greet")
|
||||
assert tpl is not None
|
||||
assert tpl["origin"] == "mcp"
|
||||
assert tpl["mcp_server"] == "alpha"
|
||||
assert tpl["readonly"] is True
|
||||
assert tpl["category"] == "mcp"
|
||||
assert "user" in tpl["variables"]
|
||||
|
||||
def test_sync_prompts_removes_stale_templates(
|
||||
self, mgr: MCPClientManager, db: SQLiteBackend
|
||||
) -> None:
|
||||
"""sync_prompts_to_storage removes templates whose MCP prompts are gone."""
|
||||
mgr.set_storage(db)
|
||||
|
||||
# Create an initial template via sync
|
||||
mgr._prompts = [
|
||||
_make_prompt("mcp__alpha__old", "old", "alpha", "Old prompt"),
|
||||
]
|
||||
mgr.sync_prompts_to_storage()
|
||||
assert len(db.list_prompt_templates()) == 1
|
||||
|
||||
# Now the prompt is gone
|
||||
mgr._prompts = []
|
||||
result = mgr.sync_prompts_to_storage()
|
||||
assert result["removed"] == ["mcp__alpha__old"]
|
||||
assert len(db.list_prompt_templates()) == 0
|
||||
|
||||
def test_shutdown_clears_all_state(self, mgr: MCPClientManager) -> None:
|
||||
"""shutdown() clears sessions, tools, resources, prompts, and listeners."""
|
||||
# Populate state
|
||||
mgr._sessions["alpha"] = MagicMock()
|
||||
mgr._per_server_tools["alpha"] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "mcp__alpha__search",
|
||||
"description": "Search",
|
||||
"parameters": {},
|
||||
},
|
||||
}
|
||||
]
|
||||
mgr._rebuild_tools()
|
||||
mgr._per_server_resources["alpha"] = [
|
||||
_make_resource("file:///a.txt", "a", "alpha"),
|
||||
{
|
||||
"uri": "db://tables/{table}",
|
||||
"name": "table",
|
||||
"description": "",
|
||||
"mimeType": "",
|
||||
"server": "alpha",
|
||||
"template": True,
|
||||
},
|
||||
]
|
||||
mgr._rebuild_resources()
|
||||
mgr._per_server_prompts["alpha"] = [
|
||||
_make_prompt("mcp__alpha__greet", "greet", "alpha"),
|
||||
]
|
||||
mgr._rebuild_prompts()
|
||||
mgr._listeners.append(lambda: None)
|
||||
mgr._resource_listeners.append(lambda: None)
|
||||
mgr._prompt_listeners.append(lambda: None)
|
||||
|
||||
# Verify populated
|
||||
assert len(mgr._sessions) == 1
|
||||
assert len(mgr._tools) == 1
|
||||
assert len(mgr._resources) == 2 # 1 concrete + 1 template
|
||||
assert len(mgr._template_prefixes) == 1
|
||||
assert len(mgr._prompts) == 1
|
||||
|
||||
mgr.shutdown()
|
||||
|
||||
assert len(mgr._sessions) == 0
|
||||
assert len(mgr._tools) == 0
|
||||
assert len(mgr._tool_map) == 0
|
||||
assert len(mgr._resources) == 0
|
||||
assert len(mgr._resource_map) == 0
|
||||
assert len(mgr._template_prefixes) == 0
|
||||
assert len(mgr._prompts) == 0
|
||||
assert len(mgr._prompt_map) == 0
|
||||
assert len(mgr._listeners) == 0
|
||||
assert len(mgr._resource_listeners) == 0
|
||||
assert len(mgr._prompt_listeners) == 0
|
||||
|
||||
def test_listener_notifications_fire_on_rebuild(self, mgr: MCPClientManager) -> None:
|
||||
"""Rebuild methods fire the appropriate listener callbacks."""
|
||||
tool_fired = []
|
||||
resource_fired = []
|
||||
prompt_fired = []
|
||||
mgr.add_listener(lambda: tool_fired.append(1))
|
||||
mgr.add_resource_listener(lambda: resource_fired.append(1))
|
||||
mgr.add_prompt_listener(lambda: prompt_fired.append(1))
|
||||
|
||||
mgr._per_server_tools["alpha"] = []
|
||||
mgr._rebuild_tools()
|
||||
assert len(tool_fired) == 1
|
||||
|
||||
mgr._per_server_resources["alpha"] = [
|
||||
_make_resource("file:///x.txt", "x", "alpha"),
|
||||
]
|
||||
mgr._rebuild_resources()
|
||||
assert len(resource_fired) == 1
|
||||
|
||||
mgr._per_server_prompts["alpha"] = [
|
||||
_make_prompt("mcp__alpha__p1", "p1", "alpha"),
|
||||
]
|
||||
mgr._rebuild_prompts()
|
||||
assert len(prompt_fired) == 1
|
||||
|
||||
# Tool and resource listeners should not have been fired again
|
||||
assert len(tool_fired) == 1
|
||||
assert len(resource_fired) == 1
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Tests for MCP prompt → governance template sync and readonly API guards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.mcp_client import MCPClientManager
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mgr() -> MCPClientManager:
|
||||
"""Create an MCPClientManager with no real servers (no start())."""
|
||||
return MCPClientManager({})
|
||||
|
||||
|
||||
def _make_storage() -> MagicMock:
|
||||
"""Create a mock storage backend with prompt template methods."""
|
||||
storage = MagicMock()
|
||||
storage.get_prompt_template_by_name.return_value = None
|
||||
storage.list_prompt_templates_by_origin.return_value = []
|
||||
storage.create_prompt_template.return_value = None
|
||||
storage.update_prompt_template.return_value = True
|
||||
storage.delete_prompt_template.return_value = True
|
||||
return storage
|
||||
|
||||
|
||||
class TestSyncPromptsToStorage:
|
||||
def test_sync_no_storage(self, mgr: MCPClientManager) -> None:
|
||||
"""Without storage set, sync returns empty stats."""
|
||||
result = mgr.sync_prompts_to_storage()
|
||||
assert result == {"added": [], "removed": [], "skipped": []}
|
||||
|
||||
def test_sync_creates_mcp_templates(self, mgr: MCPClientManager) -> None:
|
||||
"""New MCP prompts are created as templates."""
|
||||
storage = _make_storage()
|
||||
mgr.set_storage(storage)
|
||||
|
||||
# Populate internal prompts list directly
|
||||
mgr._prompts = [
|
||||
{
|
||||
"name": "mcp__test__greeting",
|
||||
"original_name": "greeting",
|
||||
"server": "test",
|
||||
"description": "Say hello",
|
||||
"arguments": [
|
||||
{"name": "name", "description": "Who to greet", "required": True},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
result = mgr.sync_prompts_to_storage()
|
||||
|
||||
assert result["added"] == ["mcp__test__greeting"]
|
||||
assert result["removed"] == []
|
||||
assert result["skipped"] == []
|
||||
storage.create_prompt_template.assert_called_once()
|
||||
call_kwargs = storage.create_prompt_template.call_args
|
||||
assert call_kwargs[1]["name"] == "mcp__test__greeting"
|
||||
assert call_kwargs[1]["origin"] == "mcp"
|
||||
assert call_kwargs[1]["mcp_server"] == "test"
|
||||
assert call_kwargs[1]["readonly"] is True
|
||||
assert call_kwargs[1]["category"] == "mcp"
|
||||
assert '"name"' in call_kwargs[1]["variables"]
|
||||
|
||||
def test_sync_skips_manual_overrides(self, mgr: MCPClientManager) -> None:
|
||||
"""A manual template with the same name is not overwritten."""
|
||||
storage = _make_storage()
|
||||
storage.get_prompt_template_by_name.return_value = {
|
||||
"template_id": "existing-id",
|
||||
"name": "mcp__test__greeting",
|
||||
"origin": "manual",
|
||||
"readonly": False,
|
||||
}
|
||||
mgr.set_storage(storage)
|
||||
|
||||
mgr._prompts = [
|
||||
{
|
||||
"name": "mcp__test__greeting",
|
||||
"original_name": "greeting",
|
||||
"server": "test",
|
||||
"description": "Say hello",
|
||||
"arguments": [],
|
||||
},
|
||||
]
|
||||
|
||||
result = mgr.sync_prompts_to_storage()
|
||||
|
||||
assert result["skipped"] == ["mcp__test__greeting"]
|
||||
assert result["added"] == []
|
||||
storage.create_prompt_template.assert_not_called()
|
||||
storage.update_prompt_template.assert_not_called()
|
||||
|
||||
def test_sync_updates_existing_mcp_template(self, mgr: MCPClientManager) -> None:
|
||||
"""An existing MCP template gets its content/variables updated."""
|
||||
storage = _make_storage()
|
||||
storage.get_prompt_template_by_name.return_value = {
|
||||
"template_id": "existing-id",
|
||||
"name": "mcp__test__greeting",
|
||||
"origin": "mcp",
|
||||
"mcp_server": "test",
|
||||
"readonly": True,
|
||||
}
|
||||
mgr.set_storage(storage)
|
||||
|
||||
mgr._prompts = [
|
||||
{
|
||||
"name": "mcp__test__greeting",
|
||||
"original_name": "greeting",
|
||||
"server": "test",
|
||||
"description": "Updated description",
|
||||
"arguments": [
|
||||
{"name": "user", "description": "The user", "required": False},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
result = mgr.sync_prompts_to_storage()
|
||||
|
||||
assert result["added"] == []
|
||||
assert result["skipped"] == []
|
||||
storage.create_prompt_template.assert_not_called()
|
||||
storage.update_prompt_template.assert_called_once()
|
||||
call_args = storage.update_prompt_template.call_args
|
||||
assert call_args[0][0] == "existing-id"
|
||||
assert "Updated description" in call_args[1]["content"]
|
||||
assert "user" in call_args[1]["variables"]
|
||||
# Security: is_default must be reset to prevent compromised MCP server
|
||||
# from injecting content into a previously admin-promoted default
|
||||
assert call_args[1]["is_default"] is False
|
||||
|
||||
def test_sync_resets_is_default_on_promoted_template(self, mgr: MCPClientManager) -> None:
|
||||
"""An MCP template promoted to default by admin gets is_default reset on sync."""
|
||||
storage = _make_storage()
|
||||
storage.get_prompt_template_by_name.return_value = {
|
||||
"template_id": "promoted-id",
|
||||
"name": "mcp__test__greeting",
|
||||
"origin": "mcp",
|
||||
"mcp_server": "test",
|
||||
"readonly": True,
|
||||
"is_default": True, # admin toggled this
|
||||
}
|
||||
mgr.set_storage(storage)
|
||||
|
||||
mgr._prompts = [
|
||||
{
|
||||
"name": "mcp__test__greeting",
|
||||
"original_name": "greeting",
|
||||
"server": "test",
|
||||
"description": "Potentially compromised content",
|
||||
"arguments": [],
|
||||
},
|
||||
]
|
||||
|
||||
mgr.sync_prompts_to_storage()
|
||||
|
||||
call_args = storage.update_prompt_template.call_args
|
||||
assert call_args[1]["is_default"] is False
|
||||
|
||||
def test_sync_removes_deleted_prompts(self, mgr: MCPClientManager) -> None:
|
||||
"""MCP templates in storage with no matching prompt are deleted."""
|
||||
storage = _make_storage()
|
||||
storage.list_prompt_templates_by_origin.return_value = [
|
||||
{
|
||||
"template_id": "old-id",
|
||||
"name": "mcp__test__old_prompt",
|
||||
"origin": "mcp",
|
||||
"mcp_server": "test",
|
||||
},
|
||||
]
|
||||
mgr.set_storage(storage)
|
||||
mgr._prompts = [] # No prompts at all
|
||||
|
||||
result = mgr.sync_prompts_to_storage()
|
||||
|
||||
assert result["removed"] == ["mcp__test__old_prompt"]
|
||||
storage.delete_prompt_template.assert_called_once_with("old-id")
|
||||
|
||||
|
||||
class TestSetStorageAutoSync:
|
||||
"""set_storage() triggers an immediate sync when servers are already connected."""
|
||||
|
||||
def test_set_storage_syncs_when_connected(self, mgr) -> None:
|
||||
storage = _make_storage()
|
||||
mgr._prompts = [
|
||||
{
|
||||
"name": "mcp__srv__p1",
|
||||
"original_name": "p1",
|
||||
"server": "srv",
|
||||
"description": "A prompt",
|
||||
"arguments": [],
|
||||
}
|
||||
]
|
||||
mgr._connected.set()
|
||||
|
||||
mgr.set_storage(storage)
|
||||
|
||||
# Should have called create_prompt_template for the discovered prompt
|
||||
storage.create_prompt_template.assert_called_once()
|
||||
call_kwargs = storage.create_prompt_template.call_args
|
||||
assert call_kwargs[1]["name"] == "mcp__srv__p1"
|
||||
assert call_kwargs[1]["origin"] == "mcp"
|
||||
|
||||
def test_set_storage_no_sync_when_not_connected(self, mgr) -> None:
|
||||
storage = _make_storage()
|
||||
mgr._prompts = [
|
||||
{
|
||||
"name": "mcp__srv__p1",
|
||||
"original_name": "p1",
|
||||
"server": "srv",
|
||||
"description": "A prompt",
|
||||
"arguments": [],
|
||||
}
|
||||
]
|
||||
# _connected is NOT set
|
||||
|
||||
mgr.set_storage(storage)
|
||||
|
||||
# Should not have synced
|
||||
storage.create_prompt_template.assert_not_called()
|
||||
|
||||
|
||||
class TestReadonlyAPIGuards:
|
||||
"""Test that the console server API guards reject edits to readonly templates."""
|
||||
|
||||
@pytest.fixture()
|
||||
def db(self, tmp_path):
|
||||
"""Create a fresh SQLite backend for each test."""
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
def test_readonly_guard_update(self, db) -> None:
|
||||
"""Readonly templates cannot be updated via storage guard logic."""
|
||||
db.create_prompt_template(
|
||||
"t1",
|
||||
"mcp__srv__prompt",
|
||||
"mcp",
|
||||
"content",
|
||||
variables="[]",
|
||||
is_default=False,
|
||||
org_id="",
|
||||
created_by="",
|
||||
origin="mcp",
|
||||
mcp_server="srv",
|
||||
readonly=True,
|
||||
)
|
||||
tpl = db.get_prompt_template("t1")
|
||||
assert tpl is not None
|
||||
assert tpl["readonly"] is True
|
||||
# Simulate API guard check
|
||||
assert tpl.get("readonly") is True
|
||||
|
||||
def test_readonly_guard_delete(self, db) -> None:
|
||||
"""Readonly templates are flagged for API-level rejection."""
|
||||
db.create_prompt_template(
|
||||
"t1",
|
||||
"mcp__srv__prompt",
|
||||
"mcp",
|
||||
"content",
|
||||
variables="[]",
|
||||
is_default=False,
|
||||
org_id="",
|
||||
created_by="",
|
||||
origin="mcp",
|
||||
mcp_server="srv",
|
||||
readonly=True,
|
||||
)
|
||||
existing = db.get_prompt_template("t1")
|
||||
assert existing is not None
|
||||
assert existing.get("readonly") is True
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Tests for prompt template runtime wiring into ChatSession."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.session import ChatSession, _render_template
|
||||
|
||||
|
||||
class NullUI:
|
||||
"""UI adapter that discards all output."""
|
||||
|
||||
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):
|
||||
pass
|
||||
|
||||
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):
|
||||
pass
|
||||
|
||||
def on_error(self, message):
|
||||
pass
|
||||
|
||||
def on_state_change(self, state):
|
||||
pass
|
||||
|
||||
def on_rename(self, name):
|
||||
pass
|
||||
|
||||
|
||||
def _make_session(**kwargs):
|
||||
defaults = dict(
|
||||
client=MagicMock(),
|
||||
model="test-model",
|
||||
ui=NullUI(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=4096,
|
||||
tool_timeout=30,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return ChatSession(**defaults)
|
||||
|
||||
|
||||
def _sys_content(session: ChatSession) -> str:
|
||||
"""Extract the system message content."""
|
||||
msgs = [m for m in session.system_messages if m["role"] == "system"]
|
||||
assert msgs
|
||||
return msgs[0]["content"]
|
||||
|
||||
|
||||
def _create_template(db, template_id, name, content, is_default=False, **kwargs):
|
||||
"""Helper to create a prompt template in storage."""
|
||||
db.create_prompt_template(
|
||||
template_id=template_id,
|
||||
name=name,
|
||||
category=kwargs.get("category", "general"),
|
||||
content=content,
|
||||
variables=kwargs.get("variables", "[]"),
|
||||
is_default=is_default,
|
||||
org_id=kwargs.get("org_id", ""),
|
||||
created_by=kwargs.get("created_by", "test"),
|
||||
origin=kwargs.get("origin", "manual"),
|
||||
mcp_server=kwargs.get("mcp_server", ""),
|
||||
readonly=kwargs.get("readonly", False),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _render_template unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRenderTemplate:
|
||||
def test_basic_substitution(self):
|
||||
result = _render_template("Hello {{name}}", {"name": "world"})
|
||||
assert result == "Hello world"
|
||||
|
||||
def test_multiple_variables(self):
|
||||
result = _render_template(
|
||||
"Model: {{model}}, WS: {{ws_id}}", {"model": "gpt-5", "ws_id": "abc123"}
|
||||
)
|
||||
assert result == "Model: gpt-5, WS: abc123"
|
||||
|
||||
def test_unresolvable_variable_kept(self):
|
||||
result = _render_template("Hello {{unknown}}", {"model": "gpt-5"})
|
||||
assert result == "Hello {{unknown}}"
|
||||
|
||||
def test_empty_context(self):
|
||||
result = _render_template("No vars here", {})
|
||||
assert result == "No vars here"
|
||||
|
||||
def test_duplicate_placeholder(self):
|
||||
result = _render_template("{{x}} and {{x}}", {"x": "val"})
|
||||
assert result == "val and val"
|
||||
|
||||
def test_no_cross_variable_injection(self):
|
||||
# If model contains {{ws_id}}, it must NOT be expanded
|
||||
result = _render_template("Model: {{model}}", {"model": "{{ws_id}}", "ws_id": "secret"})
|
||||
assert result == "Model: {{ws_id}}"
|
||||
assert "secret" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default templates in system message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDefaultTemplates:
|
||||
def test_default_templates_in_system_message(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "alpha", "You are a helpful assistant.", is_default=True)
|
||||
_create_template(db, "t2", "beta", "Always be concise.", is_default=True)
|
||||
|
||||
session = _make_session()
|
||||
content = _sys_content(session)
|
||||
assert "You are a helpful assistant." in content
|
||||
assert "Always be concise." in content
|
||||
|
||||
def test_default_templates_ordered_by_name(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t2", "b-template", "SECOND", is_default=True)
|
||||
_create_template(db, "t1", "a-template", "FIRST", is_default=True)
|
||||
|
||||
session = _make_session()
|
||||
content = _sys_content(session)
|
||||
first_pos = content.index("FIRST")
|
||||
second_pos = content.index("SECOND")
|
||||
assert first_pos < second_pos
|
||||
|
||||
def test_no_default_templates(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "alpha", "Not default.", is_default=False)
|
||||
|
||||
session = _make_session()
|
||||
content = _sys_content(session)
|
||||
assert "Not default." not in content
|
||||
|
||||
def test_templates_before_instructions(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "tpl", "TEMPLATE_CONTENT", is_default=True)
|
||||
|
||||
session = _make_session(instructions="USER_INSTRUCTIONS")
|
||||
content = _sys_content(session)
|
||||
tpl_pos = content.index("TEMPLATE_CONTENT")
|
||||
instr_pos = content.index("USER_INSTRUCTIONS")
|
||||
assert tpl_pos < instr_pos
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Explicit template selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExplicitTemplate:
|
||||
def test_explicit_template_replaces_defaults(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "default-tpl", "DEFAULT_CONTENT", is_default=True)
|
||||
_create_template(db, "t2", "specific-tpl", "SPECIFIC_CONTENT", is_default=False)
|
||||
|
||||
session = _make_session(template="specific-tpl")
|
||||
content = _sys_content(session)
|
||||
assert "SPECIFIC_CONTENT" in content
|
||||
assert "DEFAULT_CONTENT" not in content
|
||||
|
||||
def test_explicit_template_not_found(self, tmp_db):
|
||||
session = _make_session(template="nonexistent")
|
||||
content = _sys_content(session)
|
||||
# Graceful degradation — no template content injected
|
||||
assert "nonexistent" not in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variable substitution in templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTemplateVariables:
|
||||
def test_model_and_ws_id_substituted(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "vars-tpl", "Model: {{model}}, WS: {{ws_id}}", is_default=True)
|
||||
|
||||
session = _make_session()
|
||||
content = _sys_content(session)
|
||||
assert "Model: test-model" in content
|
||||
assert f"WS: {session.ws_id}" in content
|
||||
|
||||
def test_node_id_substituted(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "node-tpl", "Node: {{node_id}}", is_default=True)
|
||||
|
||||
session = _make_session(node_id="node-42")
|
||||
content = _sys_content(session)
|
||||
assert "Node: node-42" in content
|
||||
|
||||
def test_unknown_variable_preserved(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "unknown-tpl", "Val: {{unknown_var}}", is_default=True)
|
||||
|
||||
session = _make_session()
|
||||
content = _sys_content(session)
|
||||
assert "Val: {{unknown_var}}" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template persistence and resume
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTemplatePersistence:
|
||||
def test_template_persisted_in_config(self, tmp_db):
|
||||
from turnstone.core.memory import load_workstream_config
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "my-tpl", "TPL_CONTENT", is_default=False)
|
||||
|
||||
session = _make_session(template="my-tpl")
|
||||
config = load_workstream_config(session.ws_id)
|
||||
assert config["template"] == "my-tpl"
|
||||
|
||||
def test_template_restored_on_resume(self, tmp_db):
|
||||
from turnstone.core.memory import save_message
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "my-tpl", "PERSISTED_TEMPLATE", is_default=False)
|
||||
|
||||
# Create session with template, save a message so resume has history
|
||||
session1 = _make_session(template="my-tpl")
|
||||
ws_id = session1.ws_id
|
||||
save_message(ws_id, "user", "hello")
|
||||
|
||||
# New session without template, then resume
|
||||
session2 = _make_session()
|
||||
assert session2._template_name is None
|
||||
resumed = session2.resume(ws_id)
|
||||
assert resumed
|
||||
assert session2._template_name == "my-tpl"
|
||||
content = _sys_content(session2)
|
||||
assert "PERSISTED_TEMPLATE" in content
|
||||
|
||||
def test_empty_template_config_means_defaults(self, tmp_db):
|
||||
from turnstone.core.memory import load_workstream_config
|
||||
|
||||
session = _make_session()
|
||||
config = load_workstream_config(session.ws_id)
|
||||
assert config["template"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /template slash command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTemplateSlashCommand:
|
||||
def test_template_set(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "my-tpl", "SLASH_TEMPLATE", is_default=False)
|
||||
|
||||
session = _make_session()
|
||||
content_before = _sys_content(session)
|
||||
assert "SLASH_TEMPLATE" not in content_before
|
||||
|
||||
session.handle_command("/template my-tpl")
|
||||
assert session._template_name == "my-tpl"
|
||||
content_after = _sys_content(session)
|
||||
assert "SLASH_TEMPLATE" in content_after
|
||||
|
||||
def test_template_clear(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "my-tpl", "EXPLICIT_TEMPLATE", is_default=False)
|
||||
_create_template(db, "t2", "default-tpl", "DEFAULT_TEMPLATE", is_default=True)
|
||||
|
||||
session = _make_session(template="my-tpl")
|
||||
assert "EXPLICIT_TEMPLATE" in _sys_content(session)
|
||||
assert "DEFAULT_TEMPLATE" not in _sys_content(session)
|
||||
|
||||
session.handle_command("/template clear")
|
||||
assert session._template_name is None
|
||||
assert "DEFAULT_TEMPLATE" in _sys_content(session)
|
||||
assert "EXPLICIT_TEMPLATE" not in _sys_content(session)
|
||||
|
||||
def test_template_not_found(self, tmp_db):
|
||||
ui = NullUI()
|
||||
ui.on_error = MagicMock()
|
||||
session = _make_session(ui=ui)
|
||||
session.handle_command("/template nonexistent")
|
||||
ui.on_error.assert_called_once()
|
||||
assert "not found" in ui.on_error.call_args[0][0].lower()
|
||||
|
||||
def test_template_show_current(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "my-tpl", "content", is_default=False)
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, template="my-tpl")
|
||||
session.handle_command("/template")
|
||||
ui.on_info.assert_called_once()
|
||||
assert "my-tpl" in ui.on_info.call_args[0][0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP-origin templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMCPTemplates:
|
||||
def test_mcp_readonly_template_as_default(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(
|
||||
db,
|
||||
"t1",
|
||||
"mcp__server__prompt",
|
||||
"MCP_CONTENT",
|
||||
is_default=True,
|
||||
origin="mcp",
|
||||
mcp_server="server",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
session = _make_session()
|
||||
content = _sys_content(session)
|
||||
assert "MCP_CONTENT" in content
|
||||
|
||||
def test_mcp_template_selectable_explicitly(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(
|
||||
db,
|
||||
"t1",
|
||||
"mcp__server__code",
|
||||
"MCP_EXPLICIT",
|
||||
is_default=False,
|
||||
origin="mcp",
|
||||
mcp_server="server",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
session = _make_session(template="mcp__server__code")
|
||||
content = _sys_content(session)
|
||||
assert "MCP_EXPLICIT" in content
|
||||
@@ -8,6 +8,7 @@ from turnstone.mq.protocol import (
|
||||
AckEvent,
|
||||
ApprovalRequestEvent,
|
||||
ApproveMessage,
|
||||
CancelMessage,
|
||||
CloseWorkstreamMessage,
|
||||
CommandMessage,
|
||||
ContentEvent,
|
||||
@@ -68,6 +69,7 @@ INBOUND_TYPES = [
|
||||
(ListWorkstreamsMessage, {}),
|
||||
(HealthMessage, {}),
|
||||
(ListNodesMessage, {}),
|
||||
(CancelMessage, {"ws_id": "abc"}),
|
||||
]
|
||||
|
||||
|
||||
@@ -207,6 +209,20 @@ def test_create_workstream_target_node():
|
||||
assert restored.name == "debug-ws"
|
||||
|
||||
|
||||
def test_create_workstream_template_field():
|
||||
msg = CreateWorkstreamMessage(name="ws", template="code-review")
|
||||
assert msg.template == "code-review"
|
||||
raw = msg.to_json()
|
||||
restored = InboundMessage.from_json(raw)
|
||||
assert isinstance(restored, CreateWorkstreamMessage)
|
||||
assert restored.template == "code-review"
|
||||
|
||||
|
||||
def test_create_workstream_template_default_empty():
|
||||
msg = CreateWorkstreamMessage(name="ws")
|
||||
assert msg.template == ""
|
||||
|
||||
|
||||
def test_list_nodes_round_trip():
|
||||
msg = ListNodesMessage()
|
||||
raw = msg.to_json()
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from turnstone.console.server import (
|
||||
admin_create_schedule,
|
||||
admin_delete_schedule,
|
||||
@@ -15,9 +23,21 @@ from turnstone.console.server import (
|
||||
admin_list_schedules,
|
||||
admin_update_schedule,
|
||||
)
|
||||
from turnstone.core.auth import AuthResult
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
class _InjectAuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="test-admin",
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="config",
|
||||
permissions=frozenset({"admin.schedules"}),
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path):
|
||||
"""Fresh SQLite backend for each test."""
|
||||
@@ -52,6 +72,7 @@ def client(storage):
|
||||
],
|
||||
),
|
||||
],
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
return TestClient(app)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for turnstone.sdk.events — SSE event deserialization."""
|
||||
|
||||
from turnstone.sdk.events import (
|
||||
ApprovalResolvedEvent,
|
||||
ApproveRequestEvent,
|
||||
BusyErrorEvent,
|
||||
ClearUiEvent,
|
||||
@@ -92,6 +93,15 @@ def test_approve_request_event():
|
||||
assert len(e.items) == 1
|
||||
|
||||
|
||||
def test_approval_resolved_event():
|
||||
e = ServerEvent.from_dict(
|
||||
{"type": "approval_resolved", "approved": False, "feedback": "Approval timed out"}
|
||||
)
|
||||
assert isinstance(e, ApprovalResolvedEvent)
|
||||
assert e.approved is False
|
||||
assert e.feedback == "Approval timed out"
|
||||
|
||||
|
||||
def test_tool_result_event():
|
||||
e = ServerEvent.from_dict(
|
||||
{"type": "tool_result", "call_id": "c1", "name": "search", "output": "found it"}
|
||||
|
||||
+297
-7
@@ -144,12 +144,21 @@ class TestChatSessionConstruction:
|
||||
class TestPlanExec:
|
||||
"""Tests for _exec_plan: unique session-scoped plan file and existing-plan injection."""
|
||||
|
||||
def _run_plan(self, session, prompt, agent_return="# Plan\n\nDo the thing."):
|
||||
_VALID_PLAN = (
|
||||
"## Goal\n\nDo the thing.\n\n"
|
||||
"## Current State\n\nFile foo.py has bar().\n\n"
|
||||
"## Plan\n\n1. Edit foo.py line 10.\n\n"
|
||||
"## Risks\n\nNone."
|
||||
)
|
||||
|
||||
def _run_plan(self, session, prompt, agent_return=None):
|
||||
"""Invoke _exec_plan with _run_agent patched to avoid LLM calls.
|
||||
|
||||
Returns (call_id_returned, content_returned, captured_messages) where
|
||||
captured_messages is the agent_messages list passed to _run_agent.
|
||||
"""
|
||||
if agent_return is None:
|
||||
agent_return = self._VALID_PLAN
|
||||
captured = {}
|
||||
|
||||
def fake_run_agent(messages, **kwargs):
|
||||
@@ -175,10 +184,9 @@ class TestPlanExec:
|
||||
"""Written plan file contains the agent's output verbatim."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
session = _make_session()
|
||||
plan_content = "## Goal\n\nAdd a new endpoint."
|
||||
self._run_plan(session, "add endpoint", agent_return=plan_content)
|
||||
self._run_plan(session, "add endpoint")
|
||||
plan_file = tmp_path / f".plan-{session._ws_id}.md"
|
||||
assert plan_file.read_text() == plan_content
|
||||
assert plan_file.read_text() == self._VALID_PLAN
|
||||
|
||||
def test_two_sessions_produce_different_files(self, tmp_db, tmp_path, monkeypatch):
|
||||
"""Two ChatSession instances never collide on the same plan file."""
|
||||
@@ -262,10 +270,292 @@ class TestPlanExec:
|
||||
"""_exec_plan returns (call_id, agent_output)."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
session = _make_session()
|
||||
agent_output = "## Goal\n\nBuild it."
|
||||
call_id, content, _ = self._run_plan(session, "do stuff", agent_return=agent_output)
|
||||
call_id, content, _ = self._run_plan(session, "do stuff")
|
||||
assert call_id == "test-call-1"
|
||||
assert content == agent_output
|
||||
assert content == self._VALID_PLAN
|
||||
|
||||
def test_exec_plan_retries_on_garbage(self, tmp_db, tmp_path, monkeypatch):
|
||||
"""When _run_agent returns garbage, _exec_plan retries once."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
session = _make_session()
|
||||
good_plan = (
|
||||
"## Goal\n\nAdd feature X.\n\n"
|
||||
"## Current State\n\nFile foo.py has bar().\n\n"
|
||||
"## Plan\n\n1. Edit foo.py:bar()\n\n"
|
||||
"## Risks\n\nNone."
|
||||
)
|
||||
call_count = 0
|
||||
|
||||
def fake_run_agent(messages, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return "Sure, do the thing."
|
||||
return good_plan
|
||||
|
||||
item = {"call_id": "c1", "prompt": "add feature X"}
|
||||
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
|
||||
_, content = session._exec_plan(item)
|
||||
|
||||
assert call_count == 2
|
||||
assert "## Goal" in content
|
||||
|
||||
def test_exec_plan_warning_on_double_failure(self, tmp_db, tmp_path, monkeypatch):
|
||||
"""When both attempts produce garbage, content gets a warning prefix."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
session = _make_session()
|
||||
|
||||
def fake_run_agent(messages, **kwargs):
|
||||
return "nope"
|
||||
|
||||
item = {"call_id": "c1", "prompt": "add feature X"}
|
||||
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
|
||||
_, content = session._exec_plan(item)
|
||||
|
||||
assert content.startswith("[Warning:")
|
||||
|
||||
def test_retry_continues_agent_conversation(self, tmp_db, tmp_path, monkeypatch):
|
||||
"""Retry appends coaching to the same agent_messages list."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
session = _make_session()
|
||||
captured_messages: list[list] = []
|
||||
|
||||
def fake_run_agent(messages, **kwargs):
|
||||
captured_messages.append(list(messages))
|
||||
if len(captured_messages) == 1:
|
||||
return "garbage"
|
||||
return (
|
||||
"## Goal\n\nDone.\n\n## Current State\n\nx\n\n## Plan\n\n1. x\n\n## Risks\n\nNone."
|
||||
)
|
||||
|
||||
item = {"call_id": "c1", "prompt": "add feature X"}
|
||||
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
|
||||
session._exec_plan(item)
|
||||
|
||||
assert len(captured_messages) == 2
|
||||
# Second call should have more messages (coaching appended)
|
||||
assert len(captured_messages[1]) > len(captured_messages[0])
|
||||
# Last user message in second call is the coaching message
|
||||
assert "did not follow" in captured_messages[1][-1]["content"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plan validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlanValidation:
|
||||
"""Tests for ChatSession._validate_plan quality gate."""
|
||||
|
||||
GOOD_PLAN = (
|
||||
"## Goal\n\nAdd authentication to the API.\n\n"
|
||||
"## Current State\n\nFile server.py:45 has no auth middleware.\n\n"
|
||||
"## Plan\n\n1. Add AuthMiddleware to server.py.\n"
|
||||
"2. Create auth.py with JWT verification.\n\n"
|
||||
"## Risks\n\nToken expiry handling may need tuning."
|
||||
)
|
||||
|
||||
def test_valid_plan_passes(self):
|
||||
valid, issues = ChatSession._validate_plan(self.GOOD_PLAN, "add auth")
|
||||
assert valid
|
||||
assert issues == []
|
||||
|
||||
def test_too_short_fails(self):
|
||||
valid, issues = ChatSession._validate_plan("Do the thing.", "do stuff")
|
||||
assert not valid
|
||||
assert any("too short" in i for i in issues)
|
||||
|
||||
def test_no_sections_fails(self):
|
||||
content = "A" * 150 # long enough but no sections
|
||||
valid, issues = ChatSession._validate_plan(content, "build it")
|
||||
assert not valid
|
||||
assert any("missing plan sections" in i for i in issues)
|
||||
|
||||
def test_echo_detection(self):
|
||||
goal = "deliver a simpsons quote from a specific episode"
|
||||
content = "Deliver a Simpsons quote from a specific episode"
|
||||
valid, issues = ChatSession._validate_plan(content, goal)
|
||||
assert not valid
|
||||
assert any("echo" in i for i in issues)
|
||||
|
||||
def test_refusal_detection(self):
|
||||
content = "I cannot create a plan for this task because " + "x" * 100
|
||||
valid, issues = ChatSession._validate_plan(content, "do stuff")
|
||||
assert not valid
|
||||
assert any("refusal" in i for i in issues)
|
||||
|
||||
def test_partial_sections_passes(self):
|
||||
"""2 out of 4 sections is enough to pass."""
|
||||
content = (
|
||||
"## Goal\n\nFix the bug in parsing.\n\n"
|
||||
"## Plan\n\n1. Edit parser.py line 42.\n"
|
||||
"2. Add boundary check.\n"
|
||||
"This is enough detail to proceed with confidence."
|
||||
)
|
||||
valid, issues = ChatSession._validate_plan(content, "fix bug")
|
||||
assert valid
|
||||
|
||||
def test_one_section_fails(self):
|
||||
"""Only 1 out of 4 sections is not enough."""
|
||||
content = (
|
||||
"## Goal\n\nFix the bug.\n\n"
|
||||
"We should probably edit parser.py and add some checks "
|
||||
"to the boundary handling code path for safety."
|
||||
)
|
||||
valid, issues = ChatSession._validate_plan(content, "fix bug")
|
||||
assert not valid
|
||||
assert any("missing plan sections" in i for i in issues)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plan refinement loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlanRefinement:
|
||||
"""Tests for the iterative plan refinement loop in _execute_tools."""
|
||||
|
||||
GOOD_PLAN = TestPlanValidation.GOOD_PLAN
|
||||
|
||||
def test_feedback_triggers_refinement(self, tmp_db, tmp_path, monkeypatch):
|
||||
"""User feedback causes _refine_plan to run, then approval exits."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
session = _make_session()
|
||||
refine_called = []
|
||||
|
||||
review_responses = iter(["add error handling", ""])
|
||||
session.ui = MagicMock(spec_set=NullUI)
|
||||
session.ui.on_plan_review.side_effect = lambda c: next(review_responses)
|
||||
session.ui.on_info = MagicMock()
|
||||
session.ui.on_state_change = MagicMock()
|
||||
|
||||
revised = self.GOOD_PLAN + "\n\n3. Add error handling."
|
||||
|
||||
def fake_refine(content, goal, feedback):
|
||||
refine_called.append(feedback)
|
||||
return revised
|
||||
|
||||
with patch.object(session, "_refine_plan", side_effect=fake_refine):
|
||||
items = [
|
||||
{
|
||||
"func_name": "create_plan",
|
||||
"call_id": "c1",
|
||||
"prompt": "add auth",
|
||||
}
|
||||
]
|
||||
results = [("c1", self.GOOD_PLAN)]
|
||||
# Manually invoke the post-plan gate portion of _execute_tools.
|
||||
# We test the loop by calling the gate code directly.
|
||||
session.auto_approve = False
|
||||
|
||||
original_goal = items[0].get("prompt", "")
|
||||
output = results[0][1]
|
||||
refinement_round = 0
|
||||
while refinement_round < session._MAX_PLAN_REFINEMENTS:
|
||||
resp = session.ui.on_plan_review(output)
|
||||
if resp.lower() in ("n", "no", "reject"):
|
||||
break
|
||||
elif resp:
|
||||
output = session._refine_plan(output, original_goal, resp)
|
||||
refinement_round += 1
|
||||
else:
|
||||
break
|
||||
|
||||
assert len(refine_called) == 1
|
||||
assert refine_called[0] == "add error handling"
|
||||
assert "error handling" in output
|
||||
|
||||
def test_reject_skips_refinement(self, tmp_db, tmp_path, monkeypatch):
|
||||
"""Rejection exits immediately without calling _refine_plan."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
session = _make_session()
|
||||
session.ui = MagicMock(spec_set=NullUI)
|
||||
session.ui.on_plan_review.return_value = "reject"
|
||||
|
||||
with patch.object(session, "_refine_plan") as mock_refine:
|
||||
output = self.GOOD_PLAN
|
||||
resp = session.ui.on_plan_review(output)
|
||||
if resp.lower() in ("n", "no", "reject"):
|
||||
output += "\n\n---\nUser REJECTED"
|
||||
elif resp:
|
||||
output = session._refine_plan(output, "g", resp)
|
||||
|
||||
mock_refine.assert_not_called()
|
||||
assert "REJECTED" in output
|
||||
|
||||
def test_approve_skips_refinement(self, tmp_db, tmp_path, monkeypatch):
|
||||
"""Empty response (enter) approves without refinement."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
session = _make_session()
|
||||
session.ui = MagicMock(spec_set=NullUI)
|
||||
session.ui.on_plan_review.return_value = ""
|
||||
|
||||
with patch.object(session, "_refine_plan") as mock_refine:
|
||||
output = self.GOOD_PLAN
|
||||
resp = session.ui.on_plan_review(output)
|
||||
if resp.lower() in ("n", "no", "reject"):
|
||||
output += "\n\n---\nUser REJECTED"
|
||||
elif resp:
|
||||
output = session._refine_plan(output, "g", resp)
|
||||
|
||||
mock_refine.assert_not_called()
|
||||
assert "REJECTED" not in output
|
||||
|
||||
def test_max_refinement_rounds(self, tmp_db, tmp_path, monkeypatch):
|
||||
"""Loop stops after _MAX_PLAN_REFINEMENTS rounds with a final review."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
session = _make_session()
|
||||
session.ui = MagicMock(spec_set=NullUI)
|
||||
session.ui.on_plan_review.return_value = "more detail please"
|
||||
session.ui.on_info = MagicMock()
|
||||
|
||||
refine_count = 0
|
||||
|
||||
def fake_refine(content, goal, feedback):
|
||||
nonlocal refine_count
|
||||
refine_count += 1
|
||||
return content + f"\n(revision {refine_count})"
|
||||
|
||||
with patch.object(session, "_refine_plan", side_effect=fake_refine):
|
||||
output = self.GOOD_PLAN
|
||||
original_goal = "add auth"
|
||||
refinement_round = 0
|
||||
while True:
|
||||
resp = session.ui.on_plan_review(output)
|
||||
if (
|
||||
resp.lower() in ("n", "no", "reject")
|
||||
or not resp
|
||||
or refinement_round >= session._MAX_PLAN_REFINEMENTS
|
||||
):
|
||||
break
|
||||
output = session._refine_plan(output, original_goal, resp)
|
||||
refinement_round += 1
|
||||
|
||||
assert refine_count == session._MAX_PLAN_REFINEMENTS
|
||||
# User gets one extra review call after max rounds (the final prompt)
|
||||
assert session.ui.on_plan_review.call_count == session._MAX_PLAN_REFINEMENTS + 1
|
||||
|
||||
def test_refine_plan_message_structure(self, tmp_db, tmp_path, monkeypatch):
|
||||
"""_refine_plan passes system + prior plan + feedback to _run_agent."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
session = _make_session()
|
||||
captured = {}
|
||||
|
||||
def fake_run_agent(messages, **kwargs):
|
||||
captured["messages"] = list(messages)
|
||||
return self.GOOD_PLAN
|
||||
|
||||
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
|
||||
session._refine_plan(self.GOOD_PLAN, "add auth", "add tests too")
|
||||
|
||||
msgs = captured["messages"]
|
||||
assert msgs[0]["role"] == "system"
|
||||
assert msgs[1]["role"] == "assistant"
|
||||
assert msgs[1]["tool_calls"][0]["function"]["name"] == "create_plan"
|
||||
assert msgs[2]["role"] == "tool"
|
||||
assert msgs[2]["content"] == self.GOOD_PLAN
|
||||
assert msgs[3]["role"] == "user"
|
||||
assert "add tests too" in msgs[3]["content"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests for turnstone.core.policy."""
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.policy import evaluate_tool_policies_batch, evaluate_tool_policy
|
||||
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_no_policies_returns_none(storage):
|
||||
result = evaluate_tool_policy(storage, "bash")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_exact_match_allow(storage):
|
||||
storage.create_tool_policy("p1", "allow-read", "read_file", "allow", 0)
|
||||
assert evaluate_tool_policy(storage, "read_file") == "allow"
|
||||
assert evaluate_tool_policy(storage, "write_file") is None
|
||||
|
||||
|
||||
def test_glob_match_deny(storage):
|
||||
storage.create_tool_policy("p1", "block-bash", "bash*", "deny", 0)
|
||||
assert evaluate_tool_policy(storage, "bash") == "deny"
|
||||
assert evaluate_tool_policy(storage, "bash_exec") == "deny"
|
||||
assert evaluate_tool_policy(storage, "read_file") is None
|
||||
|
||||
|
||||
def test_wildcard_match(storage):
|
||||
storage.create_tool_policy("p1", "ask-all", "*", "ask", 0)
|
||||
assert evaluate_tool_policy(storage, "anything") == "ask"
|
||||
|
||||
|
||||
def test_priority_ordering(storage):
|
||||
# Higher priority wins
|
||||
storage.create_tool_policy("p1", "allow-all", "*", "allow", 0)
|
||||
storage.create_tool_policy("p2", "deny-bash", "bash*", "deny", 100)
|
||||
assert evaluate_tool_policy(storage, "bash") == "deny" # p2 matches first (higher priority)
|
||||
assert evaluate_tool_policy(storage, "read_file") == "allow" # p1 matches
|
||||
|
||||
|
||||
def test_disabled_policy_skipped(storage):
|
||||
storage.create_tool_policy("p1", "block-bash", "bash*", "deny", 100, enabled=False)
|
||||
storage.create_tool_policy("p2", "allow-all", "*", "allow", 0)
|
||||
assert evaluate_tool_policy(storage, "bash") == "allow" # p1 disabled, falls through to p2
|
||||
|
||||
|
||||
def test_batch_evaluation(storage):
|
||||
storage.create_tool_policy("p1", "block-bash", "bash*", "deny", 100)
|
||||
storage.create_tool_policy("p2", "allow-read", "read_*", "allow", 50)
|
||||
results = evaluate_tool_policies_batch(storage, ["bash", "read_file", "write_file"])
|
||||
assert results["bash"] == "deny"
|
||||
assert results["read_file"] == "allow"
|
||||
assert results["write_file"] is None
|
||||
|
||||
|
||||
def test_storage_failure_returns_none():
|
||||
"""Graceful degradation on storage failure."""
|
||||
|
||||
class BrokenStorage:
|
||||
def list_tool_policies(self, org_id=""):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
assert evaluate_tool_policy(BrokenStorage(), "bash") is None
|
||||
|
||||
|
||||
def test_batch_storage_failure():
|
||||
class BrokenStorage:
|
||||
def list_tool_policies(self, org_id=""):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
results = evaluate_tool_policies_batch(BrokenStorage(), ["a", "b"])
|
||||
assert results == {"a": None, "b": None}
|
||||
|
||||
|
||||
def test_first_match_wins(storage):
|
||||
# Two policies match, first by priority wins
|
||||
storage.create_tool_policy("p1", "deny-bash", "bash*", "deny", 100)
|
||||
storage.create_tool_policy("p2", "allow-bash", "bash*", "allow", 50)
|
||||
assert evaluate_tool_policy(storage, "bash_exec") == "deny"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP resource and prompt policy patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mcp_resource_wildcard_deny(storage):
|
||||
"""Deny all MCP resource reads via glob pattern."""
|
||||
storage.create_tool_policy("p1", "block-resources", "mcp_resource__*", "deny", 100)
|
||||
assert evaluate_tool_policy(storage, "mcp_resource__file:///secret.txt") == "deny"
|
||||
assert evaluate_tool_policy(storage, "mcp_resource__db://users") == "deny"
|
||||
assert evaluate_tool_policy(storage, "read_file") is None # unrelated tool
|
||||
|
||||
|
||||
def test_mcp_resource_per_server_pattern(storage):
|
||||
"""Allow resources from a specific server, deny others."""
|
||||
storage.create_tool_policy("p1", "block-all-resources", "mcp_resource__*", "deny", 50)
|
||||
storage.create_tool_policy("p2", "allow-docs", "mcp_resource__file:///docs/*", "allow", 100)
|
||||
assert evaluate_tool_policy(storage, "mcp_resource__file:///docs/readme.md") == "allow"
|
||||
assert evaluate_tool_policy(storage, "mcp_resource__file:///etc/passwd") == "deny"
|
||||
|
||||
|
||||
def test_mcp_prompt_wildcard_ask(storage):
|
||||
"""Require approval for all MCP prompt invocations."""
|
||||
storage.create_tool_policy("p1", "ask-prompts", "mcp__*", "ask", 100)
|
||||
assert evaluate_tool_policy(storage, "mcp__github__code_review") == "ask"
|
||||
assert evaluate_tool_policy(storage, "mcp__templates__greeting") == "ask"
|
||||
assert evaluate_tool_policy(storage, "bash") is None
|
||||
|
||||
|
||||
def test_mcp_prompt_per_server_allow(storage):
|
||||
"""Auto-approve prompts from a trusted server."""
|
||||
storage.create_tool_policy("p1", "ask-all-mcp", "mcp__*", "ask", 50)
|
||||
storage.create_tool_policy("p2", "allow-trusted", "mcp__trusted__*", "allow", 100)
|
||||
assert evaluate_tool_policy(storage, "mcp__trusted__greeting") == "allow"
|
||||
assert evaluate_tool_policy(storage, "mcp__untrusted__evil") == "ask"
|
||||
|
||||
|
||||
def test_mcp_batch_mixed(storage):
|
||||
"""Batch evaluation with mixed MCP and built-in tools."""
|
||||
storage.create_tool_policy("p1", "block-resources", "mcp_resource__*", "deny", 100)
|
||||
storage.create_tool_policy("p2", "allow-prompts", "mcp__trusted__*", "allow", 100)
|
||||
results = evaluate_tool_policies_batch(
|
||||
storage,
|
||||
["mcp_resource__file:///x", "mcp__trusted__greeting", "bash", "mcp__other__y"],
|
||||
)
|
||||
assert results["mcp_resource__file:///x"] == "deny"
|
||||
assert results["mcp__trusted__greeting"] == "allow"
|
||||
assert results["bash"] is None
|
||||
assert results["mcp__other__y"] is None
|
||||
|
||||
|
||||
def test_normalize_resource_uri_prevents_traversal():
|
||||
"""URI normalization resolves .. segments to prevent policy traversal bypass."""
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
# Normal URI unchanged
|
||||
assert ChatSession._normalize_resource_uri("file:///docs/readme.md") == "file:///docs/readme.md"
|
||||
# Traversal resolved
|
||||
assert ChatSession._normalize_resource_uri("file:///docs/../etc/passwd") == "file:///etc/passwd"
|
||||
# Double traversal
|
||||
assert ChatSession._normalize_resource_uri("file:///a/b/../../c") == "file:///c"
|
||||
# Non-file scheme (netloc preserved, path normalized)
|
||||
assert ChatSession._normalize_resource_uri("db://host/tables/../secrets") == "db://host/secrets"
|
||||
# Percent-encoded traversal decoded before normalization
|
||||
assert (
|
||||
ChatSession._normalize_resource_uri("file:///docs/%2e%2e/etc/passwd")
|
||||
== "file:///etc/passwd"
|
||||
)
|
||||
# Mixed percent-encoded and literal traversal
|
||||
assert ChatSession._normalize_resource_uri("file:///a/%2e%2e/b/../c") == "file:///c"
|
||||
|
||||
|
||||
def test_mcp_tool_granular_policy(storage):
|
||||
"""MCP tool calls use their prefixed func_name for granular policy matching."""
|
||||
storage.create_tool_policy("p1", "ask-all-mcp", "mcp__*", "ask", 50)
|
||||
storage.create_tool_policy("p2", "allow-github", "mcp__github__*", "allow", 100)
|
||||
# MCP tools now use func_name as approval_label
|
||||
assert evaluate_tool_policy(storage, "mcp__github__search") == "allow"
|
||||
assert evaluate_tool_policy(storage, "mcp__untrusted__exec") == "ask"
|
||||
@@ -72,16 +72,24 @@ class TestToolsMetadata:
|
||||
"""Validate the metadata extracted from JSON files."""
|
||||
|
||||
def test_tool_count(self):
|
||||
assert len(TOOLS) == 16
|
||||
assert len(TOOLS) == 18
|
||||
|
||||
def test_agent_tools_count(self):
|
||||
assert len(AGENT_TOOLS) == 7
|
||||
assert len(AGENT_TOOLS) == 9
|
||||
|
||||
def test_task_agent_tools_count(self):
|
||||
assert len(TASK_AGENT_TOOLS) == 10
|
||||
assert len(TASK_AGENT_TOOLS) == 12
|
||||
|
||||
def test_auto_approve_sets_match(self):
|
||||
expected = {"read_file", "search", "math", "man", "web_fetch", "web_search", "notify"}
|
||||
expected = {
|
||||
"read_file",
|
||||
"search",
|
||||
"math",
|
||||
"man",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"notify",
|
||||
}
|
||||
assert expected == AGENT_AUTO_TOOLS
|
||||
assert expected == TASK_AUTO_TOOLS
|
||||
|
||||
@@ -103,6 +111,8 @@ class TestToolsMetadata:
|
||||
"forget": "key",
|
||||
"notify": "message",
|
||||
"watch": "command",
|
||||
"read_resource": "uri",
|
||||
"use_prompt": "name",
|
||||
}
|
||||
assert expected == PRIMARY_KEY_MAP
|
||||
|
||||
|
||||
@@ -65,6 +65,14 @@ class TestUserCRUD:
|
||||
db.delete_user("u1")
|
||||
assert len(db.list_api_tokens("u1")) == 0
|
||||
|
||||
def test_delete_cascades_user_roles(self, db):
|
||||
db.create_user("u1", "admin", "Admin", "$2b$hash")
|
||||
db.create_role("r1", "editor", "Editor", "read,write", builtin=False, org_id="")
|
||||
db.assign_role("u1", "r1")
|
||||
assert len(db.list_user_roles("u1")) == 1
|
||||
db.delete_user("u1")
|
||||
assert len(db.list_user_roles("u1")) == 0
|
||||
|
||||
|
||||
class TestApiTokenCRUD:
|
||||
def test_create_and_lookup_by_hash(self, db):
|
||||
|
||||
@@ -665,6 +665,31 @@ class TestWebUI:
|
||||
assert ui._approval_result == (True, "looks good")
|
||||
t.join()
|
||||
|
||||
def test_resolve_approval_emits_event(self):
|
||||
"""resolve_approval should enqueue an approval_resolved SSE event."""
|
||||
from turnstone.server import WebUI
|
||||
|
||||
ui = WebUI(ws_id="test-emit")
|
||||
listener = ui._register_listener()
|
||||
|
||||
# Drain any init events
|
||||
while not listener.empty():
|
||||
listener.get_nowait()
|
||||
|
||||
ui.resolve_approval(False, "Approval timed out")
|
||||
|
||||
# Collect events from the listener
|
||||
events = []
|
||||
while not listener.empty():
|
||||
events.append(listener.get_nowait())
|
||||
|
||||
ui._unregister_listener(listener)
|
||||
|
||||
resolved = [e for e in events if e.get("type") == "approval_resolved"]
|
||||
assert len(resolved) == 1
|
||||
assert resolved[0]["approved"] is False
|
||||
assert resolved[0]["feedback"] == "Approval timed out"
|
||||
|
||||
def test_resolve_plan(self):
|
||||
from turnstone.server import WebUI
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "0.5.3"
|
||||
__version__ = "0.5.5"
|
||||
|
||||
@@ -136,6 +136,9 @@ class ConsoleCreateWsRequest(BaseModel):
|
||||
initial_message: str = Field(
|
||||
default="", description="Optional first message sent after creation"
|
||||
)
|
||||
template: str = Field(
|
||||
default="", description="Prompt template name (replaces default templates)"
|
||||
)
|
||||
|
||||
|
||||
class ConsoleCreateWsResponse(BaseModel):
|
||||
@@ -156,3 +159,219 @@ class ConsoleHealthResponse(BaseModel):
|
||||
workstreams: int = 0
|
||||
version_drift: bool = False
|
||||
versions: list[str] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance: Roles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RoleInfo(BaseModel):
|
||||
role_id: str
|
||||
name: str
|
||||
display_name: str
|
||||
permissions: str
|
||||
builtin: bool
|
||||
org_id: str
|
||||
created: str
|
||||
updated: str
|
||||
|
||||
|
||||
class CreateRoleRequest(BaseModel):
|
||||
name: str
|
||||
display_name: str = ""
|
||||
permissions: str = "read"
|
||||
|
||||
|
||||
class UpdateRoleRequest(BaseModel):
|
||||
display_name: str | None = None
|
||||
permissions: str | None = None
|
||||
|
||||
|
||||
class ListRolesResponse(BaseModel):
|
||||
roles: list[RoleInfo]
|
||||
|
||||
|
||||
class AssignRoleRequest(BaseModel):
|
||||
role_id: str
|
||||
|
||||
|
||||
class UserRoleInfo(BaseModel):
|
||||
role_id: str
|
||||
name: str
|
||||
display_name: str
|
||||
permissions: str
|
||||
builtin: bool
|
||||
org_id: str
|
||||
created: str
|
||||
updated: str
|
||||
assigned_by: str
|
||||
assignment_created: str
|
||||
|
||||
|
||||
class ListUserRolesResponse(BaseModel):
|
||||
roles: list[UserRoleInfo]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance: Orgs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OrgInfo(BaseModel):
|
||||
org_id: str
|
||||
name: str
|
||||
display_name: str
|
||||
settings: str
|
||||
created: str
|
||||
updated: str
|
||||
|
||||
|
||||
class UpdateOrgRequest(BaseModel):
|
||||
display_name: str | None = None
|
||||
settings: str | None = None
|
||||
|
||||
|
||||
class ListOrgsResponse(BaseModel):
|
||||
orgs: list[OrgInfo]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance: Tool Policies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ToolPolicyInfo(BaseModel):
|
||||
policy_id: str
|
||||
name: str
|
||||
tool_pattern: str
|
||||
action: str
|
||||
priority: int
|
||||
org_id: str
|
||||
enabled: bool
|
||||
created_by: str
|
||||
created: str
|
||||
updated: str
|
||||
|
||||
|
||||
class CreateToolPolicyRequest(BaseModel):
|
||||
name: str
|
||||
tool_pattern: str
|
||||
action: str # allow, deny, ask
|
||||
priority: int = 0
|
||||
org_id: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class UpdateToolPolicyRequest(BaseModel):
|
||||
name: str | None = None
|
||||
tool_pattern: str | None = None
|
||||
action: str | None = None
|
||||
priority: int | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class ListToolPoliciesResponse(BaseModel):
|
||||
policies: list[ToolPolicyInfo]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance: Prompt Templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PromptTemplateInfo(BaseModel):
|
||||
template_id: str
|
||||
name: str
|
||||
category: str
|
||||
content: str
|
||||
variables: str
|
||||
is_default: bool
|
||||
org_id: str
|
||||
created_by: str
|
||||
origin: str = "manual"
|
||||
mcp_server: str = ""
|
||||
readonly: bool = False
|
||||
created: str
|
||||
updated: str
|
||||
|
||||
|
||||
class CreatePromptTemplateRequest(BaseModel):
|
||||
name: str
|
||||
content: str
|
||||
category: str = "general"
|
||||
variables: str = "[]"
|
||||
is_default: bool = False
|
||||
org_id: str = ""
|
||||
|
||||
|
||||
class UpdatePromptTemplateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
content: str | None = None
|
||||
category: str | None = None
|
||||
variables: str | None = None
|
||||
is_default: bool | None = None
|
||||
|
||||
|
||||
class ListPromptTemplatesResponse(BaseModel):
|
||||
templates: list[PromptTemplateInfo]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance: Usage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class UsageBreakdownItem(BaseModel):
|
||||
key: str = ""
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
tool_calls_count: int = 0
|
||||
|
||||
|
||||
class UsageResponse(BaseModel):
|
||||
summary: list[UsageBreakdownItem]
|
||||
breakdown: list[UsageBreakdownItem]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance: Audit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AuditEventInfo(BaseModel):
|
||||
event_id: str
|
||||
timestamp: str
|
||||
user_id: str
|
||||
action: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
detail: str
|
||||
ip_address: str
|
||||
created: str
|
||||
|
||||
|
||||
class ListAuditEventsResponse(BaseModel):
|
||||
events: list[AuditEventInfo]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Channels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ChannelUserInfo(BaseModel):
|
||||
channel_type: str
|
||||
channel_user_id: str
|
||||
user_id: str
|
||||
created: str
|
||||
|
||||
|
||||
class ListChannelUsersResponse(BaseModel):
|
||||
channels: list[ChannelUserInfo]
|
||||
|
||||
|
||||
class CreateChannelUserRequest(BaseModel):
|
||||
channel_type: str = Field(..., description="Channel type (e.g. discord, slack)")
|
||||
channel_user_id: str = Field(..., description="External channel user identifier")
|
||||
total: int
|
||||
|
||||
@@ -8,6 +8,9 @@ if TYPE_CHECKING:
|
||||
from pydantic import BaseModel
|
||||
|
||||
from turnstone.api.console_schemas import (
|
||||
AssignRoleRequest,
|
||||
AuditEventInfo,
|
||||
ChannelUserInfo,
|
||||
ClusterNodesResponse,
|
||||
ClusterOverviewResponse,
|
||||
ClusterSnapshotResponse,
|
||||
@@ -15,7 +18,29 @@ from turnstone.api.console_schemas import (
|
||||
ConsoleCreateWsRequest,
|
||||
ConsoleCreateWsResponse,
|
||||
ConsoleHealthResponse,
|
||||
CreateChannelUserRequest,
|
||||
CreatePromptTemplateRequest,
|
||||
CreateRoleRequest,
|
||||
CreateToolPolicyRequest,
|
||||
ListAuditEventsResponse,
|
||||
ListChannelUsersResponse,
|
||||
ListOrgsResponse,
|
||||
ListPromptTemplatesResponse,
|
||||
ListRolesResponse,
|
||||
ListToolPoliciesResponse,
|
||||
ListUserRolesResponse,
|
||||
NodeDetailResponse,
|
||||
OrgInfo,
|
||||
PromptTemplateInfo,
|
||||
RoleInfo,
|
||||
ToolPolicyInfo,
|
||||
UpdateOrgRequest,
|
||||
UpdatePromptTemplateRequest,
|
||||
UpdateRoleRequest,
|
||||
UpdateToolPolicyRequest,
|
||||
UsageBreakdownItem,
|
||||
UsageResponse,
|
||||
UserRoleInfo,
|
||||
)
|
||||
from turnstone.api.openapi import EndpointSpec, QueryParam, build_openapi
|
||||
from turnstone.api.schemas import (
|
||||
@@ -197,6 +222,31 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Channels ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/users/{user_id}/channels",
|
||||
"GET",
|
||||
"List channel links for a user",
|
||||
response_model=ListChannelUsersResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/users/{user_id}/channels",
|
||||
"POST",
|
||||
"Link a channel account to a user",
|
||||
request_model=CreateChannelUserRequest,
|
||||
response_model=ChannelUserInfo,
|
||||
error_codes=[400, 404, 409],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/channels/{channel_type}/{channel_user_id}",
|
||||
"DELETE",
|
||||
"Unlink a channel account",
|
||||
response_model=StatusResponse,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Schedules ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/schedules",
|
||||
@@ -252,6 +302,191 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
error_codes=[404],
|
||||
tags=["Schedules"],
|
||||
),
|
||||
# --- Governance: Roles ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/roles",
|
||||
"GET",
|
||||
"List all roles",
|
||||
response_model=ListRolesResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/roles",
|
||||
"POST",
|
||||
"Create a custom role",
|
||||
request_model=CreateRoleRequest,
|
||||
response_model=RoleInfo,
|
||||
error_codes=[400],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/roles/{role_id}",
|
||||
"PUT",
|
||||
"Update a role",
|
||||
request_model=UpdateRoleRequest,
|
||||
response_model=RoleInfo,
|
||||
error_codes=[400, 404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/roles/{role_id}",
|
||||
"DELETE",
|
||||
"Delete a custom role",
|
||||
response_model=StatusResponse,
|
||||
error_codes=[400, 404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/users/{user_id}/roles",
|
||||
"GET",
|
||||
"List roles assigned to a user",
|
||||
response_model=ListUserRolesResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/users/{user_id}/roles",
|
||||
"POST",
|
||||
"Assign a role to a user",
|
||||
request_model=AssignRoleRequest,
|
||||
response_model=StatusResponse,
|
||||
error_codes=[400, 404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/users/{user_id}/roles/{role_id}",
|
||||
"DELETE",
|
||||
"Unassign a role from a user",
|
||||
response_model=StatusResponse,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Governance: Orgs ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/orgs",
|
||||
"GET",
|
||||
"List organizations",
|
||||
response_model=ListOrgsResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/orgs/{org_id}",
|
||||
"GET",
|
||||
"Get organization details",
|
||||
response_model=OrgInfo,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/orgs/{org_id}",
|
||||
"PUT",
|
||||
"Update organization settings",
|
||||
request_model=UpdateOrgRequest,
|
||||
response_model=OrgInfo,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Governance: Tool Policies ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/policies",
|
||||
"GET",
|
||||
"List tool policies",
|
||||
response_model=ListToolPoliciesResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/policies",
|
||||
"POST",
|
||||
"Create a tool policy",
|
||||
request_model=CreateToolPolicyRequest,
|
||||
response_model=ToolPolicyInfo,
|
||||
error_codes=[400],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/policies/{policy_id}",
|
||||
"PUT",
|
||||
"Update a tool policy",
|
||||
request_model=UpdateToolPolicyRequest,
|
||||
response_model=ToolPolicyInfo,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/policies/{policy_id}",
|
||||
"DELETE",
|
||||
"Delete a tool policy",
|
||||
response_model=StatusResponse,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Governance: Prompt Templates ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/templates",
|
||||
"GET",
|
||||
"List prompt templates",
|
||||
response_model=ListPromptTemplatesResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/templates",
|
||||
"POST",
|
||||
"Create a prompt template",
|
||||
request_model=CreatePromptTemplateRequest,
|
||||
response_model=PromptTemplateInfo,
|
||||
error_codes=[400],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/templates/{template_id}",
|
||||
"PUT",
|
||||
"Update a prompt template",
|
||||
request_model=UpdatePromptTemplateRequest,
|
||||
response_model=PromptTemplateInfo,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/templates/{template_id}",
|
||||
"DELETE",
|
||||
"Delete a prompt template",
|
||||
response_model=StatusResponse,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Governance: Usage & Audit ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/usage",
|
||||
"GET",
|
||||
"Aggregated usage data",
|
||||
response_model=UsageResponse,
|
||||
query_params=[
|
||||
QueryParam("since", "Start timestamp (ISO8601, defaults to last 7 days)"),
|
||||
QueryParam("until", "End timestamp (ISO8601)"),
|
||||
QueryParam("user_id", "Filter by user"),
|
||||
QueryParam("model", "Filter by model"),
|
||||
QueryParam(
|
||||
"group_by",
|
||||
"Group results",
|
||||
enum=["day", "hour", "model", "user"],
|
||||
),
|
||||
],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/audit",
|
||||
"GET",
|
||||
"Paginated audit events",
|
||||
response_model=ListAuditEventsResponse,
|
||||
query_params=[
|
||||
QueryParam("action", "Filter by action type"),
|
||||
QueryParam("user_id", "Filter by user"),
|
||||
QueryParam("since", "Start timestamp (ISO8601)"),
|
||||
QueryParam("until", "End timestamp (ISO8601)"),
|
||||
QueryParam("limit", "Page size", schema_type="integer", default=50),
|
||||
QueryParam("offset", "Pagination offset", schema_type="integer", default=0),
|
||||
],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Observability ---
|
||||
EndpointSpec(
|
||||
"/health",
|
||||
@@ -276,6 +511,9 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
CreateTokenRequest,
|
||||
CreateTokenResponse,
|
||||
ListTokensResponse,
|
||||
ChannelUserInfo,
|
||||
CreateChannelUserRequest,
|
||||
ListChannelUsersResponse,
|
||||
ClusterOverviewResponse,
|
||||
ClusterNodesResponse,
|
||||
ClusterWorkstreamsResponse,
|
||||
@@ -289,6 +527,28 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
ScheduleInfo,
|
||||
ListSchedulesResponse,
|
||||
ListScheduleRunsResponse,
|
||||
RoleInfo,
|
||||
CreateRoleRequest,
|
||||
UpdateRoleRequest,
|
||||
ListRolesResponse,
|
||||
AssignRoleRequest,
|
||||
UserRoleInfo,
|
||||
ListUserRolesResponse,
|
||||
OrgInfo,
|
||||
UpdateOrgRequest,
|
||||
ListOrgsResponse,
|
||||
ToolPolicyInfo,
|
||||
CreateToolPolicyRequest,
|
||||
UpdateToolPolicyRequest,
|
||||
ListToolPoliciesResponse,
|
||||
PromptTemplateInfo,
|
||||
CreatePromptTemplateRequest,
|
||||
UpdatePromptTemplateRequest,
|
||||
ListPromptTemplatesResponse,
|
||||
UsageBreakdownItem,
|
||||
UsageResponse,
|
||||
AuditEventInfo,
|
||||
ListAuditEventsResponse,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ class CreateScheduleRequest(BaseModel):
|
||||
initial_message: str = Field(description="Message sent to the new workstream")
|
||||
auto_approve: bool = Field(default=False)
|
||||
auto_approve_tools: list[str] = Field(default_factory=list)
|
||||
template: str = Field(default="", description="Prompt template name")
|
||||
enabled: bool = Field(default=True)
|
||||
|
||||
|
||||
@@ -191,6 +192,7 @@ class UpdateScheduleRequest(BaseModel):
|
||||
initial_message: str | None = None
|
||||
auto_approve: bool | None = None
|
||||
auto_approve_tools: list[str] | None = None
|
||||
template: str | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
@@ -208,6 +210,7 @@ class ScheduleInfo(BaseModel):
|
||||
initial_message: str
|
||||
auto_approve: bool = False
|
||||
auto_approve_tools: list[str] = Field(default_factory=list)
|
||||
template: str = ""
|
||||
enabled: bool = True
|
||||
created_by: str = ""
|
||||
last_run: str | None = None
|
||||
|
||||
@@ -35,6 +35,10 @@ class CommandRequest(BaseModel):
|
||||
ws_id: str = Field(description="Target workstream ID")
|
||||
|
||||
|
||||
class CancelRequest(BaseModel):
|
||||
ws_id: str = Field(description="Target workstream ID")
|
||||
|
||||
|
||||
class CreateWorkstreamRequest(BaseModel):
|
||||
name: str = Field(default="", description="Workstream display name (auto-generated if empty)")
|
||||
model: str = Field(default="", description="Model alias from registry")
|
||||
@@ -43,6 +47,9 @@ class CreateWorkstreamRequest(BaseModel):
|
||||
default="",
|
||||
description="Workstream ID to resume atomically during creation (empty = fresh start)",
|
||||
)
|
||||
template: str = Field(
|
||||
default="", description="Prompt template name (replaces default templates)"
|
||||
)
|
||||
|
||||
|
||||
class CreateWorkstreamResponse(BaseModel):
|
||||
@@ -139,6 +146,12 @@ class WorkstreamCounts(BaseModel):
|
||||
error: int = 0
|
||||
|
||||
|
||||
class McpStatus(BaseModel):
|
||||
servers: int = 0
|
||||
resources: int = 0
|
||||
prompts: int = 0
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = Field(examples=["ok", "degraded"])
|
||||
version: str = ""
|
||||
@@ -146,3 +159,4 @@ class HealthResponse(BaseModel):
|
||||
model: str = ""
|
||||
workstreams: WorkstreamCounts = WorkstreamCounts()
|
||||
backend: BackendStatus | None = None
|
||||
mcp: McpStatus | None = None
|
||||
|
||||
@@ -19,6 +19,7 @@ from turnstone.api.schemas import (
|
||||
)
|
||||
from turnstone.api.server_schemas import (
|
||||
ApproveRequest,
|
||||
CancelRequest,
|
||||
CloseWorkstreamRequest,
|
||||
CommandRequest,
|
||||
CreateWorkstreamRequest,
|
||||
@@ -103,6 +104,15 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
|
||||
error_codes=[400, 404],
|
||||
tags=["Chat"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/cancel",
|
||||
"POST",
|
||||
"Cancel the active generation in a workstream",
|
||||
request_model=CancelRequest,
|
||||
response_model=StatusResponse,
|
||||
error_codes=[400, 404],
|
||||
tags=["Chat"],
|
||||
),
|
||||
# --- Streaming ---
|
||||
EndpointSpec(
|
||||
"/v1/api/events",
|
||||
@@ -186,6 +196,7 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
ApproveRequest,
|
||||
PlanFeedbackRequest,
|
||||
CommandRequest,
|
||||
CancelRequest,
|
||||
CreateWorkstreamRequest,
|
||||
CreateWorkstreamResponse,
|
||||
CloseWorkstreamRequest,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,3 +21,4 @@ class ChannelConfig:
|
||||
model: str = ""
|
||||
auto_approve: bool = False
|
||||
auto_approve_tools: list[str] = field(default_factory=list)
|
||||
template: str = ""
|
||||
|
||||
@@ -49,11 +49,13 @@ class ChannelRouter:
|
||||
*,
|
||||
auto_approve: bool = False,
|
||||
auto_approve_tools: list[str] | None = None,
|
||||
template: str = "",
|
||||
) -> None:
|
||||
self._broker = broker
|
||||
self._storage = storage
|
||||
self._auto_approve = auto_approve
|
||||
self._auto_approve_tools: list[str] = auto_approve_tools or []
|
||||
self._template = template
|
||||
self._pending: dict[str, asyncio.Event] = {}
|
||||
self._pending_results: dict[str, str] = {}
|
||||
self._global_task: asyncio.Task[None] | None = None
|
||||
@@ -172,6 +174,7 @@ class ChannelRouter:
|
||||
resume_ws=resume_ws,
|
||||
auto_approve=self._auto_approve,
|
||||
auto_approve_tools=list(self._auto_approve_tools),
|
||||
template=self._template,
|
||||
)
|
||||
cid = msg.correlation_id
|
||||
waiter = asyncio.Event()
|
||||
|
||||
@@ -141,6 +141,7 @@ class TurnstoneBot:
|
||||
storage,
|
||||
auto_approve=config.auto_approve,
|
||||
auto_approve_tools=list(config.auto_approve_tools),
|
||||
template=config.template,
|
||||
)
|
||||
|
||||
self._subscribed_ws: set[str] = set()
|
||||
|
||||
+11
-1
@@ -204,7 +204,8 @@ class TerminalUI(SessionUI):
|
||||
try:
|
||||
prompt_text = (
|
||||
f" \001{BOLD}\002Plan ready.\001{RESET}\002 "
|
||||
f"\001{DIM}\002[enter to approve, or give feedback]\001{RESET}\002 "
|
||||
f"\001{DIM}\002[enter to approve, feedback to amend, "
|
||||
f"ctrl-c to reject]\001{RESET}\002 "
|
||||
)
|
||||
resp = input(prompt_text).strip()
|
||||
except EOFError:
|
||||
@@ -724,6 +725,11 @@ def main() -> None:
|
||||
default=None,
|
||||
help="Developer instructions injected as developer message",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--template",
|
||||
default=None,
|
||||
help="Prompt template name (replaces default templates)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--temperature",
|
||||
type=float,
|
||||
@@ -951,6 +957,7 @@ def main() -> None:
|
||||
tool_search=args.tool_search,
|
||||
tool_search_threshold=args.tool_search_threshold,
|
||||
tool_search_max_results=args.tool_search_max_results,
|
||||
template=args.template,
|
||||
)
|
||||
|
||||
# Create workstream manager and initial workstream
|
||||
@@ -1001,6 +1008,9 @@ def main() -> None:
|
||||
mcp_tools = mcp_client.get_tools()
|
||||
if mcp_tools:
|
||||
print(f"MCP tools: {len(mcp_tools)} from {mcp_client.server_count} server(s)")
|
||||
from turnstone.core.storage import get_storage as _cli_get_storage
|
||||
|
||||
mcp_client.set_storage(_cli_get_storage())
|
||||
print("Type /help for commands, /ws for workstreams, /exit or Ctrl+D to quit.\n")
|
||||
|
||||
# Prompt string -- use a short display name
|
||||
|
||||
@@ -320,6 +320,9 @@ class ClusterCollector:
|
||||
total_tokens = 0
|
||||
total_tool_calls = 0
|
||||
total_ws = 0
|
||||
mcp_servers = 0
|
||||
mcp_resources = 0
|
||||
mcp_prompts = 0
|
||||
versions: set[str] = set()
|
||||
with self._lock:
|
||||
for node in self._nodes.values():
|
||||
@@ -332,8 +335,12 @@ class ClusterCollector:
|
||||
ver = node.health.get("version", "")
|
||||
if ver:
|
||||
versions.add(ver)
|
||||
mcp = node.health.get("mcp", {})
|
||||
mcp_servers += mcp.get("servers", 0)
|
||||
mcp_resources += mcp.get("resources", 0)
|
||||
mcp_prompts += mcp.get("prompts", 0)
|
||||
node_count = len(self._nodes)
|
||||
return {
|
||||
result: dict[str, Any] = {
|
||||
"nodes": node_count,
|
||||
"workstreams": total_ws,
|
||||
"states": states,
|
||||
@@ -344,6 +351,11 @@ class ClusterCollector:
|
||||
"version_drift": len(versions) > 1,
|
||||
"versions": sorted(versions),
|
||||
}
|
||||
if mcp_servers:
|
||||
result["mcp_servers"] = mcp_servers
|
||||
result["mcp_resources"] = mcp_resources
|
||||
result["mcp_prompts"] = mcp_prompts
|
||||
return result
|
||||
|
||||
def get_version_info(self) -> dict[str, Any]:
|
||||
"""Return per-node version map and drift flag."""
|
||||
@@ -515,6 +527,9 @@ class ClusterCollector:
|
||||
total_tokens = 0
|
||||
total_tool_calls = 0
|
||||
total_ws = 0
|
||||
mcp_servers = 0
|
||||
mcp_resources = 0
|
||||
mcp_prompts = 0
|
||||
versions: set[str] = set()
|
||||
|
||||
for node in self._nodes.values():
|
||||
@@ -530,6 +545,10 @@ class ClusterCollector:
|
||||
ver = node.health.get("version", "")
|
||||
if ver:
|
||||
versions.add(ver)
|
||||
mcp = node.health.get("mcp", {})
|
||||
mcp_servers += mcp.get("servers", 0)
|
||||
mcp_resources += mcp.get("resources", 0)
|
||||
mcp_prompts += mcp.get("prompts", 0)
|
||||
|
||||
nodes_out.append(
|
||||
{
|
||||
@@ -546,19 +565,25 @@ class ClusterCollector:
|
||||
|
||||
node_count = len(self._nodes)
|
||||
|
||||
overview: dict[str, Any] = {
|
||||
"nodes": node_count,
|
||||
"workstreams": total_ws,
|
||||
"states": states,
|
||||
"aggregate": {
|
||||
"total_tokens": total_tokens,
|
||||
"total_tool_calls": total_tool_calls,
|
||||
},
|
||||
"version_drift": len(versions) > 1,
|
||||
"versions": sorted(versions),
|
||||
}
|
||||
if mcp_servers:
|
||||
overview["mcp_servers"] = mcp_servers
|
||||
overview["mcp_resources"] = mcp_resources
|
||||
overview["mcp_prompts"] = mcp_prompts
|
||||
|
||||
return {
|
||||
"nodes": nodes_out,
|
||||
"overview": {
|
||||
"nodes": node_count,
|
||||
"workstreams": total_ws,
|
||||
"states": states,
|
||||
"aggregate": {
|
||||
"total_tokens": total_tokens,
|
||||
"total_tool_calls": total_tool_calls,
|
||||
},
|
||||
"version_drift": len(versions) > 1,
|
||||
"versions": sorted(versions),
|
||||
},
|
||||
"overview": overview,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,18 @@ class TaskScheduler:
|
||||
pruned = self._storage.prune_task_runs(retention_days=90)
|
||||
if pruned:
|
||||
log.info("scheduler.pruned_runs", count=pruned)
|
||||
try:
|
||||
usage_pruned = self._storage.prune_usage_events(retention_days=90)
|
||||
if usage_pruned:
|
||||
log.info("scheduler.pruned_usage", count=usage_pruned)
|
||||
except Exception:
|
||||
log.warning("scheduler.prune_usage_error", exc_info=True)
|
||||
try:
|
||||
audit_pruned = self._storage.prune_audit_events(retention_days=365)
|
||||
if audit_pruned:
|
||||
log.info("scheduler.pruned_audit", count=audit_pruned)
|
||||
except Exception:
|
||||
log.warning("scheduler.prune_audit_error", exc_info=True)
|
||||
finally:
|
||||
# Only release our own lock (safe even if TTL expired and another took it)
|
||||
self._broker._redis.eval( # type: ignore[no-untyped-call]
|
||||
@@ -196,6 +208,7 @@ class TaskScheduler:
|
||||
auto_approve=bool(task.get("auto_approve", 0)),
|
||||
auto_approve_tools=self._parse_tools(task),
|
||||
user_id=task.get("created_by", ""),
|
||||
template=task.get("template", ""),
|
||||
)
|
||||
self._broker.push_inbound(msg.to_json(), node_id=node_id)
|
||||
|
||||
@@ -221,6 +234,7 @@ class TaskScheduler:
|
||||
auto_approve=bool(task.get("auto_approve", 0)),
|
||||
auto_approve_tools=self._parse_tools(task),
|
||||
user_id=task.get("created_by", ""),
|
||||
template=task.get("template", ""),
|
||||
)
|
||||
self._broker.push_inbound(msg.to_json())
|
||||
|
||||
|
||||
+1053
-5
File diff suppressed because it is too large
Load Diff
@@ -29,11 +29,62 @@ function showAdmin() {
|
||||
document.getElementById("breadcrumb-label").textContent = "Admin";
|
||||
document.getElementById("main").scrollTop = 0;
|
||||
history.pushState({ view: "admin" }, "");
|
||||
loadAdminUsers();
|
||||
|
||||
// Permission gating: hide tabs the user cannot access
|
||||
var perms = sessionStorage.getItem("turnstone_permissions") || "";
|
||||
var tabPerms = {
|
||||
users: "admin.users",
|
||||
tokens: "admin.users",
|
||||
channels: "admin.users",
|
||||
schedules: "admin.schedules",
|
||||
watches: "admin.watches",
|
||||
roles: "admin.roles",
|
||||
policies: "admin.policies",
|
||||
templates: "admin.templates",
|
||||
usage: "admin.usage",
|
||||
audit: "admin.audit",
|
||||
};
|
||||
if (perms) {
|
||||
var permSet = perms.split(",");
|
||||
var tabs = document.querySelectorAll(".admin-tab");
|
||||
for (var i = 0; i < tabs.length; i++) {
|
||||
var tabName = tabs[i].getAttribute("data-tab");
|
||||
var needed = tabPerms[tabName];
|
||||
if (needed && permSet.indexOf(needed) < 0) {
|
||||
tabs[i].style.display = "none";
|
||||
} else {
|
||||
tabs[i].style.display = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to the first visible tab
|
||||
var visibleTabs = document.querySelectorAll(
|
||||
'.admin-tab:not([style*="display: none"])',
|
||||
);
|
||||
if (visibleTabs.length > 0) {
|
||||
switchAdminTab(visibleTabs[0].getAttribute("data-tab"));
|
||||
} else {
|
||||
// No tabs visible — show empty state instead of loading an inaccessible tab
|
||||
var panels = document.querySelectorAll(".admin-panel");
|
||||
for (var j = 0; j < panels.length; j++) panels[j].style.display = "none";
|
||||
var empty = document.getElementById("admin-no-permissions");
|
||||
if (!empty) {
|
||||
empty = document.createElement("div");
|
||||
empty.id = "admin-no-permissions";
|
||||
empty.className = "dashboard-empty";
|
||||
empty.textContent = "You do not have permissions to view any admin tabs.";
|
||||
document.getElementById("view-admin").appendChild(empty);
|
||||
}
|
||||
empty.style.display = "";
|
||||
}
|
||||
}
|
||||
|
||||
function switchAdminTab(tab) {
|
||||
_adminTab = tab;
|
||||
// Hide no-permissions empty state if it was showing
|
||||
var noPerms = document.getElementById("admin-no-permissions");
|
||||
if (noPerms) noPerms.style.display = "none";
|
||||
var tabs = document.querySelectorAll(".admin-tab");
|
||||
for (var i = 0; i < tabs.length; i++) {
|
||||
var isActive = tabs[i].getAttribute("data-tab") === tab;
|
||||
@@ -41,22 +92,36 @@ function switchAdminTab(tab) {
|
||||
tabs[i].setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
tabs[i].setAttribute("tabindex", isActive ? "0" : "-1");
|
||||
}
|
||||
document.getElementById("admin-users").style.display =
|
||||
tab === "users" ? "" : "none";
|
||||
document.getElementById("admin-tokens").style.display =
|
||||
tab === "tokens" ? "" : "none";
|
||||
document.getElementById("admin-channels").style.display =
|
||||
tab === "channels" ? "" : "none";
|
||||
document.getElementById("admin-schedules").style.display =
|
||||
tab === "schedules" ? "" : "none";
|
||||
document.getElementById("admin-watches").style.display =
|
||||
tab === "watches" ? "" : "none";
|
||||
var panels = [
|
||||
"users",
|
||||
"tokens",
|
||||
"channels",
|
||||
"schedules",
|
||||
"watches",
|
||||
"roles",
|
||||
"policies",
|
||||
"templates",
|
||||
"usage",
|
||||
"audit",
|
||||
];
|
||||
for (var p = 0; p < panels.length; p++) {
|
||||
var el = document.getElementById("admin-" + panels[p]);
|
||||
if (el) el.style.display = panels[p] === tab ? "" : "none";
|
||||
}
|
||||
|
||||
if (tab === "users") loadAdminUsers();
|
||||
if (tab === "tokens") _populateTokenUserSelect();
|
||||
if (tab === "channels") _populateChannelUserSelect();
|
||||
if (tab === "schedules") loadAdminSchedules();
|
||||
if (tab === "watches") loadAdminWatches();
|
||||
if (tab === "roles") loadGovRoles();
|
||||
if (tab === "policies") loadGovPolicies();
|
||||
if (tab === "templates") loadGovTemplates();
|
||||
if (tab === "usage") loadGovUsage();
|
||||
if (tab === "audit") {
|
||||
_populateAuditUserFilter();
|
||||
loadGovAudit();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -102,6 +167,9 @@ function _renderUsers(users) {
|
||||
escapeHtml(u.created || "").slice(0, 10) +
|
||||
"</span>" +
|
||||
'<span class="admin-col admin-col-actions">' +
|
||||
'<button class="admin-btn-action" data-user-roles="' +
|
||||
escapeHtml(u.user_id) +
|
||||
'" title="Manage roles">roles</button>' +
|
||||
'<button class="admin-btn-danger" data-delete-user="' +
|
||||
escapeHtml(u.user_id) +
|
||||
'" data-username="' +
|
||||
@@ -111,6 +179,13 @@ function _renderUsers(users) {
|
||||
"</div>";
|
||||
}
|
||||
container.innerHTML = html;
|
||||
// Bind roles buttons
|
||||
var roleBtns = container.querySelectorAll("[data-user-roles]");
|
||||
for (var rj = 0; rj < roleBtns.length; rj++) {
|
||||
roleBtns[rj].addEventListener("click", function () {
|
||||
showUserRolesModal(this.getAttribute("data-user-roles"));
|
||||
});
|
||||
}
|
||||
// Bind delete buttons via delegation (avoids inline JS injection)
|
||||
var btns = container.querySelectorAll("[data-delete-user]");
|
||||
for (var j = 0; j < btns.length; j++) {
|
||||
@@ -586,6 +661,7 @@ function showCreateScheduleModal() {
|
||||
document.getElementById("cs-target").value = "auto";
|
||||
document.getElementById("cs-node").value = "";
|
||||
document.getElementById("cs-model").value = "";
|
||||
document.getElementById("cs-template").value = "";
|
||||
document.getElementById("cs-message").value = "";
|
||||
document.getElementById("cs-autoapprove").checked = false;
|
||||
toggleScheduleTypeFields();
|
||||
@@ -618,6 +694,7 @@ function submitCreateSchedule() {
|
||||
var nodeId = (document.getElementById("cs-node").value || "").trim();
|
||||
var model = (document.getElementById("cs-model").value || "").trim();
|
||||
var message = (document.getElementById("cs-message").value || "").trim();
|
||||
var template = (document.getElementById("cs-template").value || "").trim();
|
||||
var autoApprove = document.getElementById("cs-autoapprove").checked;
|
||||
var errEl = document.getElementById("create-schedule-error");
|
||||
|
||||
@@ -654,6 +731,7 @@ function submitCreateSchedule() {
|
||||
model: model,
|
||||
initial_message: message,
|
||||
auto_approve: autoApprove,
|
||||
template: template,
|
||||
}),
|
||||
})
|
||||
.then(function (r) {
|
||||
@@ -720,6 +798,7 @@ function showEditScheduleModal(taskId) {
|
||||
? s.target_mode
|
||||
: "";
|
||||
document.getElementById("es-model").value = s.model || "";
|
||||
document.getElementById("es-template").value = s.template || "";
|
||||
document.getElementById("es-message").value = s.initial_message || "";
|
||||
document.getElementById("es-autoapprove").checked = !!s.auto_approve;
|
||||
document.getElementById("es-enabled").checked = !!s.enabled;
|
||||
@@ -792,6 +871,7 @@ function submitEditSchedule() {
|
||||
at_time: atTime,
|
||||
target_mode: targetMode,
|
||||
model: (document.getElementById("es-model").value || "").trim(),
|
||||
template: (document.getElementById("es-template").value || "").trim(),
|
||||
initial_message: (
|
||||
document.getElementById("es-message").value || ""
|
||||
).trim(),
|
||||
@@ -1362,6 +1442,14 @@ function _installTrap(overlayId, boxId, trapRef) {
|
||||
else if (overlayId === "edit-schedule-overlay") hideEditScheduleModal();
|
||||
else if (overlayId === "schedule-runs-overlay") hideScheduleRunsModal();
|
||||
else if (overlayId === "confirm-overlay") hideConfirmModal();
|
||||
else if (overlayId === "create-role-overlay") hideCreateRoleModal();
|
||||
else if (overlayId === "edit-role-overlay") hideEditRoleModal();
|
||||
else if (overlayId === "user-roles-overlay") hideUserRolesModal();
|
||||
else if (overlayId === "create-policy-overlay") hideCreatePolicyModal();
|
||||
else if (overlayId === "edit-policy-overlay") hideEditPolicyModal();
|
||||
else if (overlayId === "create-template-overlay")
|
||||
hideCreateTemplateModal();
|
||||
else if (overlayId === "edit-template-overlay") hideEditTemplateModal();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1428,6 +1516,24 @@ document.addEventListener("keydown", function (e) {
|
||||
hideConfirmModal();
|
||||
return;
|
||||
}
|
||||
// Governance modals
|
||||
var govOverlays = [
|
||||
["create-role-overlay", hideCreateRoleModal],
|
||||
["edit-role-overlay", hideEditRoleModal],
|
||||
["user-roles-overlay", hideUserRolesModal],
|
||||
["create-policy-overlay", hideCreatePolicyModal],
|
||||
["edit-policy-overlay", hideEditPolicyModal],
|
||||
["create-template-overlay", hideCreateTemplateModal],
|
||||
["edit-template-overlay", hideEditTemplateModal],
|
||||
];
|
||||
for (var gi = 0; gi < govOverlays.length; gi++) {
|
||||
var govEl = document.getElementById(govOverlays[gi][0]);
|
||||
if (govEl && govEl.style.display !== "none") {
|
||||
e.preventDefault();
|
||||
govOverlays[gi][1]();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Tab arrow key navigation
|
||||
@@ -1436,7 +1542,14 @@ document.addEventListener("keydown", function (e) {
|
||||
if (!tablist) return;
|
||||
tablist.addEventListener("keydown", function (e) {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
var tabOrder = ["users", "tokens", "channels", "schedules", "watches"];
|
||||
var allTabs = document.querySelectorAll(
|
||||
'.admin-tab:not([style*="display: none"])',
|
||||
);
|
||||
var tabOrder = [];
|
||||
for (var ti = 0; ti < allTabs.length; ti++) {
|
||||
tabOrder.push(allTabs[ti].getAttribute("data-tab"));
|
||||
}
|
||||
if (tabOrder.length === 0) return;
|
||||
var idx = tabOrder.indexOf(_adminTab);
|
||||
if (e.key === "ArrowRight") idx = (idx + 1) % tabOrder.length;
|
||||
else idx = (idx - 1 + tabOrder.length) % tabOrder.length;
|
||||
|
||||
@@ -140,6 +140,9 @@ function recomputeOverview() {
|
||||
var totalTokens = 0,
|
||||
totalToolCalls = 0,
|
||||
totalWs = 0;
|
||||
var mcpServers = 0,
|
||||
mcpResources = 0,
|
||||
mcpPrompts = 0;
|
||||
var versions = {};
|
||||
Object.keys(clusterState.nodes).forEach(function (nid) {
|
||||
var node = clusterState.nodes[nid];
|
||||
@@ -154,6 +157,10 @@ function recomputeOverview() {
|
||||
totalTokens += aggTokens || nodeWsTokens;
|
||||
totalToolCalls += (node.aggregate || {}).total_tool_calls || 0;
|
||||
if (node.version) versions[node.version] = true;
|
||||
var mcp = (node.health || {}).mcp || {};
|
||||
mcpServers += mcp.servers || 0;
|
||||
mcpResources += mcp.resources || 0;
|
||||
mcpPrompts += mcp.prompts || 0;
|
||||
});
|
||||
var versionList = Object.keys(versions).sort();
|
||||
clusterState.overview = {
|
||||
@@ -167,6 +174,11 @@ function recomputeOverview() {
|
||||
version_drift: versionList.length > 1,
|
||||
versions: versionList,
|
||||
};
|
||||
if (mcpServers > 0) {
|
||||
clusterState.overview.mcp_servers = mcpServers;
|
||||
clusterState.overview.mcp_resources = mcpResources;
|
||||
clusterState.overview.mcp_prompts = mcpPrompts;
|
||||
}
|
||||
}
|
||||
|
||||
function buildNodeInfoFromSnapshot(node) {
|
||||
@@ -227,6 +239,23 @@ function renderFromState() {
|
||||
}).length;
|
||||
document.getElementById("node-ws-summary").textContent =
|
||||
active + " active \u00b7 " + wsList.length + " total";
|
||||
var mcpSumEl = document.getElementById("node-mcp-summary");
|
||||
if (mcpSumEl) {
|
||||
var mcpInfo = snapNode.health && snapNode.health.mcp;
|
||||
if (mcpInfo && mcpInfo.servers > 0) {
|
||||
mcpSumEl.textContent =
|
||||
mcpInfo.servers +
|
||||
" MCP server" +
|
||||
(mcpInfo.servers !== 1 ? "s" : "") +
|
||||
" \u00b7 " +
|
||||
mcpInfo.resources +
|
||||
" resources \u00b7 " +
|
||||
mcpInfo.prompts +
|
||||
" prompts";
|
||||
} else {
|
||||
mcpSumEl.textContent = "";
|
||||
}
|
||||
}
|
||||
renderWsTable(document.getElementById("node-ws-table"), wsList);
|
||||
}
|
||||
} else if (currentView === "filtered") {
|
||||
@@ -470,6 +499,43 @@ function renderStatusBar(overview) {
|
||||
verEl.appendChild(verLbl);
|
||||
metricsContainer.appendChild(verEl);
|
||||
}
|
||||
// MCP aggregate metrics
|
||||
if (overview.mcp_servers && overview.mcp_servers > 0) {
|
||||
var mcpDivider = document.createElement("span");
|
||||
mcpDivider.className = "csb-divider";
|
||||
mcpDivider.setAttribute("aria-hidden", "true");
|
||||
metricsContainer.appendChild(mcpDivider);
|
||||
var mcpTitles = {
|
||||
mcp: "MCP servers",
|
||||
rsrc: "MCP resources",
|
||||
pmpt: "MCP prompts",
|
||||
};
|
||||
var mcpMetrics = [
|
||||
{ value: overview.mcp_servers, label: "mcp" },
|
||||
{ value: overview.mcp_resources, label: "rsrc" },
|
||||
{ value: overview.mcp_prompts, label: "pmpt" },
|
||||
];
|
||||
mcpMetrics.forEach(function (m) {
|
||||
var el = document.createElement("span");
|
||||
el.className = "csb-metric";
|
||||
el.title = mcpTitles[m.label] || "";
|
||||
if (m.label === "mcp") {
|
||||
var dot = document.createElement("span");
|
||||
dot.className = "csb-mcp-dot";
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
el.appendChild(dot);
|
||||
}
|
||||
var valSpan = document.createElement("span");
|
||||
valSpan.className = "csb-metric-value";
|
||||
valSpan.textContent = formatCount(m.value);
|
||||
var labelSpan = document.createElement("span");
|
||||
labelSpan.className = "csb-metric-label";
|
||||
labelSpan.textContent = m.label;
|
||||
el.appendChild(valSpan);
|
||||
el.appendChild(labelSpan);
|
||||
metricsContainer.appendChild(el);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Node Grouping ---
|
||||
@@ -1174,6 +1240,27 @@ function showNewWsModal() {
|
||||
.catch(function () {
|
||||
/* ignore — auto is always available */
|
||||
});
|
||||
// Populate template dropdown
|
||||
var tplSelect = document.getElementById("new-ws-template");
|
||||
tplSelect.innerHTML = '<option value="">Use defaults</option>';
|
||||
authFetch("/v1/api/admin/templates")
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
(data.templates || []).forEach(function (t) {
|
||||
var opt = document.createElement("option");
|
||||
opt.value = t.name;
|
||||
var label = t.name;
|
||||
if (t.is_default) label += " (default)";
|
||||
if (t.origin === "mcp") label += " [MCP]";
|
||||
opt.textContent = label;
|
||||
tplSelect.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
/* ignore — defaults still work */
|
||||
});
|
||||
document.getElementById("new-ws-name").value = "";
|
||||
document.getElementById("new-ws-model").value = "";
|
||||
document.getElementById("new-ws-task").value = "";
|
||||
@@ -1190,7 +1277,7 @@ function showNewWsModal() {
|
||||
_newWsTrapHandler = function (e) {
|
||||
if (e.key === "Tab") {
|
||||
var box = document.getElementById("new-ws-box");
|
||||
var focusable = box.querySelectorAll("select, input, button");
|
||||
var focusable = box.querySelectorAll("select, input, textarea, button");
|
||||
var first = focusable[0];
|
||||
var last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey) {
|
||||
@@ -1228,6 +1315,7 @@ function submitNewWs() {
|
||||
var nodeId = document.getElementById("new-ws-node").value;
|
||||
var name = document.getElementById("new-ws-name").value.trim();
|
||||
var model = document.getElementById("new-ws-model").value.trim();
|
||||
var template = document.getElementById("new-ws-template").value;
|
||||
var task = document.getElementById("new-ws-task").value.trim();
|
||||
var errEl = document.getElementById("new-ws-error");
|
||||
var btn = document.getElementById("new-ws-submit");
|
||||
@@ -1241,6 +1329,7 @@ function submitNewWs() {
|
||||
if (name) body.name = name;
|
||||
if (model) body.model = model;
|
||||
if (task) body.initial_message = task;
|
||||
if (template) body.template = template;
|
||||
|
||||
authFetch("/v1/api/cluster/workstreams/new", {
|
||||
method: "POST",
|
||||
@@ -1281,7 +1370,11 @@ document.addEventListener("keydown", function (e) {
|
||||
e.preventDefault();
|
||||
hideNewWsModal();
|
||||
}
|
||||
if (e.key === "Enter" && e.target.tagName !== "SELECT") {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
e.target.tagName !== "SELECT" &&
|
||||
e.target.tagName !== "TEXTAREA"
|
||||
) {
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById("new-ws-submit");
|
||||
if (btn && !btn.disabled) submitNewWs();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@
|
||||
<div class="dash-header">
|
||||
<span class="dash-header-title">WORKSTREAMS</span>
|
||||
<span class="dash-header-summary" id="node-ws-summary"></span>
|
||||
<span id="node-mcp-summary" aria-label="MCP status"></span>
|
||||
</div>
|
||||
<div class="dash-colheaders" aria-hidden="true">
|
||||
<span class="dash-col dash-col-state">STATE</span>
|
||||
@@ -82,6 +83,11 @@
|
||||
<button id="tab-channels" class="admin-tab" data-tab="channels" role="tab" aria-selected="false" aria-controls="admin-channels" tabindex="-1" onclick="switchAdminTab('channels')">Channels</button>
|
||||
<button id="tab-schedules" class="admin-tab" data-tab="schedules" role="tab" aria-selected="false" aria-controls="admin-schedules" tabindex="-1" onclick="switchAdminTab('schedules')">Schedules</button>
|
||||
<button id="tab-watches" class="admin-tab" data-tab="watches" role="tab" aria-selected="false" aria-controls="admin-watches" tabindex="-1" onclick="switchAdminTab('watches')">Watches</button>
|
||||
<button id="tab-roles" class="admin-tab" data-tab="roles" role="tab" aria-selected="false" aria-controls="admin-roles" tabindex="-1" onclick="switchAdminTab('roles')">Roles</button>
|
||||
<button id="tab-policies" class="admin-tab" data-tab="policies" role="tab" aria-selected="false" aria-controls="admin-policies" tabindex="-1" onclick="switchAdminTab('policies')">Policies</button>
|
||||
<button id="tab-templates" class="admin-tab" data-tab="templates" role="tab" aria-selected="false" aria-controls="admin-templates" tabindex="-1" onclick="switchAdminTab('templates')">Templates</button>
|
||||
<button id="tab-usage" class="admin-tab" data-tab="usage" role="tab" aria-selected="false" aria-controls="admin-usage" tabindex="-1" onclick="switchAdminTab('usage')">Usage</button>
|
||||
<button id="tab-audit" class="admin-tab" data-tab="audit" role="tab" aria-selected="false" aria-controls="admin-audit" tabindex="-1" onclick="switchAdminTab('audit')">Audit</button>
|
||||
</div>
|
||||
|
||||
<!-- Users Tab -->
|
||||
@@ -188,6 +194,118 @@
|
||||
<div class="dashboard-empty">Loading watches...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles Tab -->
|
||||
<div id="admin-roles" class="admin-panel" role="tabpanel" aria-labelledby="tab-roles" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header" style="margin:0">ROLES</span>
|
||||
<button class="admin-action-btn" onclick="showCreateRoleModal()">+ Create role</button>
|
||||
</div>
|
||||
<div class="admin-colheaders" aria-hidden="true">
|
||||
<span class="admin-col admin-col-rname">NAME</span>
|
||||
<span class="admin-col admin-col-rperms">PERMISSIONS</span>
|
||||
<span class="admin-col admin-col-actions">ACTIONS</span>
|
||||
</div>
|
||||
<div id="admin-roles-table" role="list" aria-label="Roles" aria-live="polite">
|
||||
<div class="dashboard-empty">Loading roles...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Policies Tab -->
|
||||
<div id="admin-policies" class="admin-panel" role="tabpanel" aria-labelledby="tab-policies" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header" style="margin:0">TOOL POLICIES</span>
|
||||
<button class="admin-action-btn" onclick="showCreatePolicyModal()">+ Create policy</button>
|
||||
</div>
|
||||
<div class="admin-colheaders" aria-hidden="true">
|
||||
<span class="admin-col admin-col-pname">NAME</span>
|
||||
<span class="admin-col admin-col-ppattern">PATTERN</span>
|
||||
<span class="admin-col admin-col-paction">ACTION</span>
|
||||
<span class="admin-col admin-col-ppriority">PRI</span>
|
||||
<span class="admin-col admin-col-pstatus">STATUS</span>
|
||||
<span class="admin-col admin-col-actions">ACTIONS</span>
|
||||
</div>
|
||||
<div id="admin-policies-table" role="list" aria-label="Tool policies" aria-live="polite">
|
||||
<div class="dashboard-empty">Loading policies...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Templates Tab -->
|
||||
<div id="admin-templates" class="admin-panel" role="tabpanel" aria-labelledby="tab-templates" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header" style="margin:0">PROMPT TEMPLATES</span>
|
||||
<button class="admin-action-btn" onclick="showCreateTemplateModal()">+ Create template</button>
|
||||
</div>
|
||||
<div class="admin-colheaders" aria-hidden="true">
|
||||
<span class="admin-col admin-col-tmname">NAME</span>
|
||||
<span class="admin-col admin-col-tmcat">CATEGORY</span>
|
||||
<span class="admin-col admin-col-tmvars">VARIABLES</span>
|
||||
<span class="admin-col admin-col-actions">ACTIONS</span>
|
||||
</div>
|
||||
<div id="admin-templates-table" role="list" aria-label="Prompt templates" aria-live="polite">
|
||||
<div class="dashboard-empty">Loading templates...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Tab -->
|
||||
<div id="admin-usage" class="admin-panel" role="tabpanel" aria-labelledby="tab-usage" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header" style="margin:0">USAGE</span>
|
||||
<div class="usage-range-group" role="group" aria-label="Time range">
|
||||
<button class="usage-range-btn" data-range="24h" aria-pressed="false" onclick="setUsageRange('24h')">24h</button>
|
||||
<button class="usage-range-btn active" data-range="7d" aria-pressed="true" onclick="setUsageRange('7d')">7d</button>
|
||||
<button class="usage-range-btn" data-range="30d" aria-pressed="false" onclick="setUsageRange('30d')">30d</button>
|
||||
</div>
|
||||
<div class="usage-range-group" role="group" aria-label="Group by">
|
||||
<button class="usage-group-btn active" data-group="day" aria-pressed="true" onclick="setUsageGroupBy('day')">day</button>
|
||||
<button class="usage-group-btn" data-group="model" aria-pressed="false" onclick="setUsageGroupBy('model')">model</button>
|
||||
<button class="usage-group-btn" data-group="user" aria-pressed="false" onclick="setUsageGroupBy('user')">user</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="admin-usage-content">
|
||||
<div class="dashboard-empty">Loading usage data...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Tab -->
|
||||
<div id="admin-audit" class="admin-panel" role="tabpanel" aria-labelledby="tab-audit" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header" style="margin:0">AUDIT LOG</span>
|
||||
<label for="audit-action-filter" class="sr-only">Filter by action</label>
|
||||
<select id="audit-action-filter" onchange="loadGovAudit()">
|
||||
<option value="">All actions</option>
|
||||
<option value="user.create">user.create</option>
|
||||
<option value="user.delete">user.delete</option>
|
||||
<option value="token.create">token.create</option>
|
||||
<option value="token.revoke">token.revoke</option>
|
||||
<option value="role.create">role.create</option>
|
||||
<option value="role.update">role.update</option>
|
||||
<option value="role.delete">role.delete</option>
|
||||
<option value="role.assign">role.assign</option>
|
||||
<option value="role.unassign">role.unassign</option>
|
||||
<option value="policy.create">policy.create</option>
|
||||
<option value="policy.update">policy.update</option>
|
||||
<option value="policy.delete">policy.delete</option>
|
||||
<option value="template.create">template.create</option>
|
||||
<option value="template.update">template.update</option>
|
||||
<option value="template.delete">template.delete</option>
|
||||
</select>
|
||||
<label for="audit-user-filter" class="sr-only">Filter by user</label>
|
||||
<select id="audit-user-filter" onchange="loadGovAudit()">
|
||||
<option value="">All users</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-colheaders" aria-hidden="true">
|
||||
<span class="admin-col admin-col-atime">TIME</span>
|
||||
<span class="admin-col admin-col-auser">USER</span>
|
||||
<span class="admin-col admin-col-aaction">ACTION</span>
|
||||
<span class="admin-col admin-col-aresource">RESOURCE</span>
|
||||
<span class="admin-col admin-col-adetail">DETAIL</span>
|
||||
</div>
|
||||
<div id="admin-audit-table" role="list" aria-label="Audit events" aria-live="polite">
|
||||
<div class="dashboard-empty">Loading audit log...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,6 +349,10 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
<input id="new-ws-name" type="text" placeholder="Auto-generated if empty" autocomplete="off">
|
||||
<label for="new-ws-model">Model <span class="label-hint">optional</span></label>
|
||||
<input id="new-ws-model" type="text" placeholder="Default model" autocomplete="off">
|
||||
<label for="new-ws-template">Template <span class="label-hint">optional</span></label>
|
||||
<select id="new-ws-template">
|
||||
<option value="">Use defaults</option>
|
||||
</select>
|
||||
<label for="new-ws-task">Task <span class="label-hint">optional — sent as first message</span></label>
|
||||
<textarea id="new-ws-task" rows="3" placeholder="What should this workstream work on?"></textarea>
|
||||
<div id="new-ws-buttons">
|
||||
@@ -366,6 +488,8 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
</div>
|
||||
<label for="cs-model">Model <span class="label-hint">optional</span></label>
|
||||
<input id="cs-model" type="text" placeholder="Default model" autocomplete="off">
|
||||
<label for="cs-template">Template <span class="label-hint">optional</span></label>
|
||||
<input id="cs-template" type="text" placeholder="Prompt template name" autocomplete="off">
|
||||
<label for="cs-message">Initial message</label>
|
||||
<textarea id="cs-message" rows="3" placeholder="What should the workstream do?"></textarea>
|
||||
<label class="admin-checkbox"><input id="cs-autoapprove" type="checkbox"> Auto-approve tool calls</label>
|
||||
@@ -412,6 +536,8 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
</div>
|
||||
<label for="es-model">Model</label>
|
||||
<input id="es-model" type="text" autocomplete="off">
|
||||
<label for="es-template">Template <span class="label-hint">optional</span></label>
|
||||
<input id="es-template" type="text" autocomplete="off">
|
||||
<label for="es-message">Initial message</label>
|
||||
<textarea id="es-message" rows="3"></textarea>
|
||||
<label class="admin-checkbox"><input id="es-autoapprove" type="checkbox"> Auto-approve tool calls</label>
|
||||
@@ -434,7 +560,162 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Role Modal -->
|
||||
<div id="create-role-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-role-title">
|
||||
<div id="create-role-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="create-role-title">Create Role</h2>
|
||||
<div id="create-role-error" role="alert" aria-live="assertive"></div>
|
||||
<label for="cr-name">Name</label>
|
||||
<input id="cr-name" type="text" placeholder="e.g. security-reviewer" autocomplete="off" spellcheck="false">
|
||||
<label for="cr-displayname">Display name</label>
|
||||
<input id="cr-displayname" type="text" placeholder="Security Reviewer" autocomplete="off">
|
||||
<fieldset class="perm-fieldset"><legend>Permissions</legend>
|
||||
<div id="cr-perms-container" role="group" aria-label="Permissions"></div>
|
||||
</fieldset>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideCreateRoleModal()">Cancel</button>
|
||||
<button id="cr-submit" class="modal-submit" onclick="submitCreateRole()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role Modal -->
|
||||
<div id="edit-role-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-role-title">
|
||||
<div id="edit-role-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="edit-role-title">Edit Role</h2>
|
||||
<div id="edit-role-error" role="alert" aria-live="assertive"></div>
|
||||
<input id="er-id" type="hidden">
|
||||
<label for="er-name">Display name</label>
|
||||
<input id="er-name" type="text" autocomplete="off">
|
||||
<fieldset class="perm-fieldset"><legend>Permissions</legend>
|
||||
<div id="er-perms-container" role="group" aria-label="Permissions"></div>
|
||||
</fieldset>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideEditRoleModal()">Cancel</button>
|
||||
<button id="er-submit" class="modal-submit" onclick="submitEditRole()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Roles Modal -->
|
||||
<div id="user-roles-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="user-roles-title">
|
||||
<div id="user-roles-box" class="admin-modal">
|
||||
<h2 id="user-roles-title">Assign Roles</h2>
|
||||
<div id="user-roles-error" role="alert" aria-live="assertive"></div>
|
||||
<input id="ur-user-id" type="hidden">
|
||||
<div id="ur-roles-container"></div>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideUserRolesModal()">Cancel</button>
|
||||
<button class="modal-submit" onclick="submitUserRoles()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Policy Modal -->
|
||||
<div id="create-policy-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-policy-title">
|
||||
<div id="create-policy-box" class="admin-modal">
|
||||
<h2 id="create-policy-title">Create Tool Policy</h2>
|
||||
<div id="create-policy-error" role="alert" aria-live="assertive"></div>
|
||||
<label for="cp-name">Name</label>
|
||||
<input id="cp-name" type="text" placeholder="e.g. Block shell access" autocomplete="off">
|
||||
<label for="cp-pattern">Tool pattern <span class="label-hint">glob syntax: bash*, file_write, *</span></label>
|
||||
<input id="cp-pattern" type="text" placeholder="bash*" autocomplete="off" spellcheck="false">
|
||||
<label for="cp-action">Action</label>
|
||||
<select id="cp-action">
|
||||
<option value="ask">Ask (require approval)</option>
|
||||
<option value="allow">Allow (auto-approve)</option>
|
||||
<option value="deny">Deny (block)</option>
|
||||
</select>
|
||||
<label for="cp-priority">Priority <span class="label-hint">higher = evaluated first</span></label>
|
||||
<input id="cp-priority" type="number" value="0" min="0" max="9999">
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideCreatePolicyModal()">Cancel</button>
|
||||
<button id="cp-submit" class="modal-submit" onclick="submitCreatePolicy()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Policy Modal -->
|
||||
<div id="edit-policy-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-policy-title">
|
||||
<div id="edit-policy-box" class="admin-modal">
|
||||
<h2 id="edit-policy-title">Edit Tool Policy</h2>
|
||||
<div id="edit-policy-error" role="alert" aria-live="assertive"></div>
|
||||
<input id="ep-id" type="hidden">
|
||||
<label for="ep-name">Name</label>
|
||||
<input id="ep-name" type="text" autocomplete="off">
|
||||
<label for="ep-pattern">Tool pattern</label>
|
||||
<input id="ep-pattern" type="text" autocomplete="off" spellcheck="false">
|
||||
<label for="ep-action">Action</label>
|
||||
<select id="ep-action">
|
||||
<option value="ask">Ask (require approval)</option>
|
||||
<option value="allow">Allow (auto-approve)</option>
|
||||
<option value="deny">Deny (block)</option>
|
||||
</select>
|
||||
<label for="ep-priority">Priority</label>
|
||||
<input id="ep-priority" type="number" value="0" min="0" max="9999">
|
||||
<label class="admin-checkbox"><input id="ep-enabled" type="checkbox" checked> Enabled</label>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideEditPolicyModal()">Cancel</button>
|
||||
<button id="ep-submit" class="modal-submit" onclick="submitEditPolicy()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Template Modal -->
|
||||
<div id="create-template-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-template-title">
|
||||
<div id="create-template-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="create-template-title">Create Prompt Template</h2>
|
||||
<div id="create-template-error" role="alert" aria-live="assertive"></div>
|
||||
<label for="ctm-name">Name</label>
|
||||
<input id="ctm-name" type="text" placeholder="e.g. Code Review Agent" autocomplete="off">
|
||||
<label for="ctm-category">Category</label>
|
||||
<select id="ctm-category">
|
||||
<option value="general">General</option>
|
||||
<option value="engineering">Engineering</option>
|
||||
<option value="support">Support</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<label for="ctm-content">Content <span class="label-hint">system message text, use {{model}}, {{ws_id}}, {{node_id}} for placeholders</span></label>
|
||||
<textarea id="ctm-content" rows="6" placeholder="You are a code reviewer using {{model}}..."></textarea>
|
||||
<label>Variables <span class="label-hint">auto-detected from content — available: model, ws_id, node_id</span></label>
|
||||
<div id="ctm-variables" class="label-hint" style="padding:4px 0;min-height:1.2em"></div>
|
||||
<label class="admin-checkbox"><input id="ctm-default" type="checkbox"> Set as default for new workstreams</label>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideCreateTemplateModal()">Cancel</button>
|
||||
<button id="ctm-submit" class="modal-submit" onclick="submitCreateTemplate()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Template Modal -->
|
||||
<div id="edit-template-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-template-title">
|
||||
<div id="edit-template-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="edit-template-title">Edit Prompt Template</h2>
|
||||
<div id="edit-template-error" role="alert" aria-live="assertive"></div>
|
||||
<input id="etm-id" type="hidden">
|
||||
<label for="etm-name">Name</label>
|
||||
<input id="etm-name" type="text" autocomplete="off">
|
||||
<label for="etm-category">Category</label>
|
||||
<select id="etm-category">
|
||||
<option value="general">General</option>
|
||||
<option value="engineering">Engineering</option>
|
||||
<option value="support">Support</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<label for="etm-content">Content</label>
|
||||
<textarea id="etm-content" rows="6"></textarea>
|
||||
<label>Variables <span class="label-hint">auto-detected from content — available: model, ws_id, node_id</span></label>
|
||||
<div id="etm-variables" class="label-hint" style="padding:4px 0;min-height:1.2em"></div>
|
||||
<label class="admin-checkbox"><input id="etm-default" type="checkbox"> Set as default</label>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideEditTemplateModal()">Cancel</button>
|
||||
<button id="etm-submit" class="modal-submit" onclick="submitEditTemplate()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/admin.js"></script>
|
||||
<script src="/static/governance.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -166,6 +166,17 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* MCP indicator dot — LED effect with magenta glow */
|
||||
.csb-mcp-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--magenta);
|
||||
box-shadow: 0 0 4px var(--magenta-glow);
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.csb-loading { color: var(--fg-dim); font-size: 11px; font-style: italic; opacity: 0.8; }
|
||||
|
||||
#cluster-status-bar.stale { border-top-color: var(--yellow); }
|
||||
@@ -454,6 +465,16 @@
|
||||
}
|
||||
.dash-cell-node:hover { text-decoration: underline; color: var(--fg-bright); }
|
||||
|
||||
/* ==========================================================================
|
||||
MCP summary in node detail
|
||||
========================================================================== */
|
||||
#node-mcp-summary {
|
||||
color: var(--magenta);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Node link
|
||||
========================================================================== */
|
||||
@@ -665,6 +686,7 @@
|
||||
.node-group-header .node-group-cell:last-child { display: none; }
|
||||
.ncol-version, .node-cell-version { display: none; }
|
||||
.ncol-health, .node-cell-health { display: none; }
|
||||
#node-mcp-summary { display: none; }
|
||||
#main { padding: 16px; padding-bottom: 60px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
@@ -709,6 +731,11 @@
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.admin-tab:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.admin-toolbar {
|
||||
display: flex;
|
||||
@@ -989,7 +1016,10 @@
|
||||
.modal-submit:disabled { opacity: 0.4; cursor: not-allowed; filter: none; }
|
||||
|
||||
#create-user-overlay, #create-token-overlay, #token-created-overlay, #create-channel-overlay, #confirm-overlay,
|
||||
#create-schedule-overlay, #edit-schedule-overlay, #schedule-runs-overlay {
|
||||
#create-schedule-overlay, #edit-schedule-overlay, #schedule-runs-overlay,
|
||||
#create-role-overlay, #edit-role-overlay, #user-roles-overlay,
|
||||
#create-policy-overlay, #edit-policy-overlay,
|
||||
#create-template-overlay, #edit-template-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
@@ -1043,6 +1073,263 @@
|
||||
.admin-col-wcmd, .admin-col-wcond, .admin-col-winterval { display: none; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Admin tabs — horizontal scroll for 10+ tabs
|
||||
========================================================================== */
|
||||
.admin-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex-wrap: nowrap;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Governance: Roles grid
|
||||
========================================================================== */
|
||||
#admin-roles .admin-colheaders,
|
||||
#admin-roles .admin-row {
|
||||
grid-template-columns: 160px 1fr 110px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Governance: Tool Policies grid
|
||||
========================================================================== */
|
||||
#admin-policies .admin-colheaders,
|
||||
#admin-policies .admin-row {
|
||||
grid-template-columns: 1.2fr 1fr 70px 50px 80px 140px;
|
||||
}
|
||||
|
||||
/* Policy action badges */
|
||||
.policy-badge {
|
||||
display: inline-block;
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.policy-allow {
|
||||
color: var(--green);
|
||||
background: var(--green-glow);
|
||||
border: 1px solid var(--green-glow);
|
||||
}
|
||||
.policy-deny {
|
||||
color: var(--red);
|
||||
background: var(--red-glow);
|
||||
border: 1px solid var(--red-glow);
|
||||
}
|
||||
.policy-ask {
|
||||
color: var(--yellow);
|
||||
background: var(--yellow-glow);
|
||||
border: 1px solid var(--yellow-glow);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Governance: Prompt Templates grid
|
||||
========================================================================== */
|
||||
#admin-templates .admin-colheaders,
|
||||
#admin-templates .admin-row {
|
||||
grid-template-columns: 1.5fr 100px 1fr 140px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Governance: Audit grid
|
||||
========================================================================== */
|
||||
#admin-audit .admin-colheaders,
|
||||
#admin-audit .admin-row {
|
||||
grid-template-columns: 80px 80px 1fr 120px 1.5fr;
|
||||
}
|
||||
|
||||
/* Audit action badges */
|
||||
.audit-badge {
|
||||
display: inline-block;
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--fg-dim);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.audit-danger { color: var(--red); border-color: var(--red-glow); }
|
||||
.audit-success { color: var(--green); border-color: var(--green-glow); }
|
||||
|
||||
/* ==========================================================================
|
||||
Governance: Usage dashboard
|
||||
========================================================================== */
|
||||
.usage-summary {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 16px 0 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.usage-readout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.usage-readout-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-bright);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.usage-readout-label {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
/* Usage bar chart */
|
||||
.usage-chart { padding-top: 4px; }
|
||||
.usage-bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr 60px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.usage-bar-label {
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.usage-bar-track {
|
||||
height: 16px;
|
||||
background: var(--bg-highlight);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.usage-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
min-width: 2px;
|
||||
transition: width 0.3s ease;
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
}
|
||||
.usage-bar-value {
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Usage range/group buttons */
|
||||
.usage-range-group {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.usage-range-btn, .usage-group-btn {
|
||||
background: var(--bg);
|
||||
color: var(--fg-dim);
|
||||
border: none;
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.usage-range-btn:hover, .usage-group-btn:hover {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--fg);
|
||||
}
|
||||
.usage-range-btn.active, .usage-group-btn.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
.usage-range-btn:focus-visible, .usage-group-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Governance: Permission grid (modal checkboxes)
|
||||
========================================================================== */
|
||||
.perm-fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.perm-fieldset legend {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--fg-dim);
|
||||
padding: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.perm-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.perm-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--fg);
|
||||
padding: 3px 0;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
.perm-checkbox input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Governance: Responsive
|
||||
========================================================================== */
|
||||
@media (max-width: 700px) {
|
||||
#admin-roles .admin-colheaders, #admin-roles .admin-row {
|
||||
grid-template-columns: 1fr 100px;
|
||||
}
|
||||
.admin-col-rperms { display: none; }
|
||||
#admin-policies .admin-colheaders, #admin-policies .admin-row {
|
||||
grid-template-columns: 1fr 70px 50px 100px;
|
||||
}
|
||||
.admin-col-pstatus, .admin-col-ppriority { display: none; }
|
||||
#admin-templates .admin-colheaders, #admin-templates .admin-row {
|
||||
grid-template-columns: 1fr 100px;
|
||||
}
|
||||
.admin-col-tmcat, .admin-col-tmvars { display: none; }
|
||||
#admin-audit .admin-colheaders, #admin-audit .admin-row {
|
||||
grid-template-columns: 60px 1fr 100px;
|
||||
}
|
||||
.admin-col-auser, .admin-col-adetail { display: none; }
|
||||
.usage-readout-value { font-size: 18px; }
|
||||
.usage-bar-row { grid-template-columns: 70px 1fr 50px; }
|
||||
.perm-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Reduced motion — console-specific
|
||||
========================================================================== */
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Audit event recording helper.
|
||||
|
||||
Provides a fire-and-forget ``record_audit`` function that admin handlers
|
||||
call after mutations to create a persistent audit trail.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.core.storage._protocol import StorageBackend
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def record_audit(
|
||||
storage: StorageBackend,
|
||||
user_id: str,
|
||||
action: str,
|
||||
resource_type: str = "",
|
||||
resource_id: str = "",
|
||||
detail: dict[str, Any] | None = None,
|
||||
ip_address: str = "",
|
||||
) -> None:
|
||||
"""Record an audit event. Silently logs on failure (never raises)."""
|
||||
try:
|
||||
storage.record_audit_event(
|
||||
event_id=uuid.uuid4().hex,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
detail=json.dumps(detail) if detail else "{}",
|
||||
ip_address=ip_address,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to record audit event: %s %s", action, resource_id, exc_info=True)
|
||||
+110
-4
@@ -18,6 +18,7 @@ always accessible without authentication.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
@@ -33,7 +34,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -80,6 +81,60 @@ _ROLE_TO_SCOPES: dict[str, frozenset[str]] = {
|
||||
"full": frozenset({"read", "write", "approve"}),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RBAC helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_user_permissions(storage: Any, user_id: str) -> set[str]:
|
||||
"""Load the union of all permissions from a user's assigned roles."""
|
||||
try:
|
||||
result: set[str] = storage.get_user_permissions(user_id)
|
||||
return result
|
||||
except Exception:
|
||||
log.warning("Failed to load permissions for user %s", user_id)
|
||||
return set()
|
||||
|
||||
|
||||
def _permissions_to_scopes(permissions: set[str]) -> frozenset[str]:
|
||||
"""Derive legacy scopes from a granular permission set."""
|
||||
scopes: set[str] = set()
|
||||
if not permissions:
|
||||
scopes.add("read")
|
||||
return frozenset(scopes)
|
||||
for perm in permissions:
|
||||
if perm in VALID_SCOPES:
|
||||
scopes.update(SCOPE_HIERARCHY.get(perm, {perm}))
|
||||
# Any admin.* permission requires access to admin endpoints → approve scope
|
||||
if any(p.startswith("admin.") for p in permissions):
|
||||
scopes.update(SCOPE_HIERARCHY["approve"])
|
||||
if not scopes:
|
||||
scopes.add("read")
|
||||
return frozenset(scopes)
|
||||
|
||||
|
||||
def require_permission(request: Request, permission: str) -> JSONResponse | None:
|
||||
"""Return a 403 JSONResponse if the user lacks *permission*, else None.
|
||||
|
||||
Call from admin handlers after the middleware scope check passes.
|
||||
Config-file tokens (no user_id) are treated as full-access.
|
||||
"""
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
auth_result: AuthResult | None = getattr(getattr(request, "state", None), "auth_result", None)
|
||||
if auth_result is None:
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
# Config-file tokens (no user_id) are treated as full-access
|
||||
if not auth_result.user_id:
|
||||
return None
|
||||
if auth_result.has_permission(permission):
|
||||
return None
|
||||
return JSONResponse(
|
||||
{"error": f"Forbidden: missing '{permission}' permission"},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path classification
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -104,6 +159,7 @@ WRITE_PATHS: frozenset[str] = frozenset(
|
||||
"/api/send",
|
||||
"/api/plan",
|
||||
"/api/command",
|
||||
"/api/cancel",
|
||||
"/api/workstreams/new",
|
||||
"/api/workstreams/close",
|
||||
"/api/cluster/workstreams/new",
|
||||
@@ -133,11 +189,16 @@ class AuthResult:
|
||||
user_id: str # empty string for config-file tokens
|
||||
scopes: frozenset[str]
|
||||
token_source: str # "config", "jwt", "database"
|
||||
permissions: frozenset[str] = frozenset()
|
||||
|
||||
def has_scope(self, scope: str) -> bool:
|
||||
"""Return True if this result includes *scope*."""
|
||||
return scope in self.scopes
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
"""Return True if this result includes *permission*."""
|
||||
return permission in self.permissions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AuthConfig (unchanged from before — static config-file tokens)
|
||||
@@ -249,8 +310,9 @@ def create_jwt(
|
||||
secret: str,
|
||||
expiry_hours: int = 24,
|
||||
audience: str = "",
|
||||
permissions: frozenset[str] = frozenset(),
|
||||
) -> str:
|
||||
"""Create a signed JWT with user identity and scopes."""
|
||||
"""Create a signed JWT with user identity, scopes, and permissions."""
|
||||
import jwt
|
||||
|
||||
now = int(time.time())
|
||||
@@ -264,6 +326,8 @@ def create_jwt(
|
||||
}
|
||||
if audience:
|
||||
payload["aud"] = audience
|
||||
if permissions:
|
||||
payload["permissions"] = ",".join(sorted(permissions))
|
||||
return jwt.encode(payload, secret, algorithm="HS256")
|
||||
|
||||
|
||||
@@ -293,11 +357,15 @@ def validate_jwt(token: str, secret: str, audience: str = "") -> AuthResult | No
|
||||
user_id = payload.get("sub", "")
|
||||
scopes_str = payload.get("scopes", "")
|
||||
source = payload.get("src", "jwt")
|
||||
perms_str = payload.get("permissions", "")
|
||||
|
||||
perms = frozenset(p for p in perms_str.split(",") if p) if perms_str else frozenset()
|
||||
|
||||
return AuthResult(
|
||||
user_id=user_id,
|
||||
scopes=parse_scopes(scopes_str),
|
||||
token_source=source,
|
||||
permissions=perms,
|
||||
)
|
||||
|
||||
|
||||
@@ -531,10 +599,12 @@ def _authenticate_api_token(token: str, storage: Any) -> AuthResult | None:
|
||||
if exp_dt < now:
|
||||
return None
|
||||
|
||||
perms = _load_user_permissions(storage, row["user_id"]) if storage else set()
|
||||
return AuthResult(
|
||||
user_id=row["user_id"],
|
||||
scopes=parse_scopes(row["scopes"]),
|
||||
token_source="database",
|
||||
permissions=frozenset(perms),
|
||||
)
|
||||
|
||||
|
||||
@@ -835,10 +905,14 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
|
||||
if username and password and storage is not None:
|
||||
user = storage.get_user_by_username(username)
|
||||
if user and verify_password(password, user["password_hash"]):
|
||||
# Derive scopes and permissions from assigned roles
|
||||
perms = _load_user_permissions(storage, user["user_id"])
|
||||
scopes = _permissions_to_scopes(perms)
|
||||
result = AuthResult(
|
||||
user_id=user["user_id"],
|
||||
scopes=frozenset({"read", "write", "approve"}),
|
||||
scopes=scopes,
|
||||
token_source="password",
|
||||
permissions=frozenset(perms),
|
||||
)
|
||||
elif body.get("token"):
|
||||
result = _authenticate_token(
|
||||
@@ -865,11 +939,14 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
|
||||
source=result.token_source,
|
||||
secret=jwt_secret,
|
||||
audience=audience,
|
||||
permissions=result.permissions,
|
||||
)
|
||||
|
||||
role = "full" if result.has_scope("write") else "read"
|
||||
scopes_str = ",".join(sorted(result.scopes))
|
||||
resp_body: dict[str, str] = {"status": "ok", "role": role, "scopes": scopes_str}
|
||||
if result.permissions:
|
||||
resp_body["permissions"] = ",".join(sorted(result.permissions))
|
||||
if jwt_token:
|
||||
resp_body["jwt"] = jwt_token
|
||||
if result.user_id:
|
||||
@@ -959,7 +1036,33 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
|
||||
if not created:
|
||||
return JSONResponse({"error": "Setup already completed"}, status_code=409)
|
||||
|
||||
scopes = frozenset({"read", "write", "approve"})
|
||||
# Assign admin role to the first user — fail setup if this breaks,
|
||||
# otherwise the admin is created with read-only access and locked out.
|
||||
try:
|
||||
storage.assign_role(user_id, "builtin-admin", "")
|
||||
except Exception:
|
||||
log.error("Failed to assign admin role to first user %s — aborting setup", user_id)
|
||||
# Roll back the user creation so setup can be retried
|
||||
with contextlib.suppress(Exception):
|
||||
storage.delete_user(user_id)
|
||||
return JSONResponse(
|
||||
{"error": "Failed to assign admin role. Ensure migrations have run."},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
# Derive permissions from roles
|
||||
perms = _load_user_permissions(storage, user_id)
|
||||
if not perms:
|
||||
log.error(
|
||||
"First user %s has no permissions after role assignment — aborting setup", user_id
|
||||
)
|
||||
with contextlib.suppress(Exception):
|
||||
storage.delete_user(user_id)
|
||||
return JSONResponse(
|
||||
{"error": "Failed to load permissions. Ensure migrations have run."},
|
||||
status_code=503,
|
||||
)
|
||||
scopes = _permissions_to_scopes(perms)
|
||||
jwt_token = ""
|
||||
if jwt_secret:
|
||||
jwt_token = create_jwt(
|
||||
@@ -968,6 +1071,7 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
|
||||
source="password",
|
||||
secret=jwt_secret,
|
||||
audience=audience,
|
||||
permissions=frozenset(perms),
|
||||
)
|
||||
|
||||
resp_body: dict[str, str] = {
|
||||
@@ -977,6 +1081,8 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
|
||||
"role": "full",
|
||||
"scopes": ",".join(sorted(scopes)),
|
||||
}
|
||||
if perms:
|
||||
resp_body["permissions"] = ",".join(sorted(perms))
|
||||
if jwt_token:
|
||||
resp_body["jwt"] = jwt_token
|
||||
|
||||
|
||||
+611
-28
@@ -1,16 +1,16 @@
|
||||
"""MCP (Model Context Protocol) client manager.
|
||||
|
||||
Connects to external MCP tool servers and exposes their tools alongside
|
||||
turnstone's built-in tools.
|
||||
Connects to external MCP tool servers and exposes their tools, resources,
|
||||
and prompts alongside turnstone's built-in capabilities.
|
||||
|
||||
Architecture: the MCP SDK is fully async, but turnstone's ChatSession is
|
||||
synchronous. We bridge the two by running a dedicated asyncio event loop
|
||||
in a daemon thread. ``call_tool_sync`` dispatches coroutines onto that loop
|
||||
via ``asyncio.run_coroutine_threadsafe``.
|
||||
|
||||
Tool refresh: three mechanisms keep tool lists up-to-date without restart:
|
||||
1. Push notifications — servers declaring ``tools.listChanged`` trigger
|
||||
immediate refresh via ``ToolListChangedNotification``.
|
||||
Refresh: three mechanisms keep tool/resource/prompt lists up-to-date:
|
||||
1. Push notifications — servers declaring ``listChanged`` on the
|
||||
respective capability trigger immediate refresh.
|
||||
2. Periodic timer — servers *without* push support are polled on a
|
||||
staggered interval (configurable, default 4 h, seeded at launch).
|
||||
3. Manual — ``/mcp refresh [server]`` triggers ``refresh_sync()``.
|
||||
@@ -19,6 +19,7 @@ Tool refresh: three mechanisms keep tool lists up-to-date without restart:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
@@ -26,6 +27,7 @@ import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import AsyncExitStack
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -112,6 +114,31 @@ class MCPClientManager:
|
||||
self._listeners: list[Callable[[], None]] = []
|
||||
self._listeners_lock = threading.Lock()
|
||||
|
||||
# Resources — parallel to tools
|
||||
self._per_server_resources: dict[str, list[dict[str, Any]]] = {}
|
||||
self._resources: list[dict[str, Any]] = []
|
||||
self._resource_map: dict[str, tuple[str, str]] = {} # uri → (server, uri)
|
||||
self._supports_resources: dict[str, bool] = {} # server has resources capability
|
||||
self._supports_resource_list_changed: dict[str, bool] = {}
|
||||
self._resource_listeners: list[Callable[[], None]] = []
|
||||
self._resource_listeners_lock = threading.Lock()
|
||||
|
||||
# Prompts — parallel to tools
|
||||
self._per_server_prompts: dict[str, list[dict[str, Any]]] = {}
|
||||
self._prompts: list[dict[str, Any]] = []
|
||||
self._prompt_map: dict[str, tuple[str, str]] = {} # prefixed → (server, original)
|
||||
self._supports_prompts: dict[str, bool] = {} # server has prompts capability
|
||||
self._supports_prompt_list_changed: dict[str, bool] = {}
|
||||
self._prompt_listeners: list[Callable[[], None]] = []
|
||||
self._prompt_listeners_lock = threading.Lock()
|
||||
|
||||
# Template prefix → (server_name, full_template_uri) for URI expansion
|
||||
self._template_prefixes: dict[str, tuple[str, str]] = {}
|
||||
|
||||
# Governance storage (optional — set via set_storage())
|
||||
self._storage: Any = None
|
||||
self._sync_lock = threading.Lock()
|
||||
|
||||
# Periodic refresh for servers without push notifications
|
||||
self._refresh_interval = refresh_interval
|
||||
self._refresh_task: asyncio.Task[None] | None = None
|
||||
@@ -146,7 +173,16 @@ class MCPClientManager:
|
||||
|
||||
# Start periodic refresh for servers without push notifications
|
||||
needs_periodic = any(
|
||||
not self._supports_list_changed.get(name, False) for name in self._sessions
|
||||
not self._supports_list_changed.get(name, False)
|
||||
or (
|
||||
self._supports_resources.get(name, False)
|
||||
and not self._supports_resource_list_changed.get(name, False)
|
||||
)
|
||||
or (
|
||||
self._supports_prompts.get(name, False)
|
||||
and not self._supports_prompt_list_changed.get(name, False)
|
||||
)
|
||||
for name in self._sessions
|
||||
)
|
||||
if needs_periodic and self._refresh_interval > 0:
|
||||
self._refresh_task = asyncio.get_running_loop().create_task(self._periodic_refresh())
|
||||
@@ -178,20 +214,26 @@ class MCPClientManager:
|
||||
)
|
||||
read, write = await self._exit_stack.enter_async_context(stdio_client(params))
|
||||
|
||||
# Register notification handler — lightweight; only acts on
|
||||
# ToolListChangedNotification, which is a no-op if the server
|
||||
# never sends it.
|
||||
# Register notification handler — dispatches tool, resource, and
|
||||
# prompt list-change notifications to the appropriate refresh method.
|
||||
async def _on_notification(
|
||||
msg: Any, # RequestResponder | ServerNotification | Exception
|
||||
) -> None:
|
||||
if isinstance(msg, mcp_types.ServerNotification) and isinstance(
|
||||
msg.root, mcp_types.ToolListChangedNotification
|
||||
):
|
||||
log.info("Received tools/list_changed from '%s'", name)
|
||||
try:
|
||||
await self._refresh_server(name)
|
||||
except Exception:
|
||||
log.warning("Refresh after notification failed for '%s'", name, exc_info=True)
|
||||
if not isinstance(msg, mcp_types.ServerNotification):
|
||||
return
|
||||
root = msg.root
|
||||
try:
|
||||
if isinstance(root, mcp_types.ToolListChangedNotification):
|
||||
log.info("Received tools/list_changed from '%s'", name)
|
||||
await self._refresh_server_tools(name)
|
||||
elif isinstance(root, mcp_types.ResourceListChangedNotification):
|
||||
log.info("Received resources/list_changed from '%s'", name)
|
||||
await self._refresh_server_resources(name)
|
||||
elif isinstance(root, mcp_types.PromptListChangedNotification):
|
||||
log.info("Received prompts/list_changed from '%s'", name)
|
||||
await self._refresh_server_prompts(name)
|
||||
except Exception:
|
||||
log.warning("Refresh after notification failed for '%s'", name, exc_info=True)
|
||||
|
||||
session = await self._exit_stack.enter_async_context(
|
||||
ClientSession(read, write, message_handler=_on_notification) # type: ignore[arg-type]
|
||||
@@ -199,11 +241,22 @@ class MCPClientManager:
|
||||
await session.initialize()
|
||||
self._sessions[name] = session
|
||||
|
||||
# Check push notification support
|
||||
# Check push notification support for each capability
|
||||
caps = session.get_server_capabilities()
|
||||
|
||||
tools_cap = getattr(caps, "tools", None) if caps else None
|
||||
self._supports_list_changed[name] = bool(getattr(tools_cap, "listChanged", False))
|
||||
|
||||
resources_cap = getattr(caps, "resources", None) if caps else None
|
||||
self._supports_resources[name] = resources_cap is not None
|
||||
self._supports_resource_list_changed[name] = bool(
|
||||
getattr(resources_cap, "listChanged", False)
|
||||
)
|
||||
|
||||
prompts_cap = getattr(caps, "prompts", None) if caps else None
|
||||
self._supports_prompts[name] = prompts_cap is not None
|
||||
self._supports_prompt_list_changed[name] = bool(getattr(prompts_cap, "listChanged", False))
|
||||
|
||||
# Discover tools
|
||||
result = await session.list_tools()
|
||||
server_tools: list[dict[str, Any]] = []
|
||||
@@ -213,14 +266,88 @@ class MCPClientManager:
|
||||
self._per_server_tools[name] = server_tools
|
||||
self._rebuild_tools()
|
||||
|
||||
push_status = " (push)" if self._supports_list_changed[name] else ""
|
||||
# Discover resources
|
||||
resource_count = 0
|
||||
if resources_cap is not None:
|
||||
server_resources: list[dict[str, Any]] = []
|
||||
res_result = await session.list_resources()
|
||||
for r in res_result.resources:
|
||||
server_resources.append(
|
||||
{
|
||||
"uri": str(r.uri),
|
||||
"name": r.name or "",
|
||||
"description": r.description or "",
|
||||
"mimeType": r.mimeType or "",
|
||||
"server": name,
|
||||
}
|
||||
)
|
||||
# Also include resource templates (catalog-only — not directly
|
||||
# readable via read_resource since they contain URI placeholders)
|
||||
tmpl_result = await session.list_resource_templates()
|
||||
for t in tmpl_result.resourceTemplates:
|
||||
server_resources.append(
|
||||
{
|
||||
"uri": str(t.uriTemplate),
|
||||
"name": t.name or "",
|
||||
"description": t.description or "",
|
||||
"mimeType": t.mimeType or "",
|
||||
"server": name,
|
||||
"template": True,
|
||||
}
|
||||
)
|
||||
resource_count = len(server_resources)
|
||||
self._per_server_resources[name] = server_resources
|
||||
self._rebuild_resources()
|
||||
|
||||
# Discover prompts
|
||||
prompt_count = 0
|
||||
if prompts_cap is not None:
|
||||
server_prompts: list[dict[str, Any]] = []
|
||||
prompt_result = await session.list_prompts()
|
||||
for p in prompt_result.prompts:
|
||||
server_prompts.append(
|
||||
{
|
||||
"name": f"mcp__{name}__{p.name}",
|
||||
"original_name": p.name,
|
||||
"server": name,
|
||||
"description": p.description or "",
|
||||
"arguments": [
|
||||
{
|
||||
"name": a.name,
|
||||
"description": a.description or "",
|
||||
"required": a.required or False,
|
||||
}
|
||||
for a in (p.arguments or [])
|
||||
],
|
||||
}
|
||||
)
|
||||
prompt_count = len(server_prompts)
|
||||
self._per_server_prompts[name] = server_prompts
|
||||
self._rebuild_prompts()
|
||||
|
||||
push_parts: list[str] = []
|
||||
if self._supports_list_changed[name]:
|
||||
push_parts.append("tools")
|
||||
if self._supports_resource_list_changed[name]:
|
||||
push_parts.append("resources")
|
||||
if self._supports_prompt_list_changed[name]:
|
||||
push_parts.append("prompts")
|
||||
push_status = f" (push: {','.join(push_parts)})" if push_parts else ""
|
||||
log.info(
|
||||
"Connected MCP server '%s' — %d tool(s)%s",
|
||||
"Connected MCP server '%s' — %d tool(s), %d resource(s), %d prompt(s)%s",
|
||||
name,
|
||||
len(result.tools),
|
||||
resource_count,
|
||||
prompt_count,
|
||||
push_status,
|
||||
)
|
||||
|
||||
# Sync discovered prompts into governance storage
|
||||
try:
|
||||
self.sync_prompts_to_storage()
|
||||
except Exception:
|
||||
log.warning("Prompt sync after connect failed for '%s'", name, exc_info=True)
|
||||
|
||||
# -- tool refresh --------------------------------------------------------
|
||||
|
||||
def _rebuild_tools(self) -> None:
|
||||
@@ -242,7 +369,7 @@ class MCPClientManager:
|
||||
self._tool_map = new_map
|
||||
self._notify_listeners()
|
||||
|
||||
async def _refresh_server(self, name: str) -> tuple[list[str], list[str]]:
|
||||
async def _refresh_server_tools(self, name: str) -> tuple[list[str], list[str]]:
|
||||
"""Re-fetch tools for one server. Returns ``(added, removed)`` names."""
|
||||
session = self._sessions.get(name)
|
||||
if session is None:
|
||||
@@ -268,10 +395,21 @@ class MCPClientManager:
|
||||
)
|
||||
return added, removed
|
||||
|
||||
async def _refresh_server(self, name: str) -> tuple[list[str], list[str]]:
|
||||
"""Re-fetch tools, resources, and prompts for one server.
|
||||
|
||||
Returns ``(added_tools, removed_tools)`` names (tool diff only,
|
||||
for backward compatibility with ``/mcp refresh`` output).
|
||||
"""
|
||||
added, removed = await self._refresh_server_tools(name)
|
||||
await self._refresh_server_resources(name)
|
||||
await self._refresh_server_prompts(name)
|
||||
return added, removed
|
||||
|
||||
async def _refresh_all(
|
||||
self, server_name: str | None = None
|
||||
) -> dict[str, tuple[list[str], list[str]]]:
|
||||
"""Refresh tools for one or all servers.
|
||||
"""Refresh tools, resources, and prompts for one or all servers.
|
||||
|
||||
For disconnected servers (in config but not connected), attempts
|
||||
reconnect. Returns ``{server: (added, removed)}`` per server.
|
||||
@@ -297,6 +435,13 @@ class MCPClientManager:
|
||||
except Exception:
|
||||
log.warning("Refresh failed for MCP server '%s'", name, exc_info=True)
|
||||
results[name] = ([], [])
|
||||
|
||||
# Final sync to clean up templates from servers that are no longer connected
|
||||
try:
|
||||
self.sync_prompts_to_storage()
|
||||
except Exception:
|
||||
log.warning("Prompt sync after refresh_all failed", exc_info=True)
|
||||
|
||||
return results
|
||||
|
||||
def refresh_sync(
|
||||
@@ -319,16 +464,169 @@ class MCPClientManager:
|
||||
await asyncio.sleep(initial_delay)
|
||||
while True:
|
||||
for name in list(self._server_configs):
|
||||
if self._supports_list_changed.get(name, False):
|
||||
continue # has push — skip
|
||||
if name not in self._sessions:
|
||||
continue # not connected — skip (reconnect on manual refresh)
|
||||
try:
|
||||
await self._refresh_server(name)
|
||||
if not self._supports_list_changed.get(name, False):
|
||||
await self._refresh_server_tools(name)
|
||||
if not self._supports_resource_list_changed.get(name, False):
|
||||
await self._refresh_server_resources(name)
|
||||
if not self._supports_prompt_list_changed.get(name, False):
|
||||
await self._refresh_server_prompts(name)
|
||||
except Exception:
|
||||
log.warning("Periodic refresh failed for '%s'", name, exc_info=True)
|
||||
await asyncio.sleep(self._refresh_interval)
|
||||
|
||||
# -- resource refresh ----------------------------------------------------
|
||||
|
||||
def _rebuild_resources(self) -> None:
|
||||
"""Rebuild merged ``_resources`` and ``_resource_map`` from per-server state.
|
||||
|
||||
Uses copy-on-write: builds new objects, then assigns atomically.
|
||||
"""
|
||||
new_resources: list[dict[str, Any]] = []
|
||||
new_map: dict[str, tuple[str, str]] = {}
|
||||
for srv_name, srv_resources in self._per_server_resources.items():
|
||||
for res in srv_resources:
|
||||
uri: str = res["uri"]
|
||||
new_resources.append(res)
|
||||
if res.get("template"):
|
||||
continue # templates are catalog-only, not directly readable
|
||||
if uri in new_map:
|
||||
log.warning(
|
||||
"Resource URI collision: '%s' from '%s' overrides '%s'",
|
||||
uri,
|
||||
srv_name,
|
||||
new_map[uri][0],
|
||||
)
|
||||
new_map[uri] = (srv_name, uri)
|
||||
# Build template prefix map for URI expansion fallback
|
||||
new_prefixes: dict[str, tuple[str, str]] = {}
|
||||
for srv_name, srv_resources in self._per_server_resources.items():
|
||||
for res in srv_resources:
|
||||
if res.get("template"):
|
||||
tmpl_uri = res["uri"]
|
||||
brace = tmpl_uri.find("{")
|
||||
prefix = tmpl_uri[:brace] if brace >= 0 else tmpl_uri
|
||||
if prefix:
|
||||
if prefix in new_prefixes:
|
||||
existing_srv, existing_tmpl = new_prefixes[prefix]
|
||||
if len(tmpl_uri) > len(existing_tmpl):
|
||||
log.warning(
|
||||
"Template prefix collision: '%s' from '%s' overrides '%s'"
|
||||
" (keeping more specific template)",
|
||||
prefix,
|
||||
srv_name,
|
||||
existing_srv,
|
||||
)
|
||||
new_prefixes[prefix] = (srv_name, tmpl_uri)
|
||||
else:
|
||||
log.warning(
|
||||
"Template prefix collision: '%s' from '%s' ignored in"
|
||||
" favor of '%s' (keeping more specific template)",
|
||||
prefix,
|
||||
srv_name,
|
||||
existing_srv,
|
||||
)
|
||||
else:
|
||||
new_prefixes[prefix] = (srv_name, tmpl_uri)
|
||||
|
||||
self._resources = new_resources
|
||||
self._resource_map = new_map
|
||||
self._template_prefixes = new_prefixes
|
||||
self._notify_resource_listeners()
|
||||
|
||||
async def _refresh_server_resources(self, name: str) -> None:
|
||||
"""Re-fetch resources for one server."""
|
||||
if not self._supports_resources.get(name, False):
|
||||
return
|
||||
session = self._sessions.get(name)
|
||||
if session is None:
|
||||
return
|
||||
|
||||
server_resources: list[dict[str, Any]] = []
|
||||
res_result = await session.list_resources()
|
||||
for r in res_result.resources:
|
||||
server_resources.append(
|
||||
{
|
||||
"uri": str(r.uri),
|
||||
"name": r.name or "",
|
||||
"description": r.description or "",
|
||||
"mimeType": r.mimeType or "",
|
||||
"server": name,
|
||||
}
|
||||
)
|
||||
tmpl_result = await session.list_resource_templates()
|
||||
for t in tmpl_result.resourceTemplates:
|
||||
server_resources.append(
|
||||
{
|
||||
"uri": str(t.uriTemplate),
|
||||
"name": t.name or "",
|
||||
"description": t.description or "",
|
||||
"mimeType": t.mimeType or "",
|
||||
"server": name,
|
||||
"template": True,
|
||||
}
|
||||
)
|
||||
|
||||
self._per_server_resources[name] = server_resources
|
||||
self._rebuild_resources()
|
||||
|
||||
# -- prompt refresh ------------------------------------------------------
|
||||
|
||||
def _rebuild_prompts(self) -> None:
|
||||
"""Rebuild merged ``_prompts`` and ``_prompt_map`` from per-server state.
|
||||
|
||||
Uses copy-on-write: builds new objects, then assigns atomically.
|
||||
"""
|
||||
new_prompts: list[dict[str, Any]] = []
|
||||
new_map: dict[str, tuple[str, str]] = {}
|
||||
for srv_name, srv_prompts in self._per_server_prompts.items():
|
||||
for prompt in srv_prompts:
|
||||
prefixed: str = prompt["name"]
|
||||
new_prompts.append(prompt)
|
||||
new_map[prefixed] = (srv_name, prompt["original_name"])
|
||||
self._prompts = new_prompts
|
||||
self._prompt_map = new_map
|
||||
self._notify_prompt_listeners()
|
||||
|
||||
async def _refresh_server_prompts(self, name: str) -> None:
|
||||
"""Re-fetch prompts for one server."""
|
||||
if not self._supports_prompts.get(name, False):
|
||||
return
|
||||
session = self._sessions.get(name)
|
||||
if session is None:
|
||||
return
|
||||
|
||||
server_prompts: list[dict[str, Any]] = []
|
||||
prompt_result = await session.list_prompts()
|
||||
for p in prompt_result.prompts:
|
||||
server_prompts.append(
|
||||
{
|
||||
"name": f"mcp__{name}__{p.name}",
|
||||
"original_name": p.name,
|
||||
"server": name,
|
||||
"description": p.description or "",
|
||||
"arguments": [
|
||||
{
|
||||
"name": a.name,
|
||||
"description": a.description or "",
|
||||
"required": a.required or False,
|
||||
}
|
||||
for a in (p.arguments or [])
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self._per_server_prompts[name] = server_prompts
|
||||
self._rebuild_prompts()
|
||||
|
||||
# Sync discovered prompts into governance storage
|
||||
try:
|
||||
self.sync_prompts_to_storage()
|
||||
except Exception:
|
||||
log.warning("Prompt sync after refresh failed for '%s'", name, exc_info=True)
|
||||
|
||||
# -- listener infrastructure ---------------------------------------------
|
||||
|
||||
def add_listener(self, callback: Callable[[], None]) -> None:
|
||||
@@ -342,7 +640,7 @@ class MCPClientManager:
|
||||
self._listeners.remove(callback)
|
||||
|
||||
def _notify_listeners(self) -> None:
|
||||
"""Invoke all registered listeners (runs on MCP background thread)."""
|
||||
"""Invoke all registered tool-change listeners."""
|
||||
with self._listeners_lock:
|
||||
listeners = list(self._listeners)
|
||||
for cb in listeners:
|
||||
@@ -351,6 +649,156 @@ class MCPClientManager:
|
||||
except Exception:
|
||||
log.warning("Tool-change listener raised", exc_info=True)
|
||||
|
||||
def add_resource_listener(self, callback: Callable[[], None]) -> None:
|
||||
"""Register a callback invoked when the resource list changes."""
|
||||
with self._resource_listeners_lock:
|
||||
self._resource_listeners.append(callback)
|
||||
|
||||
def remove_resource_listener(self, callback: Callable[[], None]) -> None:
|
||||
"""Unregister a resource-change callback."""
|
||||
with self._resource_listeners_lock, contextlib.suppress(ValueError):
|
||||
self._resource_listeners.remove(callback)
|
||||
|
||||
def _notify_resource_listeners(self) -> None:
|
||||
"""Invoke all registered resource-change listeners."""
|
||||
with self._resource_listeners_lock:
|
||||
listeners = list(self._resource_listeners)
|
||||
for cb in listeners:
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
log.warning("Resource-change listener raised", exc_info=True)
|
||||
|
||||
def add_prompt_listener(self, callback: Callable[[], None]) -> None:
|
||||
"""Register a callback invoked when the prompt list changes."""
|
||||
with self._prompt_listeners_lock:
|
||||
self._prompt_listeners.append(callback)
|
||||
|
||||
def remove_prompt_listener(self, callback: Callable[[], None]) -> None:
|
||||
"""Unregister a prompt-change callback."""
|
||||
with self._prompt_listeners_lock, contextlib.suppress(ValueError):
|
||||
self._prompt_listeners.remove(callback)
|
||||
|
||||
def _notify_prompt_listeners(self) -> None:
|
||||
"""Invoke all registered prompt-change listeners."""
|
||||
with self._prompt_listeners_lock:
|
||||
listeners = list(self._prompt_listeners)
|
||||
for cb in listeners:
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
log.warning("Prompt-change listener raised", exc_info=True)
|
||||
|
||||
# -- governance storage sync ---------------------------------------------
|
||||
|
||||
def set_storage(self, storage: Any) -> None:
|
||||
"""Inject governance storage backend for prompt template sync.
|
||||
|
||||
If MCP servers are already connected, triggers an immediate sync
|
||||
so prompts discovered during startup appear in governance storage
|
||||
(``start()`` completes before ``set_storage()`` is called).
|
||||
"""
|
||||
self._storage = storage
|
||||
if self._connected.is_set():
|
||||
try:
|
||||
self.sync_prompts_to_storage()
|
||||
except Exception:
|
||||
log.warning("Prompt sync after set_storage failed", exc_info=True)
|
||||
|
||||
def sync_prompts_to_storage(self) -> dict[str, Any]:
|
||||
"""Sync discovered MCP prompts into the prompt_templates governance table.
|
||||
|
||||
Returns ``{"added": [...], "removed": [...], "skipped": [...]}``.
|
||||
Thread-safe: serialized via ``_sync_lock`` to prevent races
|
||||
between ``set_storage()`` (main thread) and MCP background thread.
|
||||
"""
|
||||
if self._storage is None:
|
||||
return {"added": [], "removed": [], "skipped": []}
|
||||
|
||||
with self._sync_lock:
|
||||
return self._sync_prompts_locked()
|
||||
|
||||
def _sync_prompts_locked(self) -> dict[str, Any]:
|
||||
"""Inner sync logic — must be called under ``_sync_lock``."""
|
||||
storage = self._storage
|
||||
added: list[str] = []
|
||||
removed: list[str] = []
|
||||
skipped: list[str] = []
|
||||
|
||||
# Current MCP prompt names (the prefixed names used as template names)
|
||||
current_names: set[str] = set()
|
||||
|
||||
for prompt in list(self._prompts):
|
||||
name: str = prompt["name"][:256]
|
||||
server: str = prompt["server"][:128]
|
||||
current_names.add(name)
|
||||
|
||||
# Build content from description + argument schema
|
||||
desc = prompt.get("description", "")[:4096]
|
||||
args_list = prompt.get("arguments", [])
|
||||
content_parts = [desc] if desc else []
|
||||
if args_list:
|
||||
content_parts.append("\nArguments:")
|
||||
for arg in args_list:
|
||||
req = " (required)" if arg.get("required") else ""
|
||||
arg_desc = arg.get("description", "")[:512]
|
||||
content_parts.append(f" - {arg['name'][:128]}{req}: {arg_desc}")
|
||||
content = "\n".join(content_parts) if content_parts else name
|
||||
|
||||
# Variables = JSON list of argument names
|
||||
variables = json.dumps([a["name"] for a in args_list])
|
||||
|
||||
existing = storage.get_prompt_template_by_name(name)
|
||||
if existing is not None:
|
||||
if existing.get("origin") == "manual":
|
||||
log.info(
|
||||
"Skipping MCP prompt '%s' — manual template with same name exists", name
|
||||
)
|
||||
skipped.append(name)
|
||||
continue
|
||||
# Existing MCP template — update content/variables.
|
||||
# Reset is_default to prevent a compromised MCP server from
|
||||
# injecting content into a previously admin-promoted default.
|
||||
storage.update_prompt_template(
|
||||
existing["template_id"],
|
||||
content=content,
|
||||
variables=variables,
|
||||
is_default=False,
|
||||
)
|
||||
else:
|
||||
# Create new MCP-sourced template
|
||||
template_id = str(uuid.uuid4())
|
||||
storage.create_prompt_template(
|
||||
template_id=template_id,
|
||||
name=name,
|
||||
category="mcp",
|
||||
content=content,
|
||||
variables=variables,
|
||||
is_default=False,
|
||||
org_id="",
|
||||
created_by="",
|
||||
origin="mcp",
|
||||
mcp_server=server,
|
||||
readonly=True,
|
||||
)
|
||||
added.append(name)
|
||||
|
||||
# Remove MCP templates whose prompts no longer exist
|
||||
existing_mcp = storage.list_prompt_templates_by_origin("mcp")
|
||||
for tpl in existing_mcp:
|
||||
if tpl["name"] not in current_names:
|
||||
storage.delete_prompt_template(tpl["template_id"])
|
||||
removed.append(tpl["name"])
|
||||
|
||||
if added or removed:
|
||||
log.info(
|
||||
"MCP prompt sync: +%d added, -%d removed, %d skipped",
|
||||
len(added),
|
||||
len(removed),
|
||||
len(skipped),
|
||||
)
|
||||
return {"added": added, "removed": removed, "skipped": skipped}
|
||||
|
||||
# -- lifecycle (shutdown) ------------------------------------------------
|
||||
|
||||
def shutdown(self) -> None:
|
||||
@@ -371,18 +819,62 @@ class MCPClientManager:
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
# Clear all state
|
||||
self._sessions.clear()
|
||||
self._tools = []
|
||||
self._tool_map = {}
|
||||
self._per_server_tools.clear()
|
||||
self._supports_list_changed.clear()
|
||||
self._resources = []
|
||||
self._resource_map = {}
|
||||
self._template_prefixes = {}
|
||||
self._per_server_resources.clear()
|
||||
self._supports_resources.clear()
|
||||
self._supports_resource_list_changed.clear()
|
||||
self._prompts = []
|
||||
self._prompt_map = {}
|
||||
self._per_server_prompts.clear()
|
||||
self._supports_prompts.clear()
|
||||
self._supports_prompt_list_changed.clear()
|
||||
# Clear listener lists to release callback references
|
||||
self._listeners.clear()
|
||||
self._resource_listeners.clear()
|
||||
self._prompt_listeners.clear()
|
||||
|
||||
log.info("MCP client shut down")
|
||||
|
||||
# -- query methods -------------------------------------------------------
|
||||
|
||||
def get_tools(self) -> list[dict[str, Any]]:
|
||||
"""Return MCP tools in OpenAI function-calling format."""
|
||||
return list(self._tools)
|
||||
return [dict(t) for t in self._tools]
|
||||
|
||||
def get_resources(self) -> list[dict[str, Any]]:
|
||||
"""Return discovered MCP resources (shallow-copied dicts)."""
|
||||
return [dict(r) for r in self._resources]
|
||||
|
||||
def get_prompts(self) -> list[dict[str, Any]]:
|
||||
"""Return discovered MCP prompts (shallow-copied dicts)."""
|
||||
return [dict(p) for p in self._prompts]
|
||||
|
||||
@property
|
||||
def resource_count(self) -> int:
|
||||
"""Number of discovered resources (no allocation)."""
|
||||
return len(self._resources)
|
||||
|
||||
@property
|
||||
def prompt_count(self) -> int:
|
||||
"""Number of discovered prompts (no allocation)."""
|
||||
return len(self._prompts)
|
||||
|
||||
def is_mcp_tool(self, func_name: str) -> bool:
|
||||
"""Check whether *func_name* belongs to an MCP server."""
|
||||
return func_name in self._tool_map
|
||||
|
||||
def is_mcp_prompt(self, name: str) -> bool:
|
||||
"""Check whether *name* is a known MCP prompt."""
|
||||
return name in self._prompt_map
|
||||
|
||||
@property
|
||||
def server_count(self) -> int:
|
||||
return len(self._sessions)
|
||||
@@ -417,7 +909,10 @@ class MCPClientManager:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
session.call_tool(original_name, arguments), self._loop
|
||||
)
|
||||
result = future.result(timeout=timeout)
|
||||
try:
|
||||
result = future.result(timeout=timeout)
|
||||
except concurrent.futures.TimeoutError:
|
||||
raise TimeoutError(f"MCP tool call timed out after {timeout}s") from None
|
||||
|
||||
# Extract text from the content array
|
||||
texts: list[str] = []
|
||||
@@ -435,6 +930,94 @@ class MCPClientManager:
|
||||
output = f"Error: {output}"
|
||||
return output
|
||||
|
||||
# -- resource read -------------------------------------------------------
|
||||
|
||||
def _match_template(self, uri: str) -> tuple[str, str] | None:
|
||||
"""Find the longest matching template prefix for an expanded URI.
|
||||
|
||||
Returns ``(server_name, template_uri)`` or *None* if no match.
|
||||
The match uses the longest static prefix stored in
|
||||
``_template_prefixes`` (the portion of each template URI before
|
||||
the first ``{``), with simple ``startswith`` matching.
|
||||
"""
|
||||
best: tuple[str, str] | None = None
|
||||
best_len = 0
|
||||
for prefix, mapping in self._template_prefixes.items():
|
||||
if uri.startswith(prefix) and len(prefix) > best_len:
|
||||
best = mapping
|
||||
best_len = len(prefix)
|
||||
return best
|
||||
|
||||
def read_resource_sync(self, uri: str, timeout: int = 120) -> str:
|
||||
"""Read a resource by URI synchronously (blocks the calling thread).
|
||||
|
||||
Returns text content for ``TextResourceContents``, or base64 data
|
||||
for ``BlobResourceContents``.
|
||||
"""
|
||||
mapping = self._resource_map.get(uri)
|
||||
if mapping is None:
|
||||
# Fall back to template prefix matching for expanded URIs
|
||||
mapping = self._match_template(uri)
|
||||
if mapping is None:
|
||||
raise ValueError(f"Unknown MCP resource: {uri}")
|
||||
server_name, _ = mapping
|
||||
session = self._sessions.get(server_name)
|
||||
if session is None:
|
||||
raise RuntimeError(f"MCP server '{server_name}' is not connected")
|
||||
assert self._loop is not None
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(session.read_resource(uri), self._loop)
|
||||
try:
|
||||
result = future.result(timeout=timeout)
|
||||
except concurrent.futures.TimeoutError:
|
||||
raise TimeoutError(f"MCP resource read timed out after {timeout}s") from None
|
||||
|
||||
parts: list[str] = []
|
||||
for item in result.contents:
|
||||
if hasattr(item, "text"):
|
||||
parts.append(item.text)
|
||||
elif hasattr(item, "blob"):
|
||||
parts.append(item.blob)
|
||||
else:
|
||||
parts.append(str(item))
|
||||
return "\n".join(parts) if parts else "(empty resource)"
|
||||
|
||||
# -- prompt invocation ---------------------------------------------------
|
||||
|
||||
def get_prompt_sync(
|
||||
self,
|
||||
prefixed_name: str,
|
||||
arguments: dict[str, str] | None = None,
|
||||
timeout: int = 30,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Invoke an MCP prompt synchronously and return expanded messages.
|
||||
|
||||
Returns a list of ``{role: str, content: str}`` dicts.
|
||||
"""
|
||||
mapping = self._prompt_map.get(prefixed_name)
|
||||
if mapping is None:
|
||||
raise ValueError(f"Unknown MCP prompt: {prefixed_name}")
|
||||
server_name, original_name = mapping
|
||||
session = self._sessions.get(server_name)
|
||||
if session is None:
|
||||
raise RuntimeError(f"MCP server '{server_name}' is not connected")
|
||||
assert self._loop is not None
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
session.get_prompt(original_name, arguments=arguments), self._loop
|
||||
)
|
||||
try:
|
||||
result = future.result(timeout=timeout)
|
||||
except concurrent.futures.TimeoutError:
|
||||
raise TimeoutError(f"MCP prompt retrieval timed out after {timeout}s") from None
|
||||
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in result.messages:
|
||||
content = msg.content
|
||||
text = content.text if hasattr(content, "text") else str(content)
|
||||
messages.append({"role": msg.role, "content": text})
|
||||
return messages
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config loading
|
||||
|
||||
@@ -143,6 +143,25 @@ def load_workstream_config(ws_id: str) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
# -- Prompt templates ---------------------------------------------------------
|
||||
|
||||
|
||||
def list_default_templates(org_id: str = "") -> list[dict[str, Any]]:
|
||||
"""Return all templates where is_default=True, ordered by name."""
|
||||
try:
|
||||
return get_storage().list_default_templates(org_id)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_prompt_template_by_name(name: str) -> dict[str, Any] | None:
|
||||
"""Lookup prompt template by name."""
|
||||
try:
|
||||
return get_storage().get_prompt_template_by_name(name)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# -- Workstream metadata ------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ class MetricsCollector:
|
||||
workstream_states: dict[str, int],
|
||||
total_workstreams: int,
|
||||
workstream_metrics: list[dict[str, Any]] | None = None,
|
||||
mcp_info: dict[str, int] | None = None,
|
||||
) -> str:
|
||||
"""Return Prometheus text exposition format (v0.0.4)."""
|
||||
lines: list[str] = []
|
||||
@@ -322,6 +323,24 @@ class MetricsCollector:
|
||||
f"turnstone_workstream_context_ratio{lstr} {_fmt_value(wm['context_ratio'])}"
|
||||
)
|
||||
|
||||
# MCP gauges (optional)
|
||||
if mcp_info:
|
||||
gauge(
|
||||
"turnstone_mcp_servers",
|
||||
"Number of connected MCP servers",
|
||||
mcp_info.get("servers", 0),
|
||||
)
|
||||
gauge(
|
||||
"turnstone_mcp_resources",
|
||||
"Number of MCP resources available",
|
||||
mcp_info.get("resources", 0),
|
||||
)
|
||||
gauge(
|
||||
"turnstone_mcp_prompts",
|
||||
"Number of MCP prompts available",
|
||||
mcp_info.get("prompts", 0),
|
||||
)
|
||||
|
||||
lines.append("") # trailing newline
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Tool policy evaluation engine.
|
||||
|
||||
Evaluates tool calls against admin-defined policies to determine whether
|
||||
a tool should be auto-allowed, denied, or require human approval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.core.storage._protocol import StorageBackend
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def evaluate_tool_policy(
|
||||
storage: StorageBackend,
|
||||
tool_name: str,
|
||||
org_id: str = "",
|
||||
) -> str | None:
|
||||
"""Check tool policies for *tool_name*.
|
||||
|
||||
Policies are evaluated in priority order (highest first). The first
|
||||
matching policy wins.
|
||||
|
||||
Returns ``"allow"``, ``"deny"``, or ``"ask"`` if a policy matches,
|
||||
or ``None`` if no policy matches (caller should fall through to the
|
||||
default approval behaviour).
|
||||
"""
|
||||
try:
|
||||
policies = storage.list_tool_policies(org_id=org_id)
|
||||
except Exception:
|
||||
log.warning("Failed to load tool policies", exc_info=True)
|
||||
return None
|
||||
|
||||
for policy in policies:
|
||||
if not policy.get("enabled", True):
|
||||
continue
|
||||
pattern = policy.get("tool_pattern", "")
|
||||
if fnmatch.fnmatch(tool_name, pattern):
|
||||
action: str = policy.get("action", "ask")
|
||||
if action in ("allow", "deny", "ask"):
|
||||
return action
|
||||
log.warning("Unknown policy action %r for policy %s", action, policy.get("policy_id"))
|
||||
return "ask"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def evaluate_tool_policies_batch(
|
||||
storage: StorageBackend,
|
||||
tool_names: list[str],
|
||||
org_id: str = "",
|
||||
) -> dict[str, str | None]:
|
||||
"""Evaluate policies for multiple tools at once (single DB query).
|
||||
|
||||
Returns a dict mapping each tool name to its policy result.
|
||||
"""
|
||||
try:
|
||||
policies = storage.list_tool_policies(org_id=org_id)
|
||||
except Exception:
|
||||
log.warning("Failed to load tool policies", exc_info=True)
|
||||
return {name: None for name in tool_names}
|
||||
|
||||
results: dict[str, str | None] = {}
|
||||
for name in tool_names:
|
||||
result = None
|
||||
for policy in policies:
|
||||
if not policy.get("enabled", True):
|
||||
continue
|
||||
pattern = policy.get("tool_pattern", "")
|
||||
if fnmatch.fnmatch(name, pattern):
|
||||
action = policy.get("action", "ask")
|
||||
result = action if action in ("allow", "deny", "ask") else "ask"
|
||||
break
|
||||
results[name] = result
|
||||
return results
|
||||
+756
-117
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,16 @@ import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import (
|
||||
api_tokens,
|
||||
audit_events,
|
||||
conversations,
|
||||
memories,
|
||||
metadata,
|
||||
orgs,
|
||||
prompt_templates,
|
||||
roles,
|
||||
tool_policies,
|
||||
usage_events,
|
||||
user_roles,
|
||||
users,
|
||||
workstream_config,
|
||||
workstreams,
|
||||
@@ -22,6 +29,23 @@ from turnstone.core.storage._sqlite import _reconstruct_messages
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _row_to_dict(row: Any, *bool_fields: str) -> dict[str, Any]:
|
||||
"""Convert a SQLAlchemy row to a dict, casting named fields to bool."""
|
||||
d = dict(row._mapping)
|
||||
for key in bool_fields:
|
||||
if key in d:
|
||||
d[key] = bool(d[key])
|
||||
return d
|
||||
|
||||
|
||||
# -- Field allowlists for governance update methods ---------------------------
|
||||
|
||||
_ROLE_MUTABLE = frozenset({"display_name", "permissions"})
|
||||
_ORG_MUTABLE = frozenset({"display_name", "settings"})
|
||||
_POLICY_MUTABLE = frozenset({"name", "tool_pattern", "action", "priority", "enabled"})
|
||||
_TEMPLATE_MUTABLE = frozenset({"name", "content", "category", "variables", "is_default"})
|
||||
|
||||
|
||||
class PostgreSQLBackend:
|
||||
"""PostgreSQL implementation of the StorageBackend protocol."""
|
||||
|
||||
@@ -531,6 +555,7 @@ class PostgreSQLBackend:
|
||||
from turnstone.core.storage._schema import channel_users
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(user_roles).where(user_roles.c.user_id == user_id))
|
||||
conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id))
|
||||
conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id))
|
||||
result = conn.execute(sa.delete(users).where(users.c.user_id == user_id))
|
||||
@@ -838,6 +863,7 @@ class PostgreSQLBackend:
|
||||
auto_approve_tools: list[str],
|
||||
created_by: str,
|
||||
next_run: str,
|
||||
template: str = "",
|
||||
) -> None:
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
@@ -859,6 +885,7 @@ class PostgreSQLBackend:
|
||||
initial_message=initial_message,
|
||||
auto_approve=1 if auto_approve else 0,
|
||||
auto_approve_tools=",".join(auto_approve_tools),
|
||||
template=template,
|
||||
enabled=1,
|
||||
created_by=created_by,
|
||||
next_run=next_run,
|
||||
@@ -901,6 +928,7 @@ class PostgreSQLBackend:
|
||||
"initial_message",
|
||||
"auto_approve",
|
||||
"auto_approve_tools",
|
||||
"template",
|
||||
"enabled",
|
||||
"last_run",
|
||||
"next_run",
|
||||
@@ -1216,6 +1244,581 @@ class PostgreSQLBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Roles -----------------------------------------------------------------
|
||||
|
||||
def create_role(
|
||||
self,
|
||||
role_id: str,
|
||||
name: str,
|
||||
display_name: str,
|
||||
permissions: str,
|
||||
builtin: bool,
|
||||
org_id: str = "",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
existing = conn.execute(
|
||||
sa.select(roles.c.role_id).where(roles.c.role_id == role_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
sa.insert(roles),
|
||||
{
|
||||
"role_id": role_id,
|
||||
"name": name,
|
||||
"display_name": display_name,
|
||||
"permissions": permissions,
|
||||
"builtin": 1 if builtin else 0,
|
||||
"org_id": org_id,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_role(self, role_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(roles).where(roles.c.role_id == role_id)).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "builtin")
|
||||
return None
|
||||
|
||||
def get_role_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(roles).where(roles.c.name == name)).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "builtin")
|
||||
return None
|
||||
|
||||
def list_roles(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(roles).order_by(roles.c.name.asc())
|
||||
if org_id:
|
||||
q = q.where(roles.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "builtin") for r in rows]
|
||||
|
||||
def update_role(self, role_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _ROLE_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_role: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _ROLE_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(roles).where(roles.c.role_id == role_id).values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_role(self, role_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(user_roles).where(user_roles.c.role_id == role_id))
|
||||
result = conn.execute(sa.delete(roles).where(roles.c.role_id == role_id))
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def assign_role(self, user_id: str, role_id: str, assigned_by: str = "") -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
existing = conn.execute(
|
||||
sa.select(user_roles.c.user_id).where(
|
||||
(user_roles.c.user_id == user_id) & (user_roles.c.role_id == role_id)
|
||||
)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
sa.insert(user_roles),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"role_id": role_id,
|
||||
"assigned_by": assigned_by,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def unassign_role(self, user_id: str, role_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(user_roles).where(
|
||||
(user_roles.c.user_id == user_id) & (user_roles.c.role_id == role_id)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def list_user_roles(self, user_id: str) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
roles.c.role_id,
|
||||
roles.c.name,
|
||||
roles.c.display_name,
|
||||
roles.c.permissions,
|
||||
roles.c.builtin,
|
||||
roles.c.org_id,
|
||||
roles.c.created,
|
||||
roles.c.updated,
|
||||
user_roles.c.assigned_by,
|
||||
user_roles.c.created.label("assignment_created"),
|
||||
)
|
||||
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
|
||||
.where(user_roles.c.user_id == user_id)
|
||||
).fetchall()
|
||||
return [_row_to_dict(r, "builtin") for r in rows]
|
||||
|
||||
def get_user_permissions(self, user_id: str) -> set[str]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(roles.c.permissions)
|
||||
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
|
||||
.where(user_roles.c.user_id == user_id)
|
||||
).fetchall()
|
||||
perms: set[str] = set()
|
||||
for r in rows:
|
||||
if r[0]:
|
||||
for p in r[0].split(","):
|
||||
p = p.strip()
|
||||
if p:
|
||||
perms.add(p)
|
||||
return perms
|
||||
|
||||
# -- Organizations ---------------------------------------------------------
|
||||
|
||||
def create_org(self, org_id: str, name: str, display_name: str, settings: str = "{}") -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
existing = conn.execute(
|
||||
sa.select(orgs.c.org_id).where(orgs.c.org_id == org_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
sa.insert(orgs),
|
||||
{
|
||||
"org_id": org_id,
|
||||
"name": name,
|
||||
"display_name": display_name,
|
||||
"settings": settings,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_org(self, org_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(orgs).where(orgs.c.org_id == org_id)).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row)
|
||||
return None
|
||||
|
||||
def list_orgs(self) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(sa.select(orgs).order_by(orgs.c.name)).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
def update_org(self, org_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _ORG_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_org: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _ORG_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(sa.update(orgs).where(orgs.c.org_id == org_id).values(**fields))
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Tool policies ---------------------------------------------------------
|
||||
|
||||
def create_tool_policy(
|
||||
self,
|
||||
policy_id: str,
|
||||
name: str,
|
||||
tool_pattern: str,
|
||||
action: str,
|
||||
priority: int,
|
||||
org_id: str = "",
|
||||
enabled: bool = True,
|
||||
created_by: str = "",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(tool_policies),
|
||||
{
|
||||
"policy_id": policy_id,
|
||||
"name": name,
|
||||
"tool_pattern": tool_pattern,
|
||||
"action": action,
|
||||
"priority": priority,
|
||||
"org_id": org_id,
|
||||
"enabled": 1 if enabled else 0,
|
||||
"created_by": created_by,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_tool_policy(self, policy_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(tool_policies).where(tool_policies.c.policy_id == policy_id)
|
||||
).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "enabled")
|
||||
return None
|
||||
|
||||
def list_tool_policies(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(tool_policies).order_by(tool_policies.c.priority.desc())
|
||||
if org_id:
|
||||
q = q.where(tool_policies.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "enabled") for r in rows]
|
||||
|
||||
def update_tool_policy(self, policy_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _POLICY_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_tool_policy: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _POLICY_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
if "enabled" in fields:
|
||||
fields["enabled"] = int(fields["enabled"])
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(tool_policies)
|
||||
.where(tool_policies.c.policy_id == policy_id)
|
||||
.values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_tool_policy(self, policy_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(tool_policies).where(tool_policies.c.policy_id == policy_id)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Prompt templates ------------------------------------------------------
|
||||
|
||||
def create_prompt_template(
|
||||
self,
|
||||
template_id: str,
|
||||
name: str,
|
||||
category: str,
|
||||
content: str,
|
||||
variables: str = "[]",
|
||||
is_default: bool = False,
|
||||
org_id: str = "",
|
||||
created_by: str = "",
|
||||
origin: str = "manual",
|
||||
mcp_server: str = "",
|
||||
readonly: bool = False,
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(prompt_templates),
|
||||
{
|
||||
"template_id": template_id,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"content": content,
|
||||
"variables": variables,
|
||||
"is_default": 1 if is_default else 0,
|
||||
"org_id": org_id,
|
||||
"created_by": created_by,
|
||||
"origin": origin,
|
||||
"mcp_server": mcp_server,
|
||||
"readonly": 1 if readonly else 0,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_prompt_template(self, template_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(prompt_templates).where(prompt_templates.c.template_id == template_id)
|
||||
).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "is_default", "readonly")
|
||||
return None
|
||||
|
||||
def get_prompt_template_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(prompt_templates).where(prompt_templates.c.name == name)
|
||||
).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "is_default", "readonly")
|
||||
return None
|
||||
|
||||
def list_prompt_templates(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(prompt_templates).order_by(prompt_templates.c.name)
|
||||
if org_id:
|
||||
q = q.where(prompt_templates.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "is_default", "readonly") for r in rows]
|
||||
|
||||
def list_default_templates(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = (
|
||||
sa.select(prompt_templates)
|
||||
.where(prompt_templates.c.is_default == 1)
|
||||
.order_by(prompt_templates.c.name)
|
||||
)
|
||||
if org_id:
|
||||
q = q.where(prompt_templates.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "is_default", "readonly") for r in rows]
|
||||
|
||||
def list_prompt_templates_by_origin(self, origin: str) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(prompt_templates)
|
||||
.where(prompt_templates.c.origin == origin)
|
||||
.order_by(prompt_templates.c.name)
|
||||
).fetchall()
|
||||
return [_row_to_dict(r, "is_default", "readonly") for r in rows]
|
||||
|
||||
def update_prompt_template(self, template_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _TEMPLATE_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_prompt_template: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _TEMPLATE_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
if "is_default" in fields:
|
||||
fields["is_default"] = int(fields["is_default"])
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(prompt_templates)
|
||||
.where(prompt_templates.c.template_id == template_id)
|
||||
.values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_prompt_template(self, template_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(prompt_templates).where(prompt_templates.c.template_id == template_id)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Usage events ----------------------------------------------------------
|
||||
|
||||
def record_usage_event(
|
||||
self,
|
||||
event_id: str,
|
||||
user_id: str = "",
|
||||
ws_id: str = "",
|
||||
node_id: str = "",
|
||||
model: str = "",
|
||||
prompt_tokens: int = 0,
|
||||
completion_tokens: int = 0,
|
||||
tool_calls_count: int = 0,
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(usage_events),
|
||||
{
|
||||
"event_id": event_id,
|
||||
"timestamp": now,
|
||||
"user_id": user_id,
|
||||
"ws_id": ws_id,
|
||||
"node_id": node_id,
|
||||
"model": model,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"tool_calls_count": tool_calls_count,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def query_usage(
|
||||
self,
|
||||
since: str,
|
||||
until: str = "",
|
||||
user_id: str = "",
|
||||
model: str = "",
|
||||
group_by: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses = ["timestamp >= :since"]
|
||||
params: dict[str, Any] = {"since": since}
|
||||
if until:
|
||||
clauses.append("timestamp <= :until")
|
||||
params["until"] = until
|
||||
if user_id:
|
||||
clauses.append("user_id = :user_id")
|
||||
params["user_id"] = user_id
|
||||
if model:
|
||||
clauses.append("model = :model")
|
||||
params["model"] = model
|
||||
where = " AND ".join(clauses)
|
||||
|
||||
if group_by == "day":
|
||||
key_expr = "substring(timestamp from 1 for 10)"
|
||||
elif group_by == "hour":
|
||||
key_expr = "substring(timestamp from 1 for 13)"
|
||||
elif group_by == "model":
|
||||
key_expr = "model"
|
||||
elif group_by == "user":
|
||||
key_expr = "user_id"
|
||||
else:
|
||||
# No grouping — single summary row
|
||||
sql = (
|
||||
f"SELECT SUM(prompt_tokens), SUM(completion_tokens), "
|
||||
f"SUM(tool_calls_count) FROM usage_events WHERE {where}"
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.text(sql), params).fetchone()
|
||||
if row:
|
||||
return [
|
||||
{
|
||||
"prompt_tokens": row[0] or 0,
|
||||
"completion_tokens": row[1] or 0,
|
||||
"tool_calls_count": row[2] or 0,
|
||||
}
|
||||
]
|
||||
return [{"prompt_tokens": 0, "completion_tokens": 0, "tool_calls_count": 0}]
|
||||
|
||||
sql = (
|
||||
f"SELECT {key_expr} AS key, SUM(prompt_tokens), SUM(completion_tokens), "
|
||||
f"SUM(tool_calls_count) FROM usage_events WHERE {where} "
|
||||
f"GROUP BY {key_expr} ORDER BY key ASC"
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(sa.text(sql), params).fetchall()
|
||||
return [
|
||||
{
|
||||
"key": r[0],
|
||||
"prompt_tokens": r[1] or 0,
|
||||
"completion_tokens": r[2] or 0,
|
||||
"tool_calls_count": r[3] or 0,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def prune_usage_events(self, retention_days: int = 90) -> int:
|
||||
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(sa.delete(usage_events).where(usage_events.c.timestamp < cutoff))
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Audit events ----------------------------------------------------------
|
||||
|
||||
def record_audit_event(
|
||||
self,
|
||||
event_id: str,
|
||||
user_id: str = "",
|
||||
action: str = "",
|
||||
resource_type: str = "",
|
||||
resource_id: str = "",
|
||||
detail: str = "{}",
|
||||
ip_address: str = "",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(audit_events),
|
||||
{
|
||||
"event_id": event_id,
|
||||
"timestamp": now,
|
||||
"user_id": user_id,
|
||||
"action": action,
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"detail": detail,
|
||||
"ip_address": ip_address,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def list_audit_events(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(
|
||||
audit_events.c.event_id,
|
||||
audit_events.c.timestamp,
|
||||
audit_events.c.user_id,
|
||||
audit_events.c.action,
|
||||
audit_events.c.resource_type,
|
||||
audit_events.c.resource_id,
|
||||
audit_events.c.detail,
|
||||
audit_events.c.ip_address,
|
||||
audit_events.c.created,
|
||||
).order_by(audit_events.c.timestamp.desc(), audit_events.c.event_id.desc())
|
||||
if action:
|
||||
q = q.where(audit_events.c.action == action)
|
||||
if user_id:
|
||||
q = q.where(audit_events.c.user_id == user_id)
|
||||
if since:
|
||||
q = q.where(audit_events.c.timestamp >= since)
|
||||
if until:
|
||||
q = q.where(audit_events.c.timestamp <= until)
|
||||
q = q.limit(limit).offset(offset)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": r[0],
|
||||
"timestamp": r[1],
|
||||
"user_id": r[2],
|
||||
"action": r[3],
|
||||
"resource_type": r[4],
|
||||
"resource_id": r[5],
|
||||
"detail": r[6],
|
||||
"ip_address": r[7],
|
||||
"created": r[8],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def count_audit_events(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
) -> int:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(sa.func.count()).select_from(audit_events)
|
||||
if action:
|
||||
q = q.where(audit_events.c.action == action)
|
||||
if user_id:
|
||||
q = q.where(audit_events.c.user_id == user_id)
|
||||
if since:
|
||||
q = q.where(audit_events.c.timestamp >= since)
|
||||
if until:
|
||||
q = q.where(audit_events.c.timestamp <= until)
|
||||
row = conn.execute(q).fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
def prune_audit_events(self, retention_days: int = 365) -> int:
|
||||
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(sa.delete(audit_events).where(audit_events.c.timestamp < cutoff))
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -247,6 +247,7 @@ class StorageBackend(Protocol):
|
||||
auto_approve_tools: list[str],
|
||||
created_by: str,
|
||||
next_run: str,
|
||||
template: str = "",
|
||||
) -> None:
|
||||
"""Create a scheduled task. No-op if task_id already exists."""
|
||||
...
|
||||
@@ -359,6 +360,225 @@ class StorageBackend(Protocol):
|
||||
"""Remove a service registration. Returns True if existed."""
|
||||
...
|
||||
|
||||
# -- Roles (RBAC) ----------------------------------------------------------
|
||||
|
||||
def create_role(
|
||||
self,
|
||||
role_id: str,
|
||||
name: str,
|
||||
display_name: str,
|
||||
permissions: str,
|
||||
builtin: bool,
|
||||
org_id: str,
|
||||
) -> None:
|
||||
"""Create a role. No-op if role_id already exists."""
|
||||
...
|
||||
|
||||
def get_role(self, role_id: str) -> dict[str, Any] | None:
|
||||
"""Return role dict or None."""
|
||||
...
|
||||
|
||||
def get_role_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
"""Lookup role by name. Returns same dict as get_role or None."""
|
||||
...
|
||||
|
||||
def list_roles(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
"""Return all roles, optionally filtered by org_id. Ordered by name."""
|
||||
...
|
||||
|
||||
def update_role(self, role_id: str, **fields: Any) -> bool:
|
||||
"""Update specified fields on a role. Returns True if found."""
|
||||
...
|
||||
|
||||
def delete_role(self, role_id: str) -> bool:
|
||||
"""Delete a custom role. Returns True if found."""
|
||||
...
|
||||
|
||||
def assign_role(self, user_id: str, role_id: str, assigned_by: str) -> None:
|
||||
"""Assign a role to a user. No-op if already assigned."""
|
||||
...
|
||||
|
||||
def unassign_role(self, user_id: str, role_id: str) -> bool:
|
||||
"""Unassign a role from a user. Returns True if existed."""
|
||||
...
|
||||
|
||||
def list_user_roles(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""List roles assigned to a user (joins user_roles with roles)."""
|
||||
...
|
||||
|
||||
def get_user_permissions(self, user_id: str) -> set[str]:
|
||||
"""Return the union of all permissions from the user's assigned roles."""
|
||||
...
|
||||
|
||||
# -- Organizations ---------------------------------------------------------
|
||||
|
||||
def create_org(self, org_id: str, name: str, display_name: str, settings: str = "{}") -> None:
|
||||
"""Create an organization. No-op if org_id already exists."""
|
||||
...
|
||||
|
||||
def get_org(self, org_id: str) -> dict[str, Any] | None:
|
||||
"""Return org dict or None."""
|
||||
...
|
||||
|
||||
def list_orgs(self) -> list[dict[str, Any]]:
|
||||
"""Return all organizations ordered by name."""
|
||||
...
|
||||
|
||||
def update_org(self, org_id: str, **fields: Any) -> bool:
|
||||
"""Update specified fields on an org. Returns True if found."""
|
||||
...
|
||||
|
||||
# -- Tool policies ---------------------------------------------------------
|
||||
|
||||
def create_tool_policy(
|
||||
self,
|
||||
policy_id: str,
|
||||
name: str,
|
||||
tool_pattern: str,
|
||||
action: str,
|
||||
priority: int,
|
||||
org_id: str,
|
||||
enabled: bool,
|
||||
created_by: str,
|
||||
) -> None:
|
||||
"""Create a tool policy."""
|
||||
...
|
||||
|
||||
def get_tool_policy(self, policy_id: str) -> dict[str, Any] | None:
|
||||
"""Return tool policy dict or None."""
|
||||
...
|
||||
|
||||
def list_tool_policies(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
"""Return all tool policies ordered by priority DESC."""
|
||||
...
|
||||
|
||||
def update_tool_policy(self, policy_id: str, **fields: Any) -> bool:
|
||||
"""Update specified fields on a tool policy. Returns True if found."""
|
||||
...
|
||||
|
||||
def delete_tool_policy(self, policy_id: str) -> bool:
|
||||
"""Delete a tool policy. Returns True if found."""
|
||||
...
|
||||
|
||||
# -- Prompt templates ------------------------------------------------------
|
||||
|
||||
def create_prompt_template(
|
||||
self,
|
||||
template_id: str,
|
||||
name: str,
|
||||
category: str,
|
||||
content: str,
|
||||
variables: str,
|
||||
is_default: bool,
|
||||
org_id: str,
|
||||
created_by: str,
|
||||
origin: str = "manual",
|
||||
mcp_server: str = "",
|
||||
readonly: bool = False,
|
||||
) -> None:
|
||||
"""Create a prompt template."""
|
||||
...
|
||||
|
||||
def get_prompt_template(self, template_id: str) -> dict[str, Any] | None:
|
||||
"""Return prompt template dict or None."""
|
||||
...
|
||||
|
||||
def get_prompt_template_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
"""Lookup prompt template by name. Returns same dict as get_prompt_template or None."""
|
||||
...
|
||||
|
||||
def list_prompt_templates(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
"""Return all prompt templates ordered by name."""
|
||||
...
|
||||
|
||||
def list_default_templates(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
"""Return all templates where is_default=True, ordered by name."""
|
||||
...
|
||||
|
||||
def list_prompt_templates_by_origin(self, origin: str) -> list[dict[str, Any]]:
|
||||
"""Return all prompt templates with the given origin, ordered by name."""
|
||||
...
|
||||
|
||||
def update_prompt_template(self, template_id: str, **fields: Any) -> bool:
|
||||
"""Update specified fields on a prompt template. Returns True if found."""
|
||||
...
|
||||
|
||||
def delete_prompt_template(self, template_id: str) -> bool:
|
||||
"""Delete a prompt template. Returns True if found."""
|
||||
...
|
||||
|
||||
# -- Usage events ----------------------------------------------------------
|
||||
|
||||
def record_usage_event(
|
||||
self,
|
||||
event_id: str,
|
||||
user_id: str,
|
||||
ws_id: str,
|
||||
node_id: str,
|
||||
model: str,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
tool_calls_count: int,
|
||||
) -> None:
|
||||
"""Record a usage event (token counts, tool calls for one LLM request)."""
|
||||
...
|
||||
|
||||
def query_usage(
|
||||
self,
|
||||
since: str,
|
||||
until: str = "",
|
||||
user_id: str = "",
|
||||
model: str = "",
|
||||
group_by: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Query aggregated usage data. group_by: 'day', 'hour', 'model', 'user'."""
|
||||
...
|
||||
|
||||
def prune_usage_events(self, retention_days: int = 90) -> int:
|
||||
"""Delete usage events older than retention_days. Returns count deleted."""
|
||||
...
|
||||
|
||||
# -- Audit events ----------------------------------------------------------
|
||||
|
||||
def record_audit_event(
|
||||
self,
|
||||
event_id: str,
|
||||
user_id: str,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
detail: str,
|
||||
ip_address: str,
|
||||
) -> None:
|
||||
"""Record an audit event."""
|
||||
...
|
||||
|
||||
def list_audit_events(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List audit events with optional filters, ordered by timestamp DESC."""
|
||||
...
|
||||
|
||||
def count_audit_events(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
) -> int:
|
||||
"""Count audit events matching the filters."""
|
||||
...
|
||||
|
||||
def prune_audit_events(self, retention_days: int = 365) -> int:
|
||||
"""Delete audit events older than retention_days. Returns count deleted."""
|
||||
...
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -71,6 +71,7 @@ users = sa.Table(
|
||||
sa.Column("username", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("display_name", sa.Text, nullable=False),
|
||||
sa.Column("password_hash", sa.Text, nullable=False),
|
||||
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
@@ -139,6 +140,7 @@ scheduled_tasks = sa.Table(
|
||||
sa.Column("initial_message", sa.Text, nullable=False),
|
||||
sa.Column("auto_approve", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("auto_approve_tools", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("template", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("last_run", sa.Text),
|
||||
@@ -217,3 +219,117 @@ services = sa.Table(
|
||||
)
|
||||
|
||||
sa.Index("idx_services_type_heartbeat", services.c.service_type, services.c.last_heartbeat)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance tables — RBAC, orgs, policies, templates, usage, audit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
orgs = sa.Table(
|
||||
"orgs",
|
||||
metadata,
|
||||
sa.Column("org_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("display_name", sa.Text, nullable=False),
|
||||
sa.Column("settings", sa.Text, nullable=False, server_default="{}"),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
roles = sa.Table(
|
||||
"roles",
|
||||
metadata,
|
||||
sa.Column("role_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("display_name", sa.Text, nullable=False),
|
||||
sa.Column("permissions", sa.Text, nullable=False), # comma-separated
|
||||
sa.Column("builtin", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
user_roles = sa.Table(
|
||||
"user_roles",
|
||||
metadata,
|
||||
sa.Column("user_id", sa.Text, nullable=False),
|
||||
sa.Column("role_id", sa.Text, nullable=False),
|
||||
sa.Column("assigned_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.PrimaryKeyConstraint("user_id", "role_id"),
|
||||
)
|
||||
|
||||
sa.Index("idx_user_roles_role_id", user_roles.c.role_id)
|
||||
|
||||
tool_policies = sa.Table(
|
||||
"tool_policies",
|
||||
metadata,
|
||||
sa.Column("policy_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False),
|
||||
sa.Column("tool_pattern", sa.Text, nullable=False),
|
||||
sa.Column("action", sa.Text, nullable=False), # allow / deny / ask
|
||||
sa.Column("priority", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
sa.Index("idx_tool_policies_priority", tool_policies.c.priority.desc())
|
||||
sa.Index("idx_tool_policies_org", tool_policies.c.org_id)
|
||||
|
||||
prompt_templates = sa.Table(
|
||||
"prompt_templates",
|
||||
metadata,
|
||||
sa.Column("template_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("category", sa.Text, nullable=False, server_default="general"),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("variables", sa.Text, nullable=False, server_default="[]"), # JSON array
|
||||
sa.Column("is_default", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("origin", sa.Text, nullable=False, server_default="manual"),
|
||||
sa.Column("mcp_server", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("readonly", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
usage_events = sa.Table(
|
||||
"usage_events",
|
||||
metadata,
|
||||
sa.Column("event_id", sa.Text, primary_key=True),
|
||||
sa.Column("timestamp", sa.Text, nullable=False),
|
||||
sa.Column("user_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("ws_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("node_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("model", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("prompt_tokens", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("completion_tokens", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("tool_calls_count", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
sa.Index("idx_usage_events_timestamp", usage_events.c.timestamp)
|
||||
sa.Index("idx_usage_events_user", usage_events.c.user_id, usage_events.c.timestamp)
|
||||
sa.Index("idx_usage_events_model", usage_events.c.model, usage_events.c.timestamp)
|
||||
sa.Index("idx_usage_events_ws", usage_events.c.ws_id)
|
||||
|
||||
audit_events = sa.Table(
|
||||
"audit_events",
|
||||
metadata,
|
||||
sa.Column("event_id", sa.Text, primary_key=True),
|
||||
sa.Column("timestamp", sa.Text, nullable=False),
|
||||
sa.Column("user_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("action", sa.Text, nullable=False),
|
||||
sa.Column("resource_type", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("resource_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("detail", sa.Text, nullable=False, server_default="{}"),
|
||||
sa.Column("ip_address", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
sa.Index("idx_audit_timestamp", audit_events.c.timestamp)
|
||||
sa.Index("idx_audit_action", audit_events.c.action)
|
||||
sa.Index("idx_audit_user", audit_events.c.user_id)
|
||||
|
||||
@@ -12,9 +12,16 @@ import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import (
|
||||
api_tokens,
|
||||
audit_events,
|
||||
conversations,
|
||||
memories,
|
||||
metadata,
|
||||
orgs,
|
||||
prompt_templates,
|
||||
roles,
|
||||
tool_policies,
|
||||
usage_events,
|
||||
user_roles,
|
||||
users,
|
||||
workstream_config,
|
||||
workstreams,
|
||||
@@ -38,6 +45,23 @@ def _fts5_query(query: str) -> str:
|
||||
return " ".join(safe)
|
||||
|
||||
|
||||
def _row_to_dict(row: Any, *bool_fields: str) -> dict[str, Any]:
|
||||
"""Convert a SQLAlchemy row to a dict, casting named fields to bool."""
|
||||
d = dict(row._mapping)
|
||||
for key in bool_fields:
|
||||
if key in d:
|
||||
d[key] = bool(d[key])
|
||||
return d
|
||||
|
||||
|
||||
# -- Field allowlists for governance update methods ---------------------------
|
||||
|
||||
_ROLE_MUTABLE = frozenset({"display_name", "permissions"})
|
||||
_ORG_MUTABLE = frozenset({"display_name", "settings"})
|
||||
_POLICY_MUTABLE = frozenset({"name", "tool_pattern", "action", "priority", "enabled"})
|
||||
_TEMPLATE_MUTABLE = frozenset({"name", "content", "category", "variables", "is_default"})
|
||||
|
||||
|
||||
class SQLiteBackend:
|
||||
"""SQLite implementation of the StorageBackend protocol."""
|
||||
|
||||
@@ -590,6 +614,7 @@ class SQLiteBackend:
|
||||
from turnstone.core.storage._schema import channel_users
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(user_roles).where(user_roles.c.user_id == user_id))
|
||||
conn.execute(sa.delete(channel_users).where(channel_users.c.user_id == user_id))
|
||||
conn.execute(sa.delete(api_tokens).where(api_tokens.c.user_id == user_id))
|
||||
result = conn.execute(sa.delete(users).where(users.c.user_id == user_id))
|
||||
@@ -891,6 +916,7 @@ class SQLiteBackend:
|
||||
auto_approve_tools: list[str],
|
||||
created_by: str,
|
||||
next_run: str,
|
||||
template: str = "",
|
||||
) -> None:
|
||||
from turnstone.core.storage._schema import scheduled_tasks
|
||||
|
||||
@@ -910,6 +936,7 @@ class SQLiteBackend:
|
||||
"initial_message": initial_message,
|
||||
"auto_approve": 1 if auto_approve else 0,
|
||||
"auto_approve_tools": ",".join(auto_approve_tools),
|
||||
"template": template,
|
||||
"enabled": 1,
|
||||
"created_by": created_by,
|
||||
"next_run": next_run,
|
||||
@@ -951,6 +978,7 @@ class SQLiteBackend:
|
||||
"initial_message",
|
||||
"auto_approve",
|
||||
"auto_approve_tools",
|
||||
"template",
|
||||
"enabled",
|
||||
"last_run",
|
||||
"next_run",
|
||||
@@ -1264,6 +1292,567 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Roles -----------------------------------------------------------------
|
||||
|
||||
def create_role(
|
||||
self,
|
||||
role_id: str,
|
||||
name: str,
|
||||
display_name: str,
|
||||
permissions: str,
|
||||
builtin: bool,
|
||||
org_id: str = "",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(roles).prefix_with("OR IGNORE"),
|
||||
{
|
||||
"role_id": role_id,
|
||||
"name": name,
|
||||
"display_name": display_name,
|
||||
"permissions": permissions,
|
||||
"builtin": 1 if builtin else 0,
|
||||
"org_id": org_id,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_role(self, role_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(roles).where(roles.c.role_id == role_id)).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "builtin")
|
||||
return None
|
||||
|
||||
def get_role_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(roles).where(roles.c.name == name)).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "builtin")
|
||||
return None
|
||||
|
||||
def list_roles(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(roles).order_by(roles.c.name.asc())
|
||||
if org_id:
|
||||
q = q.where(roles.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "builtin") for r in rows]
|
||||
|
||||
def update_role(self, role_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _ROLE_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_role: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _ROLE_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(roles).where(roles.c.role_id == role_id).values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_role(self, role_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(sa.delete(user_roles).where(user_roles.c.role_id == role_id))
|
||||
result = conn.execute(sa.delete(roles).where(roles.c.role_id == role_id))
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def assign_role(self, user_id: str, role_id: str, assigned_by: str = "") -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(user_roles).prefix_with("OR IGNORE"),
|
||||
{
|
||||
"user_id": user_id,
|
||||
"role_id": role_id,
|
||||
"assigned_by": assigned_by,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def unassign_role(self, user_id: str, role_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(user_roles).where(
|
||||
(user_roles.c.user_id == user_id) & (user_roles.c.role_id == role_id)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def list_user_roles(self, user_id: str) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
roles.c.role_id,
|
||||
roles.c.name,
|
||||
roles.c.display_name,
|
||||
roles.c.permissions,
|
||||
roles.c.builtin,
|
||||
roles.c.org_id,
|
||||
roles.c.created,
|
||||
roles.c.updated,
|
||||
user_roles.c.assigned_by,
|
||||
user_roles.c.created.label("assignment_created"),
|
||||
)
|
||||
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
|
||||
.where(user_roles.c.user_id == user_id)
|
||||
).fetchall()
|
||||
return [_row_to_dict(r, "builtin") for r in rows]
|
||||
|
||||
def get_user_permissions(self, user_id: str) -> set[str]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(roles.c.permissions)
|
||||
.select_from(user_roles.join(roles, user_roles.c.role_id == roles.c.role_id))
|
||||
.where(user_roles.c.user_id == user_id)
|
||||
).fetchall()
|
||||
perms: set[str] = set()
|
||||
for r in rows:
|
||||
if r[0]:
|
||||
for p in r[0].split(","):
|
||||
p = p.strip()
|
||||
if p:
|
||||
perms.add(p)
|
||||
return perms
|
||||
|
||||
# -- Organizations ---------------------------------------------------------
|
||||
|
||||
def create_org(self, org_id: str, name: str, display_name: str, settings: str = "{}") -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(orgs).prefix_with("OR IGNORE"),
|
||||
{
|
||||
"org_id": org_id,
|
||||
"name": name,
|
||||
"display_name": display_name,
|
||||
"settings": settings,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_org(self, org_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(orgs).where(orgs.c.org_id == org_id)).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row)
|
||||
return None
|
||||
|
||||
def list_orgs(self) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(sa.select(orgs).order_by(orgs.c.name)).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
def update_org(self, org_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _ORG_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_org: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _ORG_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(sa.update(orgs).where(orgs.c.org_id == org_id).values(**fields))
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Tool policies ---------------------------------------------------------
|
||||
|
||||
def create_tool_policy(
|
||||
self,
|
||||
policy_id: str,
|
||||
name: str,
|
||||
tool_pattern: str,
|
||||
action: str,
|
||||
priority: int,
|
||||
org_id: str = "",
|
||||
enabled: bool = True,
|
||||
created_by: str = "",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(tool_policies),
|
||||
{
|
||||
"policy_id": policy_id,
|
||||
"name": name,
|
||||
"tool_pattern": tool_pattern,
|
||||
"action": action,
|
||||
"priority": priority,
|
||||
"org_id": org_id,
|
||||
"enabled": 1 if enabled else 0,
|
||||
"created_by": created_by,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_tool_policy(self, policy_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(tool_policies).where(tool_policies.c.policy_id == policy_id)
|
||||
).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "enabled")
|
||||
return None
|
||||
|
||||
def list_tool_policies(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(tool_policies).order_by(tool_policies.c.priority.desc())
|
||||
if org_id:
|
||||
q = q.where(tool_policies.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "enabled") for r in rows]
|
||||
|
||||
def update_tool_policy(self, policy_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _POLICY_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_tool_policy: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _POLICY_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
if "enabled" in fields:
|
||||
fields["enabled"] = int(fields["enabled"])
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(tool_policies)
|
||||
.where(tool_policies.c.policy_id == policy_id)
|
||||
.values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_tool_policy(self, policy_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(tool_policies).where(tool_policies.c.policy_id == policy_id)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Prompt templates ------------------------------------------------------
|
||||
|
||||
def create_prompt_template(
|
||||
self,
|
||||
template_id: str,
|
||||
name: str,
|
||||
category: str,
|
||||
content: str,
|
||||
variables: str = "[]",
|
||||
is_default: bool = False,
|
||||
org_id: str = "",
|
||||
created_by: str = "",
|
||||
origin: str = "manual",
|
||||
mcp_server: str = "",
|
||||
readonly: bool = False,
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(prompt_templates),
|
||||
{
|
||||
"template_id": template_id,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"content": content,
|
||||
"variables": variables,
|
||||
"is_default": 1 if is_default else 0,
|
||||
"org_id": org_id,
|
||||
"created_by": created_by,
|
||||
"origin": origin,
|
||||
"mcp_server": mcp_server,
|
||||
"readonly": 1 if readonly else 0,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_prompt_template(self, template_id: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(prompt_templates).where(prompt_templates.c.template_id == template_id)
|
||||
).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "is_default", "readonly")
|
||||
return None
|
||||
|
||||
def get_prompt_template_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(prompt_templates).where(prompt_templates.c.name == name)
|
||||
).fetchone()
|
||||
if row:
|
||||
return _row_to_dict(row, "is_default", "readonly")
|
||||
return None
|
||||
|
||||
def list_prompt_templates(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(prompt_templates).order_by(prompt_templates.c.name)
|
||||
if org_id:
|
||||
q = q.where(prompt_templates.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "is_default", "readonly") for r in rows]
|
||||
|
||||
def list_default_templates(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = (
|
||||
sa.select(prompt_templates)
|
||||
.where(prompt_templates.c.is_default == 1)
|
||||
.order_by(prompt_templates.c.name)
|
||||
)
|
||||
if org_id:
|
||||
q = q.where(prompt_templates.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "is_default", "readonly") for r in rows]
|
||||
|
||||
def list_prompt_templates_by_origin(self, origin: str) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(prompt_templates)
|
||||
.where(prompt_templates.c.origin == origin)
|
||||
.order_by(prompt_templates.c.name)
|
||||
).fetchall()
|
||||
return [_row_to_dict(r, "is_default", "readonly") for r in rows]
|
||||
|
||||
def update_prompt_template(self, template_id: str, **fields: Any) -> bool:
|
||||
dropped = set(fields) - _TEMPLATE_MUTABLE
|
||||
if dropped:
|
||||
log.warning("update_prompt_template: ignoring unknown fields: %s", dropped)
|
||||
fields = {k: v for k, v in fields.items() if k in _TEMPLATE_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
if "is_default" in fields:
|
||||
fields["is_default"] = int(fields["is_default"])
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(prompt_templates)
|
||||
.where(prompt_templates.c.template_id == template_id)
|
||||
.values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_prompt_template(self, template_id: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(prompt_templates).where(prompt_templates.c.template_id == template_id)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Usage events ----------------------------------------------------------
|
||||
|
||||
def record_usage_event(
|
||||
self,
|
||||
event_id: str,
|
||||
user_id: str = "",
|
||||
ws_id: str = "",
|
||||
node_id: str = "",
|
||||
model: str = "",
|
||||
prompt_tokens: int = 0,
|
||||
completion_tokens: int = 0,
|
||||
tool_calls_count: int = 0,
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(usage_events),
|
||||
{
|
||||
"event_id": event_id,
|
||||
"timestamp": now,
|
||||
"user_id": user_id,
|
||||
"ws_id": ws_id,
|
||||
"node_id": node_id,
|
||||
"model": model,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"tool_calls_count": tool_calls_count,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def query_usage(
|
||||
self,
|
||||
since: str,
|
||||
until: str = "",
|
||||
user_id: str = "",
|
||||
model: str = "",
|
||||
group_by: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses = ["timestamp >= :since"]
|
||||
params: dict[str, Any] = {"since": since}
|
||||
if until:
|
||||
clauses.append("timestamp <= :until")
|
||||
params["until"] = until
|
||||
if user_id:
|
||||
clauses.append("user_id = :user_id")
|
||||
params["user_id"] = user_id
|
||||
if model:
|
||||
clauses.append("model = :model")
|
||||
params["model"] = model
|
||||
where = " AND ".join(clauses)
|
||||
|
||||
if group_by == "day":
|
||||
key_expr = "substr(timestamp, 1, 10)"
|
||||
elif group_by == "hour":
|
||||
key_expr = "substr(timestamp, 1, 13)"
|
||||
elif group_by == "model":
|
||||
key_expr = "model"
|
||||
elif group_by == "user":
|
||||
key_expr = "user_id"
|
||||
else:
|
||||
# No grouping — single summary row
|
||||
sql = (
|
||||
f"SELECT SUM(prompt_tokens), SUM(completion_tokens), "
|
||||
f"SUM(tool_calls_count) FROM usage_events WHERE {where}"
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.text(sql), params).fetchone()
|
||||
if row:
|
||||
return [
|
||||
{
|
||||
"prompt_tokens": row[0] or 0,
|
||||
"completion_tokens": row[1] or 0,
|
||||
"tool_calls_count": row[2] or 0,
|
||||
}
|
||||
]
|
||||
return [{"prompt_tokens": 0, "completion_tokens": 0, "tool_calls_count": 0}]
|
||||
|
||||
sql = (
|
||||
f"SELECT {key_expr} AS key, SUM(prompt_tokens), SUM(completion_tokens), "
|
||||
f"SUM(tool_calls_count) FROM usage_events WHERE {where} "
|
||||
f"GROUP BY {key_expr} ORDER BY key ASC"
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(sa.text(sql), params).fetchall()
|
||||
return [
|
||||
{
|
||||
"key": r[0],
|
||||
"prompt_tokens": r[1] or 0,
|
||||
"completion_tokens": r[2] or 0,
|
||||
"tool_calls_count": r[3] or 0,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def prune_usage_events(self, retention_days: int = 90) -> int:
|
||||
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(sa.delete(usage_events).where(usage_events.c.timestamp < cutoff))
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Audit events ----------------------------------------------------------
|
||||
|
||||
def record_audit_event(
|
||||
self,
|
||||
event_id: str,
|
||||
user_id: str = "",
|
||||
action: str = "",
|
||||
resource_type: str = "",
|
||||
resource_id: str = "",
|
||||
detail: str = "{}",
|
||||
ip_address: str = "",
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(audit_events),
|
||||
{
|
||||
"event_id": event_id,
|
||||
"timestamp": now,
|
||||
"user_id": user_id,
|
||||
"action": action,
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"detail": detail,
|
||||
"ip_address": ip_address,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def list_audit_events(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(
|
||||
audit_events.c.event_id,
|
||||
audit_events.c.timestamp,
|
||||
audit_events.c.user_id,
|
||||
audit_events.c.action,
|
||||
audit_events.c.resource_type,
|
||||
audit_events.c.resource_id,
|
||||
audit_events.c.detail,
|
||||
audit_events.c.ip_address,
|
||||
audit_events.c.created,
|
||||
).order_by(audit_events.c.timestamp.desc(), audit_events.c.event_id.desc())
|
||||
if action:
|
||||
q = q.where(audit_events.c.action == action)
|
||||
if user_id:
|
||||
q = q.where(audit_events.c.user_id == user_id)
|
||||
if since:
|
||||
q = q.where(audit_events.c.timestamp >= since)
|
||||
if until:
|
||||
q = q.where(audit_events.c.timestamp <= until)
|
||||
q = q.limit(limit).offset(offset)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": r[0],
|
||||
"timestamp": r[1],
|
||||
"user_id": r[2],
|
||||
"action": r[3],
|
||||
"resource_type": r[4],
|
||||
"resource_id": r[5],
|
||||
"detail": r[6],
|
||||
"ip_address": r[7],
|
||||
"created": r[8],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def count_audit_events(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
) -> int:
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(sa.func.count()).select_from(audit_events)
|
||||
if action:
|
||||
q = q.where(audit_events.c.action == action)
|
||||
if user_id:
|
||||
q = q.where(audit_events.c.user_id == user_id)
|
||||
if since:
|
||||
q = q.where(audit_events.c.timestamp >= since)
|
||||
if until:
|
||||
q = q.where(audit_events.c.timestamp <= until)
|
||||
row = conn.execute(q).fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
def prune_audit_events(self, retention_days: int = 365) -> int:
|
||||
cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(sa.delete(audit_events).where(audit_events.c.timestamp < cutoff))
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Governance tables — RBAC roles, orgs, tool policies, prompt templates, usage, audit.
|
||||
|
||||
Revision ID: 008
|
||||
Revises: 007
|
||||
Create Date: 2026-03-10
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "008"
|
||||
down_revision = "007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Built-in roles seeded on upgrade
|
||||
_ADMIN_PERMS = (
|
||||
"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_PERMS = "read,write,workstreams.create,workstreams.close"
|
||||
_VIEWER_PERMS = "read"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# -- Organizations ---------------------------------------------------------
|
||||
op.create_table(
|
||||
"orgs",
|
||||
sa.Column("org_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("display_name", sa.Text, nullable=False),
|
||||
sa.Column("settings", sa.Text, nullable=False, server_default="{}"),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
# -- Roles -----------------------------------------------------------------
|
||||
op.create_table(
|
||||
"roles",
|
||||
sa.Column("role_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("display_name", sa.Text, nullable=False),
|
||||
sa.Column("permissions", sa.Text, nullable=False),
|
||||
sa.Column("builtin", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
# -- User ↔ Role assignments -----------------------------------------------
|
||||
op.create_table(
|
||||
"user_roles",
|
||||
sa.Column("user_id", sa.Text, nullable=False),
|
||||
sa.Column("role_id", sa.Text, nullable=False),
|
||||
sa.Column("assigned_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.PrimaryKeyConstraint("user_id", "role_id"),
|
||||
)
|
||||
op.create_index("idx_user_roles_role_id", "user_roles", ["role_id"])
|
||||
|
||||
# -- Tool policies ---------------------------------------------------------
|
||||
op.create_table(
|
||||
"tool_policies",
|
||||
sa.Column("policy_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False),
|
||||
sa.Column("tool_pattern", sa.Text, nullable=False),
|
||||
sa.Column("action", sa.Text, nullable=False),
|
||||
sa.Column("priority", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
op.create_index("idx_tool_policies_priority", "tool_policies", [sa.text("priority DESC")])
|
||||
op.create_index("idx_tool_policies_org", "tool_policies", ["org_id"])
|
||||
|
||||
# -- Prompt templates ------------------------------------------------------
|
||||
op.create_table(
|
||||
"prompt_templates",
|
||||
sa.Column("template_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("category", sa.Text, nullable=False, server_default="general"),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("variables", sa.Text, nullable=False, server_default="[]"),
|
||||
sa.Column("is_default", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
# -- Usage events ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"usage_events",
|
||||
sa.Column("event_id", sa.Text, primary_key=True),
|
||||
sa.Column("timestamp", sa.Text, nullable=False),
|
||||
sa.Column("user_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("ws_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("node_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("model", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("prompt_tokens", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("completion_tokens", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("tool_calls_count", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
op.create_index("idx_usage_events_timestamp", "usage_events", ["timestamp"])
|
||||
op.create_index("idx_usage_events_user", "usage_events", ["user_id", "timestamp"])
|
||||
op.create_index("idx_usage_events_model", "usage_events", ["model", "timestamp"])
|
||||
op.create_index("idx_usage_events_ws", "usage_events", ["ws_id"])
|
||||
|
||||
# -- Audit events ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"audit_events",
|
||||
sa.Column("event_id", sa.Text, primary_key=True),
|
||||
sa.Column("timestamp", sa.Text, nullable=False),
|
||||
sa.Column("user_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("action", sa.Text, nullable=False),
|
||||
sa.Column("resource_type", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("resource_id", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("detail", sa.Text, nullable=False, server_default="{}"),
|
||||
sa.Column("ip_address", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
op.create_index("idx_audit_timestamp", "audit_events", ["timestamp"])
|
||||
op.create_index("idx_audit_action", "audit_events", ["action"])
|
||||
op.create_index("idx_audit_user", "audit_events", ["user_id"])
|
||||
|
||||
# -- Add org_id to users ---------------------------------------------------
|
||||
with op.batch_alter_table("users") as batch_op:
|
||||
batch_op.add_column(sa.Column("org_id", sa.Text, nullable=False, server_default=""))
|
||||
|
||||
# -- Seed default org and built-in roles -----------------------------------
|
||||
conn = op.get_bind()
|
||||
import datetime
|
||||
|
||||
now_str = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO orgs (org_id, name, display_name, settings, created, updated) "
|
||||
"VALUES (:oid, :name, :dname, '{}', :now, :now)"
|
||||
),
|
||||
{"oid": "default", "name": "default", "dname": "Default", "now": now_str},
|
||||
)
|
||||
for role_id, name, dname, perms in [
|
||||
("builtin-admin", "admin", "Admin", _ADMIN_PERMS),
|
||||
("builtin-operator", "operator", "Operator", _OPERATOR_PERMS),
|
||||
("builtin-viewer", "viewer", "Viewer", _VIEWER_PERMS),
|
||||
]:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO roles (role_id, name, display_name, permissions, builtin, org_id, created, updated) "
|
||||
"VALUES (:rid, :name, :dname, :perms, 1, '', :now, :now)"
|
||||
),
|
||||
{"rid": role_id, "name": name, "dname": dname, "perms": perms, "now": now_str},
|
||||
)
|
||||
|
||||
# Assign admin role to all existing users
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO user_roles (user_id, role_id, assigned_by, created) "
|
||||
"SELECT user_id, 'builtin-admin', '', :now FROM users"
|
||||
),
|
||||
{"now": now_str},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_audit_user", "audit_events")
|
||||
op.drop_index("idx_audit_action", "audit_events")
|
||||
op.drop_index("idx_audit_timestamp", "audit_events")
|
||||
op.drop_table("audit_events")
|
||||
|
||||
op.drop_index("idx_usage_events_ws", "usage_events")
|
||||
op.drop_index("idx_usage_events_model", "usage_events")
|
||||
op.drop_index("idx_usage_events_user", "usage_events")
|
||||
op.drop_index("idx_usage_events_timestamp", "usage_events")
|
||||
op.drop_table("usage_events")
|
||||
|
||||
op.drop_table("prompt_templates")
|
||||
|
||||
op.drop_index("idx_tool_policies_org", "tool_policies")
|
||||
op.drop_index("idx_tool_policies_priority", "tool_policies")
|
||||
op.drop_table("tool_policies")
|
||||
|
||||
op.drop_index("idx_user_roles_role_id", "user_roles")
|
||||
op.drop_table("user_roles")
|
||||
op.drop_table("roles")
|
||||
op.drop_table("orgs")
|
||||
|
||||
with op.batch_alter_table("users") as batch_op:
|
||||
batch_op.drop_column("org_id")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add MCP origin tracking columns to prompt_templates.
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2026-03-12
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "009"
|
||||
down_revision = "008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("prompt_templates") as batch_op:
|
||||
batch_op.add_column(sa.Column("origin", sa.Text, nullable=False, server_default="manual"))
|
||||
batch_op.add_column(sa.Column("mcp_server", sa.Text, nullable=False, server_default=""))
|
||||
batch_op.add_column(sa.Column("readonly", sa.Integer, nullable=False, server_default="0"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("prompt_templates") as batch_op:
|
||||
batch_op.drop_column("readonly")
|
||||
batch_op.drop_column("mcp_server")
|
||||
batch_op.drop_column("origin")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Add template column to scheduled_tasks.
|
||||
|
||||
Revision ID: 010
|
||||
Revises: 009
|
||||
Create Date: 2026-03-12
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "010"
|
||||
down_revision = "009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("scheduled_tasks") as batch_op:
|
||||
batch_op.add_column(sa.Column("template", sa.Text, nullable=False, server_default=""))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("scheduled_tasks") as batch_op:
|
||||
batch_op.drop_column("template")
|
||||
+52
-17
@@ -139,12 +139,17 @@ class Bridge:
|
||||
# -- public entry point --------------------------------------------------
|
||||
|
||||
def _fetch_node_id(self) -> str:
|
||||
"""Retrieve node_id from server /health with exponential backoff.
|
||||
"""Retrieve node_id from server /health with capped exponential backoff.
|
||||
|
||||
Raises ``SystemExit`` if the server is unreachable after 5 attempts.
|
||||
Retries indefinitely so the bridge recovers when a server comes
|
||||
back after a transient outage. 4xx responses (auth/config errors)
|
||||
still fail fast.
|
||||
"""
|
||||
delays = [1, 2, 4, 8, 16]
|
||||
for attempt, delay in enumerate(delays, 1):
|
||||
attempt = 0
|
||||
delay = 1.0
|
||||
max_delay = 60.0
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
resp = self._http.get("/health")
|
||||
if 400 <= resp.status_code < 500:
|
||||
@@ -155,20 +160,17 @@ class Bridge:
|
||||
nid = data.get("node_id", "")
|
||||
if nid:
|
||||
return str(nid)
|
||||
log.warning("Server /health missing node_id (attempt %d/%d)", attempt, len(delays))
|
||||
log.warning("Server /health missing node_id (attempt %d)", attempt)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"Failed to fetch node_id from server (attempt %d/%d): %s",
|
||||
"Failed to fetch node_id from server (attempt %d): %s",
|
||||
attempt,
|
||||
len(delays),
|
||||
exc,
|
||||
)
|
||||
if attempt < len(delays):
|
||||
time.sleep(delay)
|
||||
log.critical(
|
||||
"Could not retrieve node_id from server after %d attempts — exiting", len(delays)
|
||||
)
|
||||
raise SystemExit(1)
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 2, max_delay)
|
||||
|
||||
def run(self) -> None:
|
||||
"""Block until shutdown (KeyboardInterrupt)."""
|
||||
@@ -241,6 +243,7 @@ class Bridge:
|
||||
"command": self._handle_command,
|
||||
"create_workstream": self._handle_create_ws,
|
||||
"close_workstream": self._handle_close_ws,
|
||||
"cancel": self._handle_cancel,
|
||||
}
|
||||
# Messages that are always local (no routing needed)
|
||||
local_handlers = {
|
||||
@@ -348,6 +351,20 @@ class Bridge:
|
||||
if request_id:
|
||||
self._broker.push_response(request_id, msg.to_json())
|
||||
|
||||
def _handle_cancel(self, msg: InboundMessage) -> None:
|
||||
ws_id = getattr(msg, "ws_id", "")
|
||||
resp = self._http.post("/v1/api/cancel", json={"ws_id": ws_id})
|
||||
data = resp.json()
|
||||
self._publish_ws(
|
||||
ws_id,
|
||||
AckEvent(
|
||||
ws_id=ws_id,
|
||||
correlation_id=msg.correlation_id,
|
||||
status="ok" if data.get("status") == "ok" else "error",
|
||||
detail=data.get("error", ""),
|
||||
),
|
||||
)
|
||||
|
||||
def _handle_command(self, msg: InboundMessage) -> None:
|
||||
ws_id = getattr(msg, "ws_id", "")
|
||||
command = getattr(msg, "command", "")
|
||||
@@ -371,6 +388,7 @@ class Bridge:
|
||||
initial_message = getattr(msg, "initial_message", "")
|
||||
resume_ws = getattr(msg, "resume_ws", "")
|
||||
user_id = getattr(msg, "user_id", "")
|
||||
template = getattr(msg, "template", "")
|
||||
if user_id:
|
||||
log.info("bridge.create_ws user_id=%s name=%s model=%s", user_id, name, model)
|
||||
ws_id, resumed = self._create_ws_on_server(
|
||||
@@ -380,6 +398,7 @@ class Bridge:
|
||||
correlation_id=msg.correlation_id,
|
||||
model=model,
|
||||
resume_ws=resume_ws,
|
||||
template=template,
|
||||
)
|
||||
# Send initial_message only when no workstream was actually resumed.
|
||||
# Use the server's `resumed` response (not just the intent) so that
|
||||
@@ -447,6 +466,7 @@ class Bridge:
|
||||
correlation_id: str,
|
||||
model: str = "",
|
||||
resume_ws: str = "",
|
||||
template: str = "",
|
||||
) -> tuple[str, bool]:
|
||||
"""Create a workstream on the server. Returns (ws_id, resumed)."""
|
||||
try:
|
||||
@@ -455,6 +475,8 @@ class Bridge:
|
||||
payload["model"] = model
|
||||
if resume_ws:
|
||||
payload["resume_ws"] = resume_ws
|
||||
if template:
|
||||
payload["template"] = template
|
||||
resp = self._http.post(
|
||||
"/v1/api/workstreams/new",
|
||||
json=payload,
|
||||
@@ -694,6 +716,11 @@ class Bridge:
|
||||
def _wait_plan() -> None:
|
||||
try:
|
||||
raw_resp = self._broker.pop_response(request_id, timeout=self._approval_timeout)
|
||||
# Clear pending entry *before* posting response so that
|
||||
# a subsequent plan review event (from the refinement
|
||||
# loop) is not skipped by the duplicate guard.
|
||||
with self._lock:
|
||||
self._pending_plan_reviews.pop(ws_id, None)
|
||||
if raw_resp:
|
||||
resp_msg = InboundMessage.from_json(raw_resp)
|
||||
feedback = getattr(resp_msg, "feedback", "")
|
||||
@@ -701,9 +728,16 @@ class Bridge:
|
||||
else:
|
||||
log.warning("Plan review timeout for ws %s — rejecting", ws_id)
|
||||
self._http.post("/v1/api/plan", json={"feedback": "reject", "ws_id": ws_id})
|
||||
finally:
|
||||
except Exception:
|
||||
with self._lock:
|
||||
self._pending_plan_reviews.pop(ws_id, None)
|
||||
# Best-effort rejection so the server doesn't hang
|
||||
with contextlib.suppress(Exception):
|
||||
self._http.post(
|
||||
"/v1/api/plan",
|
||||
json={"feedback": "reject", "ws_id": ws_id},
|
||||
)
|
||||
raise
|
||||
|
||||
threading.Thread(target=self._run_in_context(_wait_plan), daemon=True).start()
|
||||
|
||||
@@ -760,12 +794,13 @@ class Bridge:
|
||||
)
|
||||
)
|
||||
|
||||
# Completion detection
|
||||
# Completion detection — emit for all idle transitions so
|
||||
# channel adapters can finalize streaming messages even when
|
||||
# the turn was initiated from the server UI (no correlation_id).
|
||||
if state == "idle":
|
||||
with self._lock:
|
||||
cid = self._active_sends.pop(ws_id, None)
|
||||
if cid:
|
||||
self._publish_ws(ws_id, TurnCompleteEvent(ws_id=ws_id, correlation_id=cid))
|
||||
self._publish_ws(ws_id, TurnCompleteEvent(ws_id=ws_id, correlation_id=cid or ""))
|
||||
|
||||
elif etype == "ws_rename":
|
||||
self._publish_global(WorkstreamRenameEvent(ws_id=ws_id, name=data.get("name", "")))
|
||||
|
||||
@@ -126,6 +126,7 @@ class TurnstoneClient:
|
||||
auto_approve_tools: list[str] | None = None,
|
||||
target_node: str = "",
|
||||
initial_message: str = "",
|
||||
template: str = "",
|
||||
) -> str:
|
||||
"""Create a workstream. Returns correlation_id."""
|
||||
msg = CreateWorkstreamMessage(
|
||||
@@ -134,6 +135,7 @@ class TurnstoneClient:
|
||||
auto_approve_tools=auto_approve_tools or [],
|
||||
target_node=target_node,
|
||||
initial_message=initial_message,
|
||||
template=template,
|
||||
)
|
||||
self._broker.push_inbound(msg.to_json(), node_id=target_node)
|
||||
return msg.correlation_id
|
||||
|
||||
@@ -97,6 +97,7 @@ class CreateWorkstreamMessage(InboundMessage):
|
||||
initial_message: str = ""
|
||||
resume_ws: str = ""
|
||||
user_id: str = ""
|
||||
template: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -128,6 +129,14 @@ class ListNodesMessage(InboundMessage):
|
||||
type: str = "list_nodes"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CancelMessage(InboundMessage):
|
||||
"""Cancel the active generation in a workstream."""
|
||||
|
||||
type: str = "cancel"
|
||||
ws_id: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outbound events (bridge → client)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -256,7 +265,8 @@ class TurnCompleteEvent(OutboundEvent):
|
||||
"""Emitted when a workstream finishes processing (returns to IDLE).
|
||||
|
||||
This is a synthetic event produced by the bridge when it detects
|
||||
the ws_state transition to 'idle' after a send.
|
||||
the ws_state transition to 'idle'. ``correlation_id`` is set for
|
||||
MQ-initiated turns and empty for turns initiated from the server UI.
|
||||
"""
|
||||
|
||||
type: str = "turn_complete"
|
||||
@@ -383,6 +393,7 @@ _INBOUND_REGISTRY: dict[str, type[InboundMessage]] = {
|
||||
ListWorkstreamsMessage,
|
||||
HealthMessage,
|
||||
ListNodesMessage,
|
||||
CancelMessage,
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
+351
-1
@@ -20,7 +20,18 @@ from turnstone.api.console_schemas import (
|
||||
ClusterWorkstreamsResponse,
|
||||
ConsoleCreateWsResponse,
|
||||
ConsoleHealthResponse,
|
||||
ListAuditEventsResponse,
|
||||
ListOrgsResponse,
|
||||
ListPromptTemplatesResponse,
|
||||
ListRolesResponse,
|
||||
ListToolPoliciesResponse,
|
||||
ListUserRolesResponse,
|
||||
NodeDetailResponse,
|
||||
OrgInfo,
|
||||
PromptTemplateInfo,
|
||||
RoleInfo,
|
||||
ToolPolicyInfo,
|
||||
UsageResponse,
|
||||
)
|
||||
from turnstone.api.schemas import (
|
||||
AuthLoginResponse,
|
||||
@@ -115,6 +126,7 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
name: str = "",
|
||||
model: str = "",
|
||||
initial_message: str = "",
|
||||
template: str = "",
|
||||
) -> ConsoleCreateWsResponse:
|
||||
body: dict[str, Any] = {}
|
||||
if node_id:
|
||||
@@ -125,6 +137,8 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
body["model"] = model
|
||||
if initial_message:
|
||||
body["initial_message"] = initial_message
|
||||
if template:
|
||||
body["template"] = template
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/v1/api/cluster/workstreams/new",
|
||||
@@ -293,6 +307,211 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
response_model=ListScheduleRunsResponse,
|
||||
)
|
||||
|
||||
# -- governance: roles ---------------------------------------------------
|
||||
|
||||
async def list_roles(self) -> ListRolesResponse:
|
||||
"""List all roles."""
|
||||
return await self._request("GET", "/v1/api/admin/roles", response_model=ListRolesResponse)
|
||||
|
||||
async def create_role(
|
||||
self, name: str, display_name: str = "", permissions: str = "read"
|
||||
) -> RoleInfo:
|
||||
"""Create a custom role."""
|
||||
body: dict[str, Any] = {"name": name, "permissions": permissions}
|
||||
if display_name:
|
||||
body["display_name"] = display_name
|
||||
return await self._request(
|
||||
"POST", "/v1/api/admin/roles", json_body=body, response_model=RoleInfo
|
||||
)
|
||||
|
||||
async def update_role(self, role_id: str, **fields: Any) -> RoleInfo:
|
||||
"""Update a role's display_name and/or permissions."""
|
||||
return await self._request(
|
||||
"PUT", f"/v1/api/admin/roles/{role_id}", json_body=fields, response_model=RoleInfo
|
||||
)
|
||||
|
||||
async def delete_role(self, role_id: str) -> StatusResponse:
|
||||
"""Delete a custom role."""
|
||||
return await self._request(
|
||||
"DELETE", f"/v1/api/admin/roles/{role_id}", response_model=StatusResponse
|
||||
)
|
||||
|
||||
async def list_user_roles(self, user_id: str) -> ListUserRolesResponse:
|
||||
"""List roles assigned to a user."""
|
||||
return await self._request(
|
||||
"GET", f"/v1/api/admin/users/{user_id}/roles", response_model=ListUserRolesResponse
|
||||
)
|
||||
|
||||
async def assign_role(self, user_id: str, role_id: str) -> StatusResponse:
|
||||
"""Assign a role to a user."""
|
||||
return await self._request(
|
||||
"POST",
|
||||
f"/v1/api/admin/users/{user_id}/roles",
|
||||
json_body={"role_id": role_id},
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
async def unassign_role(self, user_id: str, role_id: str) -> StatusResponse:
|
||||
"""Unassign a role from a user."""
|
||||
return await self._request(
|
||||
"DELETE",
|
||||
f"/v1/api/admin/users/{user_id}/roles/{role_id}",
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
# -- governance: organizations -------------------------------------------
|
||||
|
||||
async def list_orgs(self) -> ListOrgsResponse:
|
||||
"""List organizations."""
|
||||
return await self._request("GET", "/v1/api/admin/orgs", response_model=ListOrgsResponse)
|
||||
|
||||
async def get_org(self, org_id: str) -> OrgInfo:
|
||||
"""Get organization details."""
|
||||
return await self._request("GET", f"/v1/api/admin/orgs/{org_id}", response_model=OrgInfo)
|
||||
|
||||
async def update_org(self, org_id: str, **fields: Any) -> OrgInfo:
|
||||
"""Update organization settings."""
|
||||
return await self._request(
|
||||
"PUT", f"/v1/api/admin/orgs/{org_id}", json_body=fields, response_model=OrgInfo
|
||||
)
|
||||
|
||||
# -- governance: tool policies -------------------------------------------
|
||||
|
||||
async def list_policies(self) -> ListToolPoliciesResponse:
|
||||
"""List tool policies ordered by priority."""
|
||||
return await self._request(
|
||||
"GET", "/v1/api/admin/policies", response_model=ListToolPoliciesResponse
|
||||
)
|
||||
|
||||
async def create_policy(
|
||||
self,
|
||||
name: str,
|
||||
tool_pattern: str,
|
||||
action: str,
|
||||
priority: int = 0,
|
||||
**kwargs: Any,
|
||||
) -> ToolPolicyInfo:
|
||||
"""Create a tool policy."""
|
||||
body: dict[str, Any] = {
|
||||
"name": name,
|
||||
"tool_pattern": tool_pattern,
|
||||
"action": action,
|
||||
"priority": priority,
|
||||
**kwargs,
|
||||
}
|
||||
return await self._request(
|
||||
"POST", "/v1/api/admin/policies", json_body=body, response_model=ToolPolicyInfo
|
||||
)
|
||||
|
||||
async def update_policy(self, policy_id: str, **fields: Any) -> ToolPolicyInfo:
|
||||
"""Update a tool policy."""
|
||||
return await self._request(
|
||||
"PUT",
|
||||
f"/v1/api/admin/policies/{policy_id}",
|
||||
json_body=fields,
|
||||
response_model=ToolPolicyInfo,
|
||||
)
|
||||
|
||||
async def delete_policy(self, policy_id: str) -> StatusResponse:
|
||||
"""Delete a tool policy."""
|
||||
return await self._request(
|
||||
"DELETE", f"/v1/api/admin/policies/{policy_id}", response_model=StatusResponse
|
||||
)
|
||||
|
||||
# -- governance: prompt templates ----------------------------------------
|
||||
|
||||
async def list_templates(self) -> ListPromptTemplatesResponse:
|
||||
"""List prompt templates."""
|
||||
return await self._request(
|
||||
"GET", "/v1/api/admin/templates", response_model=ListPromptTemplatesResponse
|
||||
)
|
||||
|
||||
async def create_template(
|
||||
self,
|
||||
name: str,
|
||||
content: str,
|
||||
category: str = "general",
|
||||
variables: str = "[]",
|
||||
is_default: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> PromptTemplateInfo:
|
||||
"""Create a prompt template."""
|
||||
body: dict[str, Any] = {
|
||||
"name": name,
|
||||
"content": content,
|
||||
"category": category,
|
||||
"variables": variables,
|
||||
"is_default": is_default,
|
||||
**kwargs,
|
||||
}
|
||||
return await self._request(
|
||||
"POST", "/v1/api/admin/templates", json_body=body, response_model=PromptTemplateInfo
|
||||
)
|
||||
|
||||
async def update_template(self, template_id: str, **fields: Any) -> PromptTemplateInfo:
|
||||
"""Update a prompt template."""
|
||||
return await self._request(
|
||||
"PUT",
|
||||
f"/v1/api/admin/templates/{template_id}",
|
||||
json_body=fields,
|
||||
response_model=PromptTemplateInfo,
|
||||
)
|
||||
|
||||
async def delete_template(self, template_id: str) -> StatusResponse:
|
||||
"""Delete a prompt template."""
|
||||
return await self._request(
|
||||
"DELETE",
|
||||
f"/v1/api/admin/templates/{template_id}",
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
# -- governance: usage & audit -------------------------------------------
|
||||
|
||||
async def get_usage(
|
||||
self,
|
||||
since: str,
|
||||
until: str = "",
|
||||
user_id: str = "",
|
||||
model: str = "",
|
||||
group_by: str = "",
|
||||
) -> UsageResponse:
|
||||
"""Query aggregated usage data."""
|
||||
params: dict[str, Any] = {"since": since}
|
||||
if until:
|
||||
params["until"] = until
|
||||
if user_id:
|
||||
params["user_id"] = user_id
|
||||
if model:
|
||||
params["model"] = model
|
||||
if group_by:
|
||||
params["group_by"] = group_by
|
||||
return await self._request(
|
||||
"GET", "/v1/api/admin/usage", params=params, response_model=UsageResponse
|
||||
)
|
||||
|
||||
async def get_audit(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> ListAuditEventsResponse:
|
||||
"""Query paginated audit events."""
|
||||
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
||||
if action:
|
||||
params["action"] = action
|
||||
if user_id:
|
||||
params["user_id"] = user_id
|
||||
if since:
|
||||
params["since"] = since
|
||||
if until:
|
||||
params["until"] = until
|
||||
return await self._request(
|
||||
"GET", "/v1/api/admin/audit", params=params, response_model=ListAuditEventsResponse
|
||||
)
|
||||
|
||||
|
||||
class TurnstoneConsole:
|
||||
"""Synchronous client for the turnstone console API.
|
||||
@@ -358,10 +577,15 @@ class TurnstoneConsole:
|
||||
name: str = "",
|
||||
model: str = "",
|
||||
initial_message: str = "",
|
||||
template: str = "",
|
||||
) -> ConsoleCreateWsResponse:
|
||||
return self._runner.run(
|
||||
self._async.create_workstream(
|
||||
node_id=node_id, name=name, model=model, initial_message=initial_message
|
||||
node_id=node_id,
|
||||
name=name,
|
||||
model=model,
|
||||
initial_message=initial_message,
|
||||
template=template,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -469,6 +693,132 @@ class TurnstoneConsole:
|
||||
def list_schedule_runs(self, task_id: str, *, limit: int = 50) -> ListScheduleRunsResponse:
|
||||
return self._runner.run(self._async.list_schedule_runs(task_id, limit=limit))
|
||||
|
||||
# -- governance: roles ---------------------------------------------------
|
||||
|
||||
def list_roles(self) -> ListRolesResponse:
|
||||
return self._runner.run(self._async.list_roles())
|
||||
|
||||
def create_role(self, name: str, display_name: str = "", permissions: str = "read") -> RoleInfo:
|
||||
return self._runner.run(
|
||||
self._async.create_role(name, display_name=display_name, permissions=permissions)
|
||||
)
|
||||
|
||||
def update_role(self, role_id: str, **fields: Any) -> RoleInfo:
|
||||
return self._runner.run(self._async.update_role(role_id, **fields))
|
||||
|
||||
def delete_role(self, role_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.delete_role(role_id))
|
||||
|
||||
def list_user_roles(self, user_id: str) -> ListUserRolesResponse:
|
||||
return self._runner.run(self._async.list_user_roles(user_id))
|
||||
|
||||
def assign_role(self, user_id: str, role_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.assign_role(user_id, role_id))
|
||||
|
||||
def unassign_role(self, user_id: str, role_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.unassign_role(user_id, role_id))
|
||||
|
||||
# -- governance: organizations -------------------------------------------
|
||||
|
||||
def list_orgs(self) -> ListOrgsResponse:
|
||||
return self._runner.run(self._async.list_orgs())
|
||||
|
||||
def get_org(self, org_id: str) -> OrgInfo:
|
||||
return self._runner.run(self._async.get_org(org_id))
|
||||
|
||||
def update_org(self, org_id: str, **fields: Any) -> OrgInfo:
|
||||
return self._runner.run(self._async.update_org(org_id, **fields))
|
||||
|
||||
# -- governance: tool policies -------------------------------------------
|
||||
|
||||
def list_policies(self) -> ListToolPoliciesResponse:
|
||||
return self._runner.run(self._async.list_policies())
|
||||
|
||||
def create_policy(
|
||||
self,
|
||||
name: str,
|
||||
tool_pattern: str,
|
||||
action: str,
|
||||
priority: int = 0,
|
||||
**kwargs: Any,
|
||||
) -> ToolPolicyInfo:
|
||||
return self._runner.run(
|
||||
self._async.create_policy(name, tool_pattern, action, priority=priority, **kwargs)
|
||||
)
|
||||
|
||||
def update_policy(self, policy_id: str, **fields: Any) -> ToolPolicyInfo:
|
||||
return self._runner.run(self._async.update_policy(policy_id, **fields))
|
||||
|
||||
def delete_policy(self, policy_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.delete_policy(policy_id))
|
||||
|
||||
# -- governance: prompt templates ----------------------------------------
|
||||
|
||||
def list_templates(self) -> ListPromptTemplatesResponse:
|
||||
return self._runner.run(self._async.list_templates())
|
||||
|
||||
def create_template(
|
||||
self,
|
||||
name: str,
|
||||
content: str,
|
||||
category: str = "general",
|
||||
variables: str = "[]",
|
||||
is_default: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> PromptTemplateInfo:
|
||||
return self._runner.run(
|
||||
self._async.create_template(
|
||||
name,
|
||||
content,
|
||||
category=category,
|
||||
variables=variables,
|
||||
is_default=is_default,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
def update_template(self, template_id: str, **fields: Any) -> PromptTemplateInfo:
|
||||
return self._runner.run(self._async.update_template(template_id, **fields))
|
||||
|
||||
def delete_template(self, template_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.delete_template(template_id))
|
||||
|
||||
# -- governance: usage & audit -------------------------------------------
|
||||
|
||||
def get_usage(
|
||||
self,
|
||||
since: str,
|
||||
until: str = "",
|
||||
user_id: str = "",
|
||||
model: str = "",
|
||||
group_by: str = "",
|
||||
) -> UsageResponse:
|
||||
return self._runner.run(
|
||||
self._async.get_usage(
|
||||
since, until=until, user_id=user_id, model=model, group_by=group_by
|
||||
)
|
||||
)
|
||||
|
||||
def get_audit(
|
||||
self,
|
||||
action: str = "",
|
||||
user_id: str = "",
|
||||
since: str = "",
|
||||
until: str = "",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> ListAuditEventsResponse:
|
||||
return self._runner.run(
|
||||
self._async.get_audit(
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
since=since,
|
||||
until=until,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
)
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -94,6 +94,13 @@ class ApproveRequestEvent(ServerEvent):
|
||||
items: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApprovalResolvedEvent(ServerEvent):
|
||||
type: str = "approval_resolved"
|
||||
approved: bool = False
|
||||
feedback: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResultEvent(ServerEvent):
|
||||
type: str = "tool_result"
|
||||
@@ -149,6 +156,11 @@ class ClearUiEvent(ServerEvent):
|
||||
type: str = "clear_ui"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CancelledEvent(ServerEvent):
|
||||
type: str = "cancelled"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server global events (/v1/api/events/global)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -280,6 +292,7 @@ _SERVER_REGISTRY: dict[str, type[ServerEvent]] = {
|
||||
StreamEndEvent,
|
||||
ToolInfoEvent,
|
||||
ApproveRequestEvent,
|
||||
ApprovalResolvedEvent,
|
||||
ToolResultEvent,
|
||||
ToolOutputChunkEvent,
|
||||
StatusEvent,
|
||||
@@ -288,6 +301,7 @@ _SERVER_REGISTRY: dict[str, type[ServerEvent]] = {
|
||||
ErrorEvent,
|
||||
BusyErrorEvent,
|
||||
ClearUiEvent,
|
||||
CancelledEvent,
|
||||
WsStateEvent,
|
||||
WsActivityEvent,
|
||||
WsRenameEvent,
|
||||
|
||||
+20
-1
@@ -77,6 +77,7 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
model: str = "",
|
||||
auto_approve: bool = False,
|
||||
resume_ws: str = "",
|
||||
template: str = "",
|
||||
) -> CreateWorkstreamResponse:
|
||||
body: dict[str, Any] = {}
|
||||
if name:
|
||||
@@ -87,6 +88,8 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
body["auto_approve"] = True
|
||||
if resume_ws:
|
||||
body["resume_ws"] = resume_ws
|
||||
if template:
|
||||
body["template"] = template
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/v1/api/workstreams/new",
|
||||
@@ -145,6 +148,14 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
async def cancel(self, ws_id: str) -> StatusResponse:
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/v1/api/cancel",
|
||||
json_body={"ws_id": ws_id},
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
# -- streaming -----------------------------------------------------------
|
||||
|
||||
async def stream_events(self, ws_id: str) -> AsyncIterator[ServerEvent]:
|
||||
@@ -310,10 +321,15 @@ class TurnstoneServer:
|
||||
model: str = "",
|
||||
auto_approve: bool = False,
|
||||
resume_ws: str = "",
|
||||
template: str = "",
|
||||
) -> CreateWorkstreamResponse:
|
||||
return self._runner.run(
|
||||
self._async.create_workstream(
|
||||
name=name, model=model, auto_approve=auto_approve, resume_ws=resume_ws
|
||||
name=name,
|
||||
model=model,
|
||||
auto_approve=auto_approve,
|
||||
resume_ws=resume_ws,
|
||||
template=template,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -343,6 +359,9 @@ class TurnstoneServer:
|
||||
def command(self, *, ws_id: str, command: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.command(ws_id=ws_id, command=command))
|
||||
|
||||
def cancel(self, ws_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.cancel(ws_id))
|
||||
|
||||
# -- streaming -----------------------------------------------------------
|
||||
|
||||
def stream_events(self, ws_id: str) -> Iterator[ServerEvent]:
|
||||
|
||||
+162
-4
@@ -43,7 +43,7 @@ from turnstone.api.server_spec import build_server_spec
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, AuthMiddleware
|
||||
from turnstone.core.metrics import metrics as _metrics
|
||||
from turnstone.core.ratelimit import resolve_client_ip
|
||||
from turnstone.core.session import ChatSession, SessionUI # noqa: F401
|
||||
from turnstone.core.session import ChatSession, GenerationCancelled, SessionUI # noqa: F401
|
||||
from turnstone.core.tools import TOOLS # noqa: F401 — available for introspection
|
||||
from turnstone.core.workstream import Workstream, WorkstreamManager, WorkstreamState
|
||||
|
||||
@@ -80,8 +80,9 @@ class WebUI:
|
||||
_global_queue: queue.Queue[dict[str, Any]] | None = None
|
||||
_workstream_mgr: WorkstreamManager | None = None
|
||||
|
||||
def __init__(self, ws_id: str = "") -> None:
|
||||
def __init__(self, ws_id: str = "", user_id: str = "") -> None:
|
||||
self.ws_id = ws_id
|
||||
self._user_id = user_id
|
||||
self._listeners: list[queue.Queue[dict[str, Any]]] = []
|
||||
self._listeners_lock = threading.Lock()
|
||||
self._approval_event = threading.Event()
|
||||
@@ -96,6 +97,7 @@ class WebUI:
|
||||
self._ws_completion_tokens: int = 0
|
||||
self._ws_messages: int = 0
|
||||
self._ws_tool_calls: dict[str, int] = {}
|
||||
self._ws_tool_calls_reported: int = 0 # last cumulative total sent to usage
|
||||
self._ws_context_ratio: float = 0.0
|
||||
# Activity tracking for dashboard (current tool / thinking / approval)
|
||||
self._ws_current_activity: str = ""
|
||||
@@ -198,6 +200,59 @@ class WebUI:
|
||||
}
|
||||
)
|
||||
|
||||
# -- Tool policy evaluation -----------------------------------------------
|
||||
# Check admin-defined tool policies before the auto_approve check.
|
||||
if pending:
|
||||
try:
|
||||
from turnstone.core.policy import evaluate_tool_policies_batch
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
if storage is not None:
|
||||
tool_names = [
|
||||
it.get("approval_label", "") or it.get("func_name", "")
|
||||
for it in pending
|
||||
if it.get("func_name")
|
||||
]
|
||||
if tool_names:
|
||||
verdicts = evaluate_tool_policies_batch(storage, tool_names)
|
||||
still_pending = []
|
||||
for it in pending:
|
||||
policy_name = it.get("approval_label", "") or it.get("func_name", "")
|
||||
verdict = verdicts.get(policy_name)
|
||||
if verdict == "deny":
|
||||
it["denied"] = True
|
||||
it["denial_msg"] = (
|
||||
f"Blocked by tool policy (pattern match for '{policy_name}')"
|
||||
)
|
||||
elif verdict == "allow":
|
||||
it["needs_approval"] = False
|
||||
else:
|
||||
still_pending.append(it)
|
||||
# Rebuild serialized to reflect policy verdicts
|
||||
serialized = [
|
||||
{
|
||||
"call_id": it.get("call_id", ""),
|
||||
"header": it.get("header", ""),
|
||||
"preview": it.get("preview", ""),
|
||||
"func_name": it.get("func_name", ""),
|
||||
"approval_label": it.get("approval_label", it.get("func_name", "")),
|
||||
"needs_approval": it.get("needs_approval", False),
|
||||
"error": it.get("denial_msg") if it.get("denied") else None,
|
||||
}
|
||||
for it in items
|
||||
]
|
||||
# If all were resolved by policy, check if any were denied
|
||||
if not still_pending:
|
||||
any_denied = any(it.get("denied") for it in items)
|
||||
if any_denied:
|
||||
self._enqueue({"type": "tool_info", "items": serialized})
|
||||
return False, "Blocked by tool policy"
|
||||
pending = still_pending
|
||||
except Exception:
|
||||
log.debug("Tool policy evaluation failed", exc_info=True)
|
||||
# -- End tool policy evaluation -------------------------------------------
|
||||
|
||||
if not pending or self.auto_approve:
|
||||
# Track auto-approved tool activity
|
||||
first = items[0] if items else {}
|
||||
@@ -258,6 +313,9 @@ class WebUI:
|
||||
self._ws_prompt_tokens += usage["prompt_tokens"]
|
||||
self._ws_completion_tokens += usage["completion_tokens"]
|
||||
self._ws_context_ratio = total_tok / context_window if context_window > 0 else 0.0
|
||||
tool_total = sum(self._ws_tool_calls.values())
|
||||
tool_count = tool_total - self._ws_tool_calls_reported
|
||||
self._ws_tool_calls_reported = tool_total
|
||||
self._enqueue(
|
||||
{
|
||||
"type": "status",
|
||||
@@ -269,6 +327,26 @@ class WebUI:
|
||||
"effort": effort,
|
||||
}
|
||||
)
|
||||
# Record usage event for governance dashboard
|
||||
try:
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
if storage is not None:
|
||||
import uuid
|
||||
|
||||
storage.record_usage_event(
|
||||
event_id=uuid.uuid4().hex,
|
||||
user_id=self._user_id,
|
||||
ws_id=self.ws_id,
|
||||
node_id="",
|
||||
model=usage.get("model", ""),
|
||||
prompt_tokens=usage["prompt_tokens"],
|
||||
completion_tokens=usage["completion_tokens"],
|
||||
tool_calls_count=tool_count,
|
||||
)
|
||||
except Exception:
|
||||
pass # Non-critical — never break the response pipeline
|
||||
|
||||
def on_plan_review(self, content: str) -> str:
|
||||
self._plan_event.clear()
|
||||
@@ -300,8 +378,17 @@ class WebUI:
|
||||
WebUI._global_queue.put({"type": "ws_rename", "ws_id": self.ws_id, "name": name})
|
||||
|
||||
def resolve_approval(self, approved: bool, feedback: str | None = None) -> None:
|
||||
"""Called by the HTTP handler when the user approves/denies."""
|
||||
"""Resolve a pending approval, whether triggered by the HTTP handler
|
||||
(user approves/denies in the browser) or by server-initiated flows
|
||||
such as cancellations or timeouts."""
|
||||
self._approval_result = (approved, feedback)
|
||||
self._enqueue(
|
||||
{
|
||||
"type": "approval_resolved",
|
||||
"approved": approved,
|
||||
"feedback": feedback or "",
|
||||
}
|
||||
)
|
||||
self._approval_event.set()
|
||||
|
||||
def resolve_plan(self, feedback: str) -> None:
|
||||
@@ -681,6 +768,13 @@ async def health(request: Request) -> JSONResponse:
|
||||
"circuit_state": monitor.circuit_state.value if monitor else "closed",
|
||||
},
|
||||
}
|
||||
mc = getattr(request.app.state, "mcp_client", None)
|
||||
if mc:
|
||||
data["mcp"] = {
|
||||
"servers": mc.server_count,
|
||||
"resources": mc.resource_count,
|
||||
"prompts": mc.prompt_count,
|
||||
}
|
||||
return JSONResponse(data)
|
||||
|
||||
|
||||
@@ -704,10 +798,19 @@ async def metrics_endpoint(request: Request) -> Response:
|
||||
"context_ratio": ui._ws_context_ratio,
|
||||
}
|
||||
)
|
||||
mcp_info = None
|
||||
mc = getattr(request.app.state, "mcp_client", None)
|
||||
if mc:
|
||||
mcp_info = {
|
||||
"servers": mc.server_count,
|
||||
"resources": mc.resource_count,
|
||||
"prompts": mc.prompt_count,
|
||||
}
|
||||
content = _metrics.generate_text(
|
||||
workstream_states=states,
|
||||
total_workstreams=len(wss),
|
||||
workstream_metrics=ws_data,
|
||||
mcp_info=mcp_info,
|
||||
)
|
||||
return Response(content, media_type="text/plain; version=0.0.4; charset=utf-8")
|
||||
|
||||
@@ -771,6 +874,10 @@ async def send_message(request: Request) -> JSONResponse:
|
||||
assert ui is not None
|
||||
try:
|
||||
session.send(message)
|
||||
except GenerationCancelled:
|
||||
# Safety net — send() normally handles this internally.
|
||||
ui._enqueue({"type": "stream_end"})
|
||||
ui.on_state_change("idle")
|
||||
except Exception as e:
|
||||
ui.on_error(f"Error: {e}")
|
||||
ui._enqueue({"type": "stream_end"})
|
||||
@@ -823,6 +930,33 @@ async def plan_feedback(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
async def cancel_generation(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/cancel — cancel the active generation in a workstream."""
|
||||
from turnstone.core.web_helpers import read_json_or_400
|
||||
|
||||
body = await read_json_or_400(request)
|
||||
if isinstance(body, JSONResponse):
|
||||
return body
|
||||
ws_id = body.get("ws_id")
|
||||
mgr = request.app.state.workstreams
|
||||
ws, ui = _get_ws(mgr, ws_id)
|
||||
if not ws or not ui:
|
||||
return JSONResponse({"error": "Unknown workstream"}, status_code=404)
|
||||
session = ws.session
|
||||
if session is None:
|
||||
return JSONResponse({"error": "No session"}, status_code=400)
|
||||
# Only act if generation is actually in progress
|
||||
if ws.worker_thread and ws.worker_thread.is_alive():
|
||||
# Set the cooperative cancel flag (worker thread checks at checkpoints)
|
||||
session.cancel()
|
||||
# Unblock any pending approval/plan review waits
|
||||
ui.resolve_approval(False, "Cancelled by user")
|
||||
ui.resolve_plan("reject")
|
||||
# Emit cancelled SSE event so SDK consumers get a typed signal
|
||||
ui._enqueue({"type": "cancelled"})
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
async def command(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/command — execute a slash command."""
|
||||
from turnstone.core.web_helpers import read_json_or_400
|
||||
@@ -877,10 +1011,13 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
return body
|
||||
mgr: WorkstreamManager = request.app.state.workstreams
|
||||
skip: bool = request.app.state.skip_permissions
|
||||
auth = getattr(getattr(request, "state", None), "auth_result", None)
|
||||
uid: str = getattr(auth, "user_id", "") or ""
|
||||
body_template = body.get("template", "")
|
||||
try:
|
||||
ws = mgr.create(
|
||||
name=body.get("name", ""),
|
||||
ui_factory=lambda wid: WebUI(ws_id=wid),
|
||||
ui_factory=lambda wid: WebUI(ws_id=wid, user_id=uid),
|
||||
model=body.get("model") or None,
|
||||
)
|
||||
assert isinstance(ws.ui, WebUI)
|
||||
@@ -924,6 +1061,19 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
|
||||
# Per-workstream template override — only when not resumed (resumed
|
||||
# workstreams restore their own template from workstream_config).
|
||||
if body_template and not resumed and ws.session:
|
||||
from turnstone.core.memory import get_prompt_template_by_name
|
||||
|
||||
if not get_prompt_template_by_name(body_template):
|
||||
# Workstream already created — close it and return error
|
||||
mgr.close(ws.id)
|
||||
return JSONResponse(
|
||||
{"error": f"Template not found: {body_template}"}, status_code=400
|
||||
)
|
||||
ws.session.set_template(body_template)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"ws_id": ws.id,
|
||||
@@ -1165,6 +1315,7 @@ def create_app(
|
||||
Route("/api/approve", approve, methods=["POST"]),
|
||||
Route("/api/plan", plan_feedback, methods=["POST"]),
|
||||
Route("/api/command", command, methods=["POST"]),
|
||||
Route("/api/cancel", cancel_generation, methods=["POST"]),
|
||||
Route("/api/workstreams/new", create_workstream, methods=["POST"]),
|
||||
Route("/api/workstreams/close", close_workstream, methods=["POST"]),
|
||||
Route("/api/watches", list_watches),
|
||||
@@ -1239,6 +1390,11 @@ def main() -> None:
|
||||
default=None,
|
||||
help="Developer instructions injected as developer message",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--template",
|
||||
default=None,
|
||||
help="Prompt template name (replaces default templates)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--temperature",
|
||||
type=float,
|
||||
@@ -1583,6 +1739,7 @@ def main() -> None:
|
||||
tool_search=args.tool_search,
|
||||
tool_search_threshold=args.tool_search_threshold,
|
||||
tool_search_max_results=args.tool_search_max_results,
|
||||
template=args.template,
|
||||
)
|
||||
|
||||
# Create WatchRunner (periodic command polling, server-level)
|
||||
@@ -1696,6 +1853,7 @@ def main() -> None:
|
||||
mcp_tools = mcp_client.get_tools()
|
||||
if mcp_tools:
|
||||
log.info("MCP tools: %d from %d server(s)", len(mcp_tools), mcp_client.server_count)
|
||||
mcp_client.set_storage(get_storage())
|
||||
log.info(
|
||||
"Health monitor: probe every %ss, circuit breaker threshold=%s",
|
||||
args.health_probe_interval,
|
||||
|
||||
@@ -274,8 +274,9 @@ function _submitLogin() {
|
||||
if (!r.ok) throw new Error("server");
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
.then(function (data) {
|
||||
_setBusy(false);
|
||||
_storePermissions(data);
|
||||
_onSuccess();
|
||||
})
|
||||
.catch(function (err) {
|
||||
@@ -306,8 +307,9 @@ function _submitToken() {
|
||||
if (!r.ok) throw new Error("server");
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
.then(function (data) {
|
||||
_setBusy(false);
|
||||
_storePermissions(data);
|
||||
_onSuccess();
|
||||
})
|
||||
.catch(function (err) {
|
||||
@@ -369,8 +371,9 @@ function _submitSetup() {
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
.then(function (data) {
|
||||
_setBusy(false);
|
||||
_storePermissions(data);
|
||||
_onSuccess();
|
||||
})
|
||||
.catch(function (err) {
|
||||
@@ -379,6 +382,14 @@ function _submitSetup() {
|
||||
});
|
||||
}
|
||||
|
||||
function _storePermissions(data) {
|
||||
if (data && data.permissions) {
|
||||
sessionStorage.setItem("turnstone_permissions", data.permissions);
|
||||
} else {
|
||||
sessionStorage.removeItem("turnstone_permissions");
|
||||
}
|
||||
}
|
||||
|
||||
function _setBusy(busy, label) {
|
||||
_loginBusy = busy;
|
||||
var btn = document.getElementById("login-submit");
|
||||
@@ -403,6 +414,7 @@ function _onSuccess() {
|
||||
|
||||
function logout() {
|
||||
fetch("/v1/api/auth/logout", { method: "POST" }).then(function () {
|
||||
sessionStorage.removeItem("turnstone_permissions");
|
||||
if (typeof window.onLogout === "function") window.onLogout();
|
||||
showLogin();
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
--yellow: #fbbf24;
|
||||
--cyan: #67e8f9;
|
||||
--magenta: #c084fc;
|
||||
--on-color: var(--bg);
|
||||
|
||||
/* Glow variants for LED effects */
|
||||
--green-glow: rgba(52, 211, 153, 0.25);
|
||||
@@ -35,6 +36,7 @@
|
||||
--yellow-glow: rgba(251, 191, 36, 0.25);
|
||||
--accent-glow-strong: rgba(229, 160, 66, 0.3);
|
||||
--cyan-glow: rgba(103, 232, 249, 0.2);
|
||||
--magenta-glow: rgba(192, 132, 252, 0.25);
|
||||
|
||||
/* Structure */
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
@@ -66,11 +68,13 @@
|
||||
--yellow: #b45309;
|
||||
--cyan: #0e7490;
|
||||
--magenta: #7c3aed;
|
||||
--on-color: #ffffff;
|
||||
--green-glow: rgba(4, 120, 87, 0.25);
|
||||
--red-glow: rgba(220, 38, 38, 0.25);
|
||||
--yellow-glow: rgba(180, 83, 9, 0.25);
|
||||
--accent-glow-strong: rgba(140, 94, 27, 0.15);
|
||||
--cyan-glow: rgba(14, 116, 144, 0.2);
|
||||
--magenta-glow: rgba(124, 58, 237, 0.2);
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--border-strong: rgba(0, 0, 0, 0.12);
|
||||
--code-bg: #f0f1f5;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "read_resource",
|
||||
"description": "Read a resource from a connected MCP server by URI. Returns the resource content (text or base64-encoded binary). Use this to access data, files, or content exposed by connected MCP servers. Available resource URIs are listed in your system context.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uri": {
|
||||
"type": "string",
|
||||
"description": "The resource URI to read (e.g. 'file:///path', 'db://table/row')."
|
||||
}
|
||||
},
|
||||
"required": ["uri"]
|
||||
},
|
||||
"agent": true,
|
||||
"task_agent": true,
|
||||
"auto_approve": false,
|
||||
"primary_key": "uri"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user