mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b180770eff | |||
| 9d2e11f2be | |||
| 57080f4615 | |||
| 45f27fb2a7 | |||
| ebc8e75285 | |||
| 485af92f7f | |||
| 664d44c109 | |||
| 3cf9485169 | |||
| d43b9d1647 | |||
| ea8d9d1798 | |||
| d9aa50dca9 | |||
| 6f89d0cc13 | |||
| 62d2a0fe6a | |||
| 5df37f83a7 | |||
| 651c4d98cd | |||
| e901e859c7 | |||
| 200dcfeac5 | |||
| 8c414feba2 | |||
| d7cea053b6 | |||
| c45e98462b | |||
| e17cbe35a5 | |||
| fd47c23177 | |||
| 9fe988b1be | |||
| 3ce66960bc | |||
| 8a852a12e3 | |||
| 9a518657a3 | |||
| c424176c73 |
+34
-14
@@ -1,29 +1,49 @@
|
||||
# =============================================================================
|
||||
# Turnstone Environment Variables
|
||||
# Copy to .env and adjust values for your deployment
|
||||
# Copy to .env and adjust values for your deployment.
|
||||
#
|
||||
# Usage:
|
||||
# Single node: docker compose --profile production up
|
||||
# 10-node cluster: docker compose --profile cluster up
|
||||
# =============================================================================
|
||||
|
||||
# -- LLM Backend --------------------------------------------------------------
|
||||
LLM_BASE_URL=http://host.docker.internal:8000/v1
|
||||
OPENAI_API_KEY=sk-...
|
||||
# ANTHROPIC_API_KEY=sk-ant-... # Set instead for Anthropic provider
|
||||
# TAVILY_API_KEY=tvly-... # For web search fallback (local models only)
|
||||
OPENAI_API_KEY=dummy
|
||||
# ANTHROPIC_API_KEY=sk-ant-...# Set instead of OPENAI_API_KEY for Anthropic
|
||||
# TAVILY_API_KEY=tvly-... # Web search fallback (local models only)
|
||||
# MODEL=# Override default model alias
|
||||
|
||||
# -- Database (production profile) --------------------------------------------
|
||||
# -- Authentication (required) ------------------------------------------------
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
TURNSTONE_JWT_SECRET=changeme-to-32-bytes-of-hex
|
||||
|
||||
# -- Database ------------------------------------------------------------------
|
||||
# Single-node default is SQLite (zero config). Set these for PostgreSQL:
|
||||
# DB_BACKEND=postgresql
|
||||
# POSTGRES_USER=turnstone
|
||||
# POSTGRES_PASSWORD=changeme
|
||||
# DATABASE_URL=postgresql+psycopg://turnstone:changeme@postgres:5432/turnstone
|
||||
|
||||
# -- Redis ---------------------------------------------------------------------
|
||||
# REDIS_PASSWORD=
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# -- Authentication ------------------------------------------------------------
|
||||
# TURNSTONE_AUTH_ENABLED=true
|
||||
# TURNSTONE_AUTH_TOKEN=your-secret-token
|
||||
# TURNSTONE_JWT_SECRET=python -c "import secrets; print(secrets.token_hex(32))"
|
||||
|
||||
# -- Ports ---------------------------------------------------------------------
|
||||
# SERVER_PORT=8080
|
||||
# CONSOLE_PORT=8090
|
||||
|
||||
# -- Workspace -----------------------------------------------------------------
|
||||
# Bind-mount a host directory into the container at /workspace.
|
||||
# The model can read/write files here. Default: empty Docker volume.
|
||||
# WORKSPACE_MOUNT=/path/to/your/project
|
||||
|
||||
# -- Agent behavior ------------------------------------------------------------
|
||||
# SKIP_PERMISSIONS=true # Auto-approve all tool calls (dev only)
|
||||
# MCP_CONFIG=/workspace/mcp.json# MCP server configuration file
|
||||
|
||||
# -- Discord channel gateway ---------------------------------------------------
|
||||
# TURNSTONE_DISCORD_TOKEN=
|
||||
# TURNSTONE_DISCORD_GUILD=0
|
||||
|
||||
# -- Cluster (profile: cluster) -----------------------------------------------
|
||||
# These are set per-node in compose.yaml; only override for custom topologies.
|
||||
# TURNSTONE_NODE_ID=node-1
|
||||
# TURNSTONE_ADVERTISE_URL=http://server-1:8080
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
{
|
||||
"description": "Infrastructure dependencies",
|
||||
"groupName": "Infrastructure",
|
||||
"matchPackageNames": ["structlog", "redis", "croniter", "discord.py"],
|
||||
"matchPackageNames": ["structlog", "croniter", "discord.py"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": ["patch"]
|
||||
@@ -101,7 +101,6 @@
|
||||
"matchPackageNames": [
|
||||
"ruff",
|
||||
"mypy",
|
||||
"types-redis",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pre-commit"
|
||||
|
||||
@@ -2,9 +2,10 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, "stable/*"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, "stable/*"]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -25,8 +26,8 @@ jobs:
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: pip install mypy types-redis
|
||||
- run: pip install -e ".[mq]"
|
||||
- run: pip install mypy
|
||||
- run: pip install -e ".[all]"
|
||||
- run: mypy turnstone/
|
||||
|
||||
test:
|
||||
@@ -39,7 +40,7 @@ jobs:
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: pip install -e ".[test,mq]"
|
||||
- run: pip install -e ".[test]"
|
||||
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
if: always()
|
||||
@@ -68,7 +69,7 @@ jobs:
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: pip install -e ".[test,mq,postgres]"
|
||||
- run: pip install -e ".[test,postgres]"
|
||||
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
|
||||
env:
|
||||
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release tag
|
||||
id: tag
|
||||
run: |
|
||||
TAG=$(git tag --points-at HEAD | grep '^v' | head -1)
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "No v* tag at HEAD — skipping publish"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute Docker tags
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
id: tags
|
||||
env:
|
||||
REF: ${{ steps.tag.outputs.tag }}
|
||||
run: |
|
||||
VERSION="${REF#v}"
|
||||
FULL="${REGISTRY}/${IMAGE_NAME}"
|
||||
FULL="${FULL,,}"
|
||||
|
||||
if echo "$VERSION" | grep -qE '(a|b|rc)[0-9]+$'; then
|
||||
TAGS="${FULL}:${VERSION},${FULL}:experimental"
|
||||
else
|
||||
MINOR="${VERSION%.*}"
|
||||
TAGS="${FULL}:${VERSION},${FULL}:${MINOR},${FULL}:stable,${FULL}:latest"
|
||||
fi
|
||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
|
||||
- name: Build and push
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -2,7 +2,7 @@ name: Docker Security Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, "stable/*"]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly Monday 06:00 UTC
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Publish to PyPI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -10,20 +11,43 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release tag
|
||||
id: tag
|
||||
run: |
|
||||
TAG=$(git tag --points-at HEAD | grep '^v' | head -1)
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "No v* tag at HEAD — skipping publish"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: pip install build
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
- run: python -m build
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref, '-') }}
|
||||
prerelease: ${{ contains(steps.tag.outputs.tag, 'a') || contains(steps.tag.outputs.tag, 'b') || contains(steps.tag.outputs.tag, 'rc') }}
|
||||
|
||||
@@ -17,3 +17,24 @@ CVE-2026-27135
|
||||
# Affects libsystemd0, libudev1
|
||||
# https://avd.aquasec.com/nvd/cve-2026-29111
|
||||
CVE-2026-29111
|
||||
|
||||
# glibc iconv() DoS — fix_deferred, no patched libc in Debian 13 yet
|
||||
# Affects libc-bin, libc6
|
||||
# https://avd.aquasec.com/nvd/cve-2026-4046
|
||||
CVE-2026-4046
|
||||
|
||||
# minimatch ReDoS — transitive npm dep (MCP server), no direct exposure
|
||||
# https://avd.aquasec.com/nvd/cve-2026-27903
|
||||
CVE-2026-27903
|
||||
# https://avd.aquasec.com/nvd/cve-2026-27904
|
||||
CVE-2026-27904
|
||||
|
||||
# picomatch ReDoS — transitive npm dep, no direct exposure
|
||||
# https://avd.aquasec.com/nvd/cve-2026-33671
|
||||
CVE-2026-33671
|
||||
|
||||
# node-tar path traversal — transitive npm dep, not used to extract untrusted archives
|
||||
# https://avd.aquasec.com/nvd/cve-2026-29786
|
||||
CVE-2026-29786
|
||||
# https://avd.aquasec.com/nvd/cve-2026-31802
|
||||
CVE-2026-31802
|
||||
|
||||
+15
-6
@@ -8,7 +8,7 @@ FROM python:3.14-slim
|
||||
LABEL org.opencontainers.image.title="turnstone" \
|
||||
org.opencontainers.image.description="Multi-node AI orchestration platform"
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.2 /uv /usr/local/bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.3 /uv /usr/local/bin/uv
|
||||
|
||||
# Remove the slim image's man page exclusion so man-db has actual content
|
||||
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
|
||||
@@ -18,23 +18,29 @@ RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-reco
|
||||
libpq5 git curl jq man-db manpages procps file \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github)
|
||||
COPY --from=node:24-slim /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node:24-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
# Non-root user
|
||||
RUN useradd --create-home --shell /bin/bash turnstone
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Compile bytecode for faster startup
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
|
||||
# Install dependencies first (cached layer — only re-runs when deps change)
|
||||
COPY pyproject.toml uv.lock README.md LICENSE ./
|
||||
RUN uv sync --frozen --no-install-project --no-dev \
|
||||
--extra all
|
||||
--no-compile --extra all
|
||||
|
||||
# Install the project itself
|
||||
COPY turnstone/ turnstone/
|
||||
RUN uv sync --frozen --no-dev \
|
||||
--extra all
|
||||
--no-compile --extra all
|
||||
|
||||
# Compile bytecode in a separate step (avoids fd exhaustion during install)
|
||||
RUN python -m compileall -q .venv turnstone/
|
||||
|
||||
# Add venv to PATH so entry points are found
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
@@ -49,6 +55,9 @@ COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
WORKDIR /data
|
||||
RUN chown turnstone:turnstone /data
|
||||
|
||||
# Workspace mount point — bind-mount a host directory here
|
||||
RUN mkdir -p /workspace && chown turnstone:turnstone /workspace
|
||||
|
||||
USER turnstone
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
|
||||
@@ -7,10 +7,21 @@
|
||||
|
||||
Multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers with direct HTTP routing, interactive interfaces, and enterprise governance.
|
||||
|
||||
> **Beta — Use at your own risk.** APIs, configuration formats, and database schemas may change between versions without migration paths.
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero.png" alt="Turnstone console — multi-workstream AI orchestration with mermaid diagrams" width="960"/>
|
||||
</p>
|
||||
|
||||
Named after the [Ruddy Turnstone](https://en.wikipedia.org/wiki/Ruddy_turnstone) (*Arenaria interpres*) — a shorebird that flips stones to discover what's hiding underneath.
|
||||
|
||||
### Release Tracks
|
||||
|
||||
| Track | Install | Docker | Description |
|
||||
|-------|---------|--------|-------------|
|
||||
| **Stable** | `pip install turnstone` | `ghcr.io/turnstonelabs/turnstone:stable` | Production-grade. Bugfixes only. |
|
||||
| **Experimental** | `pip install turnstone --pre` | `ghcr.io/turnstonelabs/turnstone:experimental` | New features. May have rough edges. |
|
||||
|
||||
See [docs/releasing.md](docs/releasing.md) for the full release process.
|
||||
|
||||
## What it does
|
||||
|
||||
Turnstone gives LLMs tools — shell, files, search, web, planning — and orchestrates multi-turn conversations where the model investigates, acts, and reports.
|
||||
|
||||
+12
-55
@@ -6,7 +6,6 @@
|
||||
# Single node: docker compose --profile production up
|
||||
# Production (PG): DB_BACKEND=postgresql docker compose --profile production up
|
||||
# 10-node cluster: docker compose --profile cluster up
|
||||
# Cluster + DDG: docker compose --profile ddgCluster up
|
||||
# =============================================================================
|
||||
|
||||
name: turnstone
|
||||
@@ -17,6 +16,7 @@ networks:
|
||||
|
||||
volumes:
|
||||
turnstone-data:
|
||||
workspace:
|
||||
postgres-data:
|
||||
|
||||
services:
|
||||
@@ -28,7 +28,6 @@ services:
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
- ddgCluster
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
@@ -82,15 +81,14 @@ services:
|
||||
- "${SERVER_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ./docker/mcp-ddg.json:/etc/turnstone/mcp-ddg.json:ro
|
||||
- ${WORKSPACE_MOUNT:-workspace}:/workspace
|
||||
environment:
|
||||
- LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-dummy}
|
||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||
- SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-}
|
||||
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- MODEL=${MODEL:-}
|
||||
- MCP_CONFIG=${MCP_CONFIG:-}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
@@ -105,9 +103,6 @@ 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
|
||||
@@ -127,13 +122,11 @@ services:
|
||||
- turnstone-console
|
||||
- --host=0.0.0.0
|
||||
- --port=8090
|
||||
- --poll-interval=${CONSOLE_POLL_INTERVAL:-10}
|
||||
ports:
|
||||
- "${CONSOLE_PORT:-8090}:8090"
|
||||
environment:
|
||||
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-}
|
||||
- TURNSTONE_CONSOLE_URL=http://console:8090
|
||||
@@ -158,7 +151,6 @@ services:
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
- ddgCluster
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -169,8 +161,8 @@ services:
|
||||
environment:
|
||||
- TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-}
|
||||
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-postgresql}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
|
||||
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
|
||||
@@ -182,39 +174,6 @@ 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.14-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
|
||||
|
||||
# ===================================================================
|
||||
# 10-node cluster (profile: cluster)
|
||||
#
|
||||
@@ -229,7 +188,7 @@ services:
|
||||
server-1: &cluster-server
|
||||
image: turnstone:local
|
||||
build: { context: ., dockerfile: Dockerfile }
|
||||
profiles: [cluster, ddgCluster]
|
||||
profiles: [cluster]
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -244,15 +203,14 @@ services:
|
||||
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ./docker/mcp-ddg.json:/etc/turnstone/mcp-ddg.json:ro
|
||||
- ${WORKSPACE_MOUNT:-workspace}:/workspace
|
||||
environment: &cluster-server-env
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy}
|
||||
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
|
||||
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
|
||||
TURNSTONE_AUTH_ENABLED: ${TURNSTONE_AUTH_ENABLED:-}
|
||||
TURNSTONE_AUTH_TOKEN: ${TURNSTONE_AUTH_TOKEN:-}
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
MODEL: ${MODEL:-}
|
||||
MCP_CONFIG: ${MCP_CONFIG:-}
|
||||
TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql}
|
||||
@@ -263,7 +221,6 @@ services:
|
||||
networks: [turnstone-net]
|
||||
depends_on:
|
||||
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
|
||||
|
||||
@@ -36,13 +36,13 @@ spec:
|
||||
- secretRef:
|
||||
name: {{ include "turnstone.llm.secretName" . }}
|
||||
optional: true
|
||||
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
|
||||
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
|
||||
env:
|
||||
- name: TURNSTONE_AUTH_TOKEN
|
||||
- name: TURNSTONE_JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.auth.existingSecret }}
|
||||
key: TURNSTONE_AUTH_TOKEN
|
||||
name: {{ include "turnstone.auth.secretName" . }}
|
||||
key: TURNSTONE_JWT_SECRET
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
|
||||
@@ -41,12 +41,12 @@ spec:
|
||||
env:
|
||||
- name: TURNSTONE_DB_URL
|
||||
value: "postgresql+psycopg://$(TURNSTONE_DB_USER):$(POSTGRES_PASSWORD)@$(TURNSTONE_DB_HOST):$(TURNSTONE_DB_PORT)/$(TURNSTONE_DB_NAME)"
|
||||
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
|
||||
- name: TURNSTONE_AUTH_TOKEN
|
||||
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
|
||||
- name: TURNSTONE_JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.auth.existingSecret }}
|
||||
key: TURNSTONE_AUTH_TOKEN
|
||||
name: {{ include "turnstone.auth.secretName" . }}
|
||||
key: TURNSTONE_JWT_SECRET
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
|
||||
@@ -15,7 +15,7 @@ data:
|
||||
{{- else if and (not .Values.postgresql.enabled) .Values.database.external.password }}
|
||||
POSTGRES_PASSWORD: {{ .Values.database.external.password | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if and .Values.auth.enabled .Values.auth.token (not .Values.auth.existingSecret) }}
|
||||
TURNSTONE_AUTH_TOKEN: {{ .Values.auth.token | b64enc | quote }}
|
||||
{{- if and .Values.auth.jwtSecret (not .Values.auth.existingSecret) }}
|
||||
TURNSTONE_JWT_SECRET: {{ .Values.auth.jwtSecret | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -59,10 +59,9 @@ llm:
|
||||
apiKey: ""
|
||||
existingSecret: ""
|
||||
|
||||
# -- Authentication
|
||||
# -- Authentication (always enabled, JWT secret required)
|
||||
auth:
|
||||
enabled: false
|
||||
token: ""
|
||||
jwtSecret: ""
|
||||
existingSecret: ""
|
||||
|
||||
# -- Ingress configuration
|
||||
|
||||
@@ -40,8 +40,8 @@ resource "aws_iam_role_policy" "ecs_execution_secrets" {
|
||||
[
|
||||
aws_secretsmanager_secret.openai_api_key.arn,
|
||||
aws_secretsmanager_secret.db_password.arn,
|
||||
aws_secretsmanager_secret.jwt_secret.arn,
|
||||
],
|
||||
var.auth_token != "" ? [aws_secretsmanager_secret.auth_token[0].arn] : [],
|
||||
)
|
||||
},
|
||||
]
|
||||
|
||||
@@ -41,20 +41,26 @@ locals {
|
||||
},
|
||||
]
|
||||
|
||||
auth_env = var.auth_token != "" ? [
|
||||
{ name = "TURNSTONE_AUTH_ENABLED", value = "true" },
|
||||
] : []
|
||||
|
||||
auth_secrets = var.auth_token != "" ? [
|
||||
auth_secrets = [
|
||||
{
|
||||
name = "TURNSTONE_AUTH_TOKEN"
|
||||
valueFrom = aws_secretsmanager_secret_version.auth_token[0].arn
|
||||
name = "TURNSTONE_JWT_SECRET"
|
||||
valueFrom = aws_secretsmanager_secret_version.jwt_secret.arn
|
||||
},
|
||||
] : []
|
||||
]
|
||||
}
|
||||
|
||||
# ---------- Secrets Manager ----------
|
||||
|
||||
resource "aws_secretsmanager_secret" "jwt_secret" {
|
||||
name = "${var.name_prefix}-${var.environment}-jwt-secret"
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "jwt_secret" {
|
||||
secret_id = aws_secretsmanager_secret.jwt_secret.id
|
||||
secret_string = var.jwt_secret
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "openai_api_key" {
|
||||
name = "${var.name_prefix}-${var.environment}-openai-api-key"
|
||||
tags = local.common_tags
|
||||
@@ -65,17 +71,7 @@ resource "aws_secretsmanager_secret_version" "openai_api_key" {
|
||||
secret_string = var.openai_api_key
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "auth_token" {
|
||||
count = var.auth_token != "" ? 1 : 0
|
||||
name = "${var.name_prefix}-${var.environment}-auth-token"
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "auth_token" {
|
||||
count = var.auth_token != "" ? 1 : 0
|
||||
secret_id = aws_secretsmanager_secret.auth_token[0].id
|
||||
secret_string = var.auth_token
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "db_password" {
|
||||
name = "${var.name_prefix}-${var.environment}-db-password"
|
||||
@@ -140,7 +136,7 @@ resource "aws_ecs_task_definition" "server" {
|
||||
{ containerPort = 8080, protocol = "tcp" },
|
||||
]
|
||||
|
||||
environment = concat(local.common_env, local.auth_env)
|
||||
environment = local.common_env
|
||||
secrets = concat(local.common_secrets, local.auth_secrets)
|
||||
|
||||
logConfiguration = {
|
||||
@@ -209,7 +205,7 @@ resource "aws_ecs_task_definition" "console" {
|
||||
{ containerPort = 8090, protocol = "tcp" },
|
||||
]
|
||||
|
||||
environment = concat(local.common_env, local.auth_env)
|
||||
environment = local.common_env
|
||||
secrets = concat(local.common_secrets, local.auth_secrets)
|
||||
|
||||
logConfiguration = {
|
||||
|
||||
@@ -90,11 +90,10 @@ variable "name_prefix" {
|
||||
default = "turnstone"
|
||||
}
|
||||
|
||||
variable "auth_token" {
|
||||
description = "Optional authentication token for the Turnstone API. Empty string disables auth."
|
||||
variable "jwt_secret" {
|
||||
description = "JWT signing secret for Turnstone auth (required, min 32 characters)."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "certificate_arn" {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ddg": {
|
||||
"url": "http://ddg-search:3000/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ console.log(result.content);
|
||||
|
||||
## Authentication
|
||||
|
||||
When auth is enabled (`[auth].enabled = true` or `TURNSTONE_AUTH_ENABLED=1`), all API endpoints except public paths require a valid token.
|
||||
Auth is always enabled. All API endpoints except public paths require a valid token.
|
||||
|
||||
### Sending Credentials
|
||||
|
||||
@@ -65,15 +65,14 @@ Include a token in one of two ways:
|
||||
- **Bearer header**: `Authorization: Bearer <token>`
|
||||
- **Cookie**: `turnstone_auth=<token>` (set automatically by the login endpoint)
|
||||
|
||||
The server accepts three token types:
|
||||
The server accepts two token types:
|
||||
|
||||
| Type | Format | Example |
|
||||
|------|--------|---------|
|
||||
| JWT | Base64 segments separated by dots | `eyJhbG...` |
|
||||
| API token | `ts_` prefix + 64 hex chars | `ts_a1b2c3d4...` |
|
||||
| Config token | Arbitrary string from `config.toml` | `my-secret-token` |
|
||||
|
||||
JWTs are the recommended credential for browser sessions. API tokens are suitable for programmatic access and CI/CD. Config tokens are a simple option for single-node deployments.
|
||||
JWTs are the recommended credential for browser sessions. API tokens are suitable for programmatic access and CI/CD.
|
||||
|
||||
### `POST /v1/api/auth/login`
|
||||
|
||||
|
||||
+24
-28
@@ -74,7 +74,7 @@ turnstone/
|
||||
_sync.py Background event loop for sync wrappers
|
||||
_types.py TurnResult + TurnstoneAPIError
|
||||
console/
|
||||
collector.py ClusterCollector — aggregates state from all nodes via HTTP
|
||||
collector.py ClusterCollector — aggregates state from all nodes via SSE
|
||||
scheduler.py TaskScheduler — background cron/at scheduler, dispatches via HTTP
|
||||
server.py Cluster dashboard HTTP server + SSE + CLI entry point
|
||||
static/ Cluster dashboard web UI (page-specific HTML, CSS, JS)
|
||||
@@ -1016,13 +1016,10 @@ limits using a token-bucket algorithm. Each IP gets a `TokenBucket` with
|
||||
Turnstone supports three authentication mechanisms, unified behind an
|
||||
`AuthResult` dataclass that carries `user_id`, `scopes`, and `token_source`:
|
||||
|
||||
1. **Config-file tokens** — static secrets in `config.toml` `[[auth.tokens]]`
|
||||
or the `TURNSTONE_AUTH_TOKEN` env var. Validated in-memory via
|
||||
`hmac.compare_digest`. Map to scopes through their role (`read` or `full`).
|
||||
2. **API tokens** — database-backed, prefixed `ts_`, stored as SHA-256 hashes
|
||||
1. **API tokens** — database-backed, prefixed `ts_`, stored as SHA-256 hashes
|
||||
in the `api_tokens` table. Can be exchanged for JWTs via
|
||||
`POST /v1/api/auth/login`.
|
||||
3. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
|
||||
2. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
|
||||
successful credential validation. Contain `sub` (user_id), `scopes`, and
|
||||
`src` (origin) in claims.
|
||||
|
||||
@@ -1046,9 +1043,8 @@ Three hierarchical scopes control endpoint access:
|
||||
2. **Token extraction** — `Authorization: Bearer <token>` header first, then
|
||||
`turnstone_auth` cookie as fallback.
|
||||
3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix
|
||||
indicates API token; otherwise config-file token.
|
||||
4. **Validation** — JWT signature check, API token hash lookup in storage, or
|
||||
config-token hmac comparison.
|
||||
indicates API token.
|
||||
4. **Validation** — JWT signature check or API token hash lookup in storage.
|
||||
5. **Scope check** — `required_scope(method, path)` determines the minimum
|
||||
scope; the request is rejected with 403 if the token lacks it.
|
||||
6. **Context propagation** — on success, `ctx_user_id` is set so structured
|
||||
@@ -1201,31 +1197,31 @@ bell + status line to stderr to alert the user.
|
||||
### Cluster Console
|
||||
|
||||
```
|
||||
Monitoring (3 daemon threads) Control + Proxy (async Starlette)
|
||||
Monitoring (2 daemon threads) Control + Proxy (async Starlette)
|
||||
+------------------+ +----------------------------+
|
||||
| Event subscriber | | POST /v1/api/cluster/ |
|
||||
| SSE on | | workstreams/new |
|
||||
| /events/glob | | → POST to target server |
|
||||
| Node discovery | | POST /v1/api/cluster/ |
|
||||
| Service registry | | workstreams/new |
|
||||
| every 60 seconds | | → POST to target server |
|
||||
+------------------+ +----------------------------+
|
||||
| Node discovery | | GET /node/{node_id}/ |
|
||||
| Service registry | | → httpx.AsyncClient |
|
||||
| every 15 seconds | | proxy to server_url |
|
||||
+------------------+ | GET /node/{id}/v1/api/events |
|
||||
| Poll loop | | → SSE stream proxy |
|
||||
| GET /v1/api/dash | | POST /node/{id}/v1/api/send |
|
||||
| GET /health | | → forwarded to server |
|
||||
| ThreadPoolExec | +----------------------------+
|
||||
+------------------+
|
||||
| SSE manager | | GET /node/{node_id}/ |
|
||||
| asyncio loop | | → httpx.AsyncClient |
|
||||
| 1 task per node | | proxy to server_url |
|
||||
| /events/global | | GET /node/{id}/v1/api/events |
|
||||
| snapshot+deltas | | → SSE stream proxy |
|
||||
+------------------+ | POST /node/{id}/v1/api/send |
|
||||
| → forwarded to server |
|
||||
+----------------------------+
|
||||
```
|
||||
|
||||
The console HTTP layer is a Starlette/ASGI app served by uvicorn. The SSE
|
||||
endpoint uses `EventSourceResponse` with the same listener queue pattern as
|
||||
the main server. `ClusterCollector`'s background threads (event subscriber,
|
||||
node discovery, poll loop) use `ThreadPoolExecutor`
|
||||
for parallel HTTP polling. The poll loop diffs workstream IDs between poll
|
||||
cycles and fans out synthetic `ws_created`/`ws_closed` SSE events for any
|
||||
changes, ensuring browser clients stay in sync even when real-time cluster
|
||||
events are missed.
|
||||
the main server. `ClusterCollector` runs two daemon threads: a discovery loop
|
||||
that queries the service registry every 60 seconds, and an SSE manager that
|
||||
runs a single asyncio event loop multiplexing persistent SSE connections to
|
||||
all nodes via `GET /v1/api/events/global`. Each node delivers a full snapshot
|
||||
on connect followed by real-time delta events — state changes, health
|
||||
transitions, and aggregate metrics arrive sub-second instead of on a 15-second
|
||||
poll cycle.
|
||||
|
||||
The console has two write-path capabilities:
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:75c1832b6079e8628f4bbf4ce98d37880c4de133636b7555e3869990b046ddc6
|
||||
size 567704
|
||||
+5
-6
@@ -193,7 +193,6 @@ Plan review requests are displayed as a blue embed with:
|
||||
| `--auto-approve` | — | `false` | Auto-approve ALL tool calls (skips approval buttons entirely) |
|
||||
| `--http-host` | — | `127.0.0.1` | HTTP server bind address for notify endpoint |
|
||||
| `--http-port` | `TURNSTONE_CHANNEL_PORT` | `8091` | HTTP server port |
|
||||
| `--auth-token` | `TURNSTONE_CHANNEL_AUTH_TOKEN` | — | Static auth token for `/v1/api/notify` (alternative to JWT) |
|
||||
| `--log-level` | `TURNSTONE_LOG_LEVEL` | `INFO` | Log level |
|
||||
| `--log-format` | `TURNSTONE_LOG_FORMAT` | `auto` | Log format (`auto`/`json`/`text`) |
|
||||
|
||||
@@ -321,11 +320,11 @@ The `services` table schema:
|
||||
### Security
|
||||
|
||||
- **Authentication** — the gateway's `POST /v1/api/notify` endpoint
|
||||
requires authentication. Configure either `TURNSTONE_JWT_SECRET`
|
||||
(the server mints JWTs with `aud: turnstone-channel` automatically)
|
||||
or a static token via `--auth-token`. If neither is set, the
|
||||
gateway fails closed and rejects all requests with 401. Server JWTs
|
||||
(`aud: turnstone-server`) are rejected.
|
||||
requires authentication. Configure `TURNSTONE_JWT_SECRET` so the
|
||||
server can mint JWTs with `aud: turnstone-channel` automatically.
|
||||
If the secret is not set, the gateway fails closed and rejects all
|
||||
requests with 401. Server JWTs (`aud: turnstone-server`) are
|
||||
rejected.
|
||||
- **Rate limit** — maximum 5 notifications per turn. The counter only
|
||||
increments on successful delivery, so failures don't consume the
|
||||
budget.
|
||||
|
||||
+7
-11
@@ -1,6 +1,6 @@
|
||||
# Cluster Dashboard (turnstone-console)
|
||||
|
||||
`turnstone-console` is a cluster management service that provides cluster-wide visibility and control across all turnstone nodes. It discovers nodes via the `services` database table, polls each node's HTTP API for workstream data, and receives real-time state changes via HTTP polling.
|
||||
`turnstone-console` is a cluster management service that provides cluster-wide visibility and control across all turnstone nodes. It discovers nodes via the `services` database table and subscribes to each node's SSE event stream for real-time workstream, health, and metric updates.
|
||||
|
||||
The console also supports **workstream creation** (dispatched via HTTP proxy to target nodes) and a **reverse proxy** that serves each node's server UI through the console port — so users only need network access to the console, not to individual server nodes.
|
||||
|
||||
@@ -21,7 +21,7 @@ turnstone-console ──────┤
|
||||
|
||||
Data flows in two directions:
|
||||
|
||||
- **Inbound (monitoring):** The console discovers nodes via the `services` database table (nodes register on startup and send periodic heartbeats). It periodically polls each node's `GET /v1/api/dashboard` for full workstream snapshots and `GET /health` for node health.
|
||||
- **Inbound (monitoring):** The console discovers nodes via the `services` database table (nodes register on startup and send periodic heartbeats). It opens a persistent SSE connection to each node's `GET /v1/api/events/global` endpoint, receiving a full snapshot on connect followed by real-time delta events (state changes, health transitions, aggregate metrics).
|
||||
- **Outbound (control):** The console proxies workstream creation requests to target nodes via HTTP.
|
||||
- **Proxy (pass-through):** The console reverse-proxies each node's server UI at `/node/{node_id}/`, forwarding HTTP and SSE traffic so the browser never contacts server nodes directly.
|
||||
|
||||
@@ -30,8 +30,7 @@ Data flows in two directions:
|
||||
| Source | Method | Direction | Data |
|
||||
|--------|--------|-----------|------|
|
||||
| `services` table | Database query | Read | Node discovery (node_id, server_url, started) |
|
||||
| Node HTTP API | `GET {server_url}/v1/api/dashboard` | Read | Full workstream list with tokens, context, activity |
|
||||
| Node HTTP API | `GET {server_url}/health` | Read | Node health status |
|
||||
| Node SSE | `GET {server_url}/v1/api/events/global` | Stream | Snapshot on connect, then real-time delta events (state, health, aggregate) |
|
||||
| Node HTTP API | `POST {server_url}/v1/api/workstreams/new` | Write | Workstream creation |
|
||||
| Node HTTP API | `GET/POST {server_url}/*` | Proxy | Server UI, API requests, SSE streams |
|
||||
|
||||
@@ -41,9 +40,9 @@ Data flows in two directions:
|
||||
|
||||
The collector (`turnstone/console/collector.py`) maintains an in-memory snapshot of all nodes and workstreams. Two daemon threads handle data acquisition:
|
||||
|
||||
1. **Node discovery** — queries the `services` database table every 15 seconds. Adds newly discovered nodes, removes expired ones (stale heartbeats), emits `node_joined` / `node_lost` events to SSE listeners.
|
||||
1. **Node discovery** — queries the `services` database table every 60 seconds. Adds newly discovered nodes, removes expired ones (stale heartbeats), emits `node_joined` / `node_lost` events to SSE listeners, and spawns/cancels SSE tasks for new/lost nodes.
|
||||
|
||||
2. **Poll loop** — fetches `GET /v1/api/dashboard` and `GET /health` from each known node every 10 seconds. Uses `ThreadPoolExecutor(max_workers=50)` for parallelism. Each poll replaces the node's workstream list with the authoritative server data.
|
||||
2. **SSE manager** — a single asyncio event loop on one thread multiplexes persistent SSE connections to all discovered nodes via `GET /v1/api/events/global`. Each connection receives a `node_snapshot` on connect (workstreams, health, aggregate) followed by real-time delta events (`ws_state`, `ws_created`, `ws_closed`, `ws_rename`, `health_changed`, `aggregate`). On disconnect, the node is marked unreachable and the connection is retried with exponential backoff (1s–30s). An `?expected_node_id=` query parameter provides identity verification against IP reuse (server returns 409 on mismatch).
|
||||
|
||||
A `get_snapshot()` method builds the full cluster state under a single lock acquisition — overview aggregates and per-node workstream lists in one atomic read. This is served both as a REST endpoint and as the initial SSE event on client connect.
|
||||
|
||||
@@ -54,7 +53,7 @@ All reads and writes to the node/workstream map are protected by a single `threa
|
||||
### Scale Considerations
|
||||
|
||||
- **50,000 workstreams** (1,000 nodes × 50 per node) at ~500 bytes each = ~25 MB in memory
|
||||
- **1,000 nodes** polled in parallel — fan-out concurrency is configurable via `cluster.node_fan_out_limit` (default 200), yielding 5 batches at ~100ms each = ~0.5 second poll cycle
|
||||
- **1,000 nodes** connected via persistent SSE — a single asyncio event loop multiplexes all connections with negligible overhead. Ensure `ulimit -n` >= 4096 for fd headroom
|
||||
- **Filtering and pagination** run in-memory on the full workstream list — sub-millisecond at this scale
|
||||
- **SSE fan-out** uses per-client queues (2,000 events) — backed-up clients get events dropped, not blocking
|
||||
- **Database** — for clusters sharing PostgreSQL, use [PgBouncer](pgbouncer.md) in transaction pooling mode
|
||||
@@ -629,8 +628,6 @@ CLI flags for `turnstone-console`:
|
||||
|------|---------|-------------|
|
||||
| `--host` | `0.0.0.0` | Bind host |
|
||||
| `--port` | `8090` | HTTP port |
|
||||
| `--poll-interval` | `10` | Node polling interval (seconds) |
|
||||
| `--auth-token` | `$TURNSTONE_AUTH_TOKEN` | Bearer token for server node communication and proxy |
|
||||
| `--log-level` | `INFO` | Log level |
|
||||
|
||||
Config file (`~/.config/turnstone/config.toml`):
|
||||
@@ -640,7 +637,6 @@ Config file (`~/.config/turnstone/config.toml`):
|
||||
host = "0.0.0.0"
|
||||
port = 8090
|
||||
url = "http://localhost:8090" # used by CLI /cluster commands
|
||||
poll_interval = 10
|
||||
```
|
||||
|
||||
---
|
||||
@@ -652,7 +648,7 @@ poll_interval = 10
|
||||
turnstone-server --port 8080
|
||||
|
||||
# Start cluster console (one instance)
|
||||
turnstone-console --port 8090 --auth-token "$TURNSTONE_AUTH_TOKEN"
|
||||
turnstone-console --port 8090
|
||||
```
|
||||
|
||||
Open `http://localhost:8090` for the cluster dashboard. Create workstreams via the "+ new" button. Click any workstream to open the proxied server UI — no direct access to server ports required.
|
||||
|
||||
@@ -10,55 +10,76 @@ participant "ClusterCollector" as CC
|
||||
participant "Node-A\n(server)" as NodeA
|
||||
participant "Node-B\n(server)" as NodeB
|
||||
|
||||
== Thread 1: HTTP Polling (every 10s) ==
|
||||
== Thread 1: Node Discovery (every 60s) ==
|
||||
|
||||
CC -> CC : Iterate registered nodes
|
||||
CC -> CC : list_services("server",\nmax_age_seconds=120)
|
||||
activate CC #C8E6C9
|
||||
|
||||
CC -> NodeA : GET /v1/api/dashboard
|
||||
activate NodeA
|
||||
NodeA --> CC : {workstreams: [...],\naggregate: {total_tokens, ...}}
|
||||
deactivate NodeA
|
||||
|
||||
CC -> NodeA : GET /health
|
||||
activate NodeA
|
||||
NodeA --> CC : {status:"ok", version:"0.9.2",\nmodel:"...", workstreams:{...}}
|
||||
deactivate NodeA
|
||||
|
||||
CC -> NodeB : GET /v1/api/dashboard
|
||||
activate NodeB
|
||||
NodeB --> CC : {workstreams: [...],\naggregate: {total_tokens, ...}}
|
||||
deactivate NodeB
|
||||
|
||||
CC -> CC : Diff old vs new workstream IDs
|
||||
CC -> CC : Replace NodeSnapshot["nodeA"]\n.workstreams, .health, .aggregate
|
||||
CC -> CC : _fanout(ws_created) for\nnewly appeared workstreams
|
||||
CC -> CC : _fanout(ws_closed) for\nremoved workstreams
|
||||
|
||||
note right of CC
|
||||
Poll-diff fanout ensures
|
||||
browser SSE clients learn
|
||||
about workstreams that
|
||||
appeared between polls.
|
||||
end note
|
||||
CC -> CC : New node? → spawn SSE task\nLost node? → cancel SSE task
|
||||
CC -> CC : _fanout(node_joined)\n_fanout(node_lost)
|
||||
|
||||
deactivate CC
|
||||
|
||||
== Thread 2: SSE Manager (asyncio event loop) ==
|
||||
|
||||
note over CC
|
||||
Single asyncio event loop multiplexes
|
||||
one persistent SSE connection per node.
|
||||
Scales to 1000+ nodes.
|
||||
end note
|
||||
|
||||
CC -> NodeA : GET /v1/api/events/global\n?expected_node_id=nodeA
|
||||
activate NodeA
|
||||
activate CC #BBDEFB
|
||||
|
||||
NodeA --> CC : data: {"type":"node_snapshot",\n"node_id":"nodeA",\n"workstreams":[...],\n"health":{...},\n"aggregate":{...}}
|
||||
|
||||
note right of CC
|
||||
Snapshot populates NodeSnapshot
|
||||
in-memory state. Reconciles
|
||||
against stale data (emits
|
||||
ws_created/ws_closed diffs).
|
||||
end note
|
||||
|
||||
loop real-time delta events
|
||||
NodeA --> CC : data: {"type":"ws_state",\n"ws_id":"ws1","state":"running"}
|
||||
CC -> CC : Update NodeSnapshot\n_fanout(cluster_state)
|
||||
end
|
||||
|
||||
alt health transition
|
||||
NodeA --> CC : data: {"type":"health_changed",\n"circuit_state":"open"}
|
||||
CC -> CC : Update node.health
|
||||
end
|
||||
|
||||
alt periodic aggregate (every 10s)
|
||||
NodeA --> CC : data: {"type":"aggregate",\n"total_tokens":50000}
|
||||
CC -> CC : Update node.aggregate
|
||||
end
|
||||
|
||||
deactivate CC
|
||||
deactivate NodeA
|
||||
|
||||
alt SSE disconnect
|
||||
CC -> CC : Mark node unreachable\nReconnect with backoff\n(1s → 30s cap)
|
||||
end
|
||||
|
||||
alt identity mismatch (409 or snapshot node_id differs)
|
||||
CC -> CC : Mark node unreachable\nStop reconnecting to this URL
|
||||
end
|
||||
|
||||
== Browser SSE Stream ==
|
||||
|
||||
Browser -> Server : GET /v1/api/cluster/events
|
||||
activate Server
|
||||
|
||||
Server -> CC : get_snapshot()
|
||||
Server -> CC : get_snapshot_and_register(queue)
|
||||
note right : Atomic: snapshot + listener\nregistration under both locks\n→ no event gap
|
||||
CC --> Server : ClusterSnapshot\n(full current state)
|
||||
|
||||
Server -> CC : register_listener(queue)
|
||||
note right : Per-client queue.Queue(maxsize=2000)\nSSE via EventSourceResponse + run_in_executor()
|
||||
|
||||
Server -> Browser : data: {"type":"snapshot",...}\n(full state as first SSE event)
|
||||
|
||||
loop continuous (incremental updates)
|
||||
CC -> Server : event via listener queue\n(from polling thread)
|
||||
CC -> Server : event via listener queue\n(from SSE manager thread)
|
||||
Server -> Browser : data: {"type":"cluster_state",...}\n\n
|
||||
end
|
||||
|
||||
@@ -81,7 +102,7 @@ Server --> Browser : JSON response
|
||||
|
||||
Browser -> Server : GET /v1/api/cluster/overview
|
||||
Server -> CC : get_overview()
|
||||
CC --> Server : {nodes: 2, workstreams: 12,\nstates: {running:5, ...},\naggregate: {total_tokens: 50000},\nversion_drift: false, versions: ["0.9.2"]}
|
||||
CC --> Server : {nodes: 2, workstreams: 12,\nstates: {running:5, ...},\naggregate: {total_tokens: 50000},\nversion_drift: false, versions: ["0.9.7"]}
|
||||
Server --> Browser : JSON response
|
||||
|
||||
Browser -> Server : GET /v1/api/cluster/nodes?sort=activity
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:14824b81fa87f9a29e9b182b54132d3e438dd83f880b20b111b4bf51bc4b39d1
|
||||
size 317947
|
||||
oid sha256:040f7d9ec7d676da40b9487e0825caf2c1574cbdd9f16d0998d90e0c2e4f8861
|
||||
size 360309
|
||||
|
||||
+3
-4
@@ -69,15 +69,14 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CONSOLE_PORT` | `8090` | Host port mapping |
|
||||
| `CONSOLE_POLL_INTERVAL` | `10` | Node polling interval (seconds) |
|
||||
|
||||
### Auth
|
||||
|
||||
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_AUTH_ENABLED` | — | Set to `1` to require authentication |
|
||||
| `TURNSTONE_AUTH_TOKEN` | — | Config-file token for server/console (backward compat, works alongside JWT) |
|
||||
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required when using user identity / JWT auth) |
|
||||
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required) |
|
||||
|
||||
### Database
|
||||
|
||||
|
||||
+5
-5
@@ -38,7 +38,7 @@ are set.
|
||||
| `TURNSTONE_OIDC_PROVIDER_NAME` | No | `SSO` | Display name for the login button (e.g. "Google", "Okta") |
|
||||
| `TURNSTONE_OIDC_ROLE_CLAIM` | No | — | ID token claim containing role/group values (see [Role Mapping](#role-mapping)) |
|
||||
| `TURNSTONE_OIDC_ROLE_MAP` | No | — | Mapping from claim values to Turnstone role IDs (see [Role Mapping](#role-mapping)) |
|
||||
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens and config-file tokens still work. |
|
||||
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens continue to work. |
|
||||
| `TURNSTONE_OIDC_REDIRECT_BASE` | No | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Recommended when running behind a reverse proxy. When unset, derived from the request Host header. |
|
||||
|
||||
OIDC is enabled when all three required fields (issuer, client ID, client
|
||||
@@ -246,10 +246,10 @@ password) before OIDC is enabled. The setup wizard always works
|
||||
regardless of this setting because it is only available when zero users
|
||||
exist in the database.
|
||||
|
||||
API token login (`POST /v1/api/auth/login` with a `ts_` token) and
|
||||
config-file tokens (`Authorization: Bearer tok_xxx`) continue to work
|
||||
regardless of this setting. OIDC-only mode affects password-based
|
||||
authentication only.
|
||||
API token login (`POST /v1/api/auth/login` with a `ts_` token)
|
||||
continues to work regardless of this setting. JWTs and API tokens are
|
||||
the supported authentication methods. OIDC-only mode affects
|
||||
password-based authentication only.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Release Process
|
||||
|
||||
Turnstone uses two parallel release tracks published from a single PyPI package.
|
||||
|
||||
## Release Tracks
|
||||
|
||||
| Track | Versions | Branch | Docker tags | PyPI install |
|
||||
|-------|----------|--------|-------------|--------------|
|
||||
| **Stable** | `1.0.0`, `1.0.1` | `stable/1.0` | `:1.0.1`, `:1.0`, `:stable`, `:latest` | `pip install turnstone` |
|
||||
| **Experimental** | `1.1.0a1`, `1.1.0a2` | `main` | `:1.1.0a1`, `:experimental` | `pip install turnstone --pre` |
|
||||
|
||||
- **Stable** receives bugfixes only. Production-grade.
|
||||
- **Experimental** receives new features. May be rough around the edges.
|
||||
- When experimental matures, it is promoted to stable. The previous stable branch stops receiving patches.
|
||||
|
||||
## Version Scheme
|
||||
|
||||
[PEP 440](https://peps.python.org/pep-0440/) pre-release suffixes on a single package:
|
||||
|
||||
- `1.0.0` — stable release
|
||||
- `1.1.0a1` — alpha (experimental)
|
||||
- `1.1.0b1` — beta (experimental, more stable)
|
||||
- `1.1.0rc1` — release candidate (experimental, nearly stable)
|
||||
- `1.1.0` — promoted to stable
|
||||
|
||||
## Releasing an Experimental Version (from main)
|
||||
|
||||
```bash
|
||||
scripts/release.sh 1.1.0a2 --push
|
||||
```
|
||||
|
||||
This bumps `pyproject.toml` + `turnstone/__init__.py`, regenerates `uv.lock`, commits, tags `v1.1.0a2`, and pushes. CI runs, then publish + Docker workflows fire automatically.
|
||||
|
||||
## Releasing a Stable Patch (from stable/X.Y)
|
||||
|
||||
```bash
|
||||
git checkout stable/1.0
|
||||
git cherry-pick <commit-hash> # bugfix from main
|
||||
scripts/release.sh 1.0.2 --push
|
||||
```
|
||||
|
||||
## Promoting Experimental to Stable
|
||||
|
||||
When `main` is ready for a stable release:
|
||||
|
||||
```bash
|
||||
# 1. Tag the stable release on main
|
||||
scripts/release.sh 1.1.0 --push
|
||||
|
||||
# 2. Create the stable maintenance branch from that tag
|
||||
git branch stable/1.1 v1.1.0
|
||||
git push origin stable/1.1
|
||||
|
||||
# 3. Start the next experimental cycle on main
|
||||
scripts/release.sh 1.2.0a1 --push
|
||||
```
|
||||
|
||||
The previous `stable/1.0` branch stops receiving patches at this point.
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
All releases are gated on CI success:
|
||||
|
||||
1. `git push` with `v*` tag triggers **CI** (lint, typecheck, test, test-postgres, lock-check, security audit)
|
||||
2. On CI success, **Publish to PyPI** fires via `workflow_run`
|
||||
3. On CI success, **Publish Docker Image** fires via `workflow_run`
|
||||
|
||||
Pre-release tags (`a`, `b`, `rc` suffixes) produce:
|
||||
- PyPI: pre-release version (not installed by default)
|
||||
- GitHub Release: marked as pre-release
|
||||
- Docker: `:experimental` alias + exact version tag
|
||||
|
||||
Stable tags produce:
|
||||
- PyPI: stable version (default `pip install`)
|
||||
- GitHub Release: full release
|
||||
- Docker: `:stable`, `:latest`, `:X.Y`, `:X.Y.Z` tags
|
||||
|
||||
## Dependency Updates
|
||||
|
||||
Renovate targets `main` (experimental) only. Stable branches receive manual dependency updates via cherry-pick when security-relevant.
|
||||
+2
-2
@@ -332,6 +332,6 @@ client.login(token="ts_abc123...")
|
||||
- `client.logout()` clears the stored JWT from the client.
|
||||
- If a request returns 401, the SDK raises `TurnstoneAPIError` — the caller is responsible for re-authenticating.
|
||||
|
||||
### Backward Compatibility
|
||||
### Token Types
|
||||
|
||||
The config-file token (`TURNSTONE_AUTH_TOKEN`) still works as a simple Bearer token for environments that do not use the user/JWT system. When the server receives a non-JWT Bearer token, it falls back to the legacy token check.
|
||||
The SDK accepts any Bearer token — JWTs (from `ServiceTokenManager` or login) and API tokens (`ts_` prefix) are both supported. Use `token_factory` for auto-rotating JWTs or a static `token` for API tokens.
|
||||
|
||||
+11
-54
@@ -8,23 +8,6 @@ credentials while individual server nodes validate JWTs locally.
|
||||
|
||||
## Token Types
|
||||
|
||||
### Config-file tokens
|
||||
|
||||
Static tokens defined in `config.toml` or the `TURNSTONE_AUTH_TOKEN`
|
||||
environment variable. Validated in-memory using `hmac.compare_digest`
|
||||
(timing-safe). Each token maps to a role that determines its scopes.
|
||||
|
||||
```toml
|
||||
[[auth.tokens]]
|
||||
value = "tok_legacy"
|
||||
role = "full" # full → {read, write, approve}
|
||||
```
|
||||
|
||||
Role mappings: `"read"` → `{read}`, `"full"` → `{read, write, approve}`.
|
||||
|
||||
Config tokens are sent directly as `Authorization: Bearer tok_legacy`
|
||||
on every request. No JWT exchange is needed.
|
||||
|
||||
### API tokens
|
||||
|
||||
Database-backed tokens prefixed with `ts_`. Created via the admin CLI
|
||||
@@ -149,15 +132,6 @@ The API token is hashed, looked up in the database, and exchanged for a
|
||||
JWT with the token's scopes. This is the recommended flow for SDKs and
|
||||
automated clients that need cookie-based sessions.
|
||||
|
||||
### Config-file tokens (direct)
|
||||
|
||||
Config tokens are validated per-request via `hmac.compare_digest`. No
|
||||
login exchange is needed — include the token as a `Bearer` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer tok_legacy
|
||||
```
|
||||
|
||||
### First-time setup
|
||||
|
||||
When no users exist in the database:
|
||||
@@ -276,7 +250,7 @@ Setting `TURNSTONE_OIDC_PASSWORD_ENABLED=false` hides the password
|
||||
form on the login page and blocks password-based login at the API
|
||||
level. The setup wizard always works regardless of this setting — the
|
||||
first admin user is created with a password before OIDC is relevant.
|
||||
API tokens and config-file tokens are unaffected by this setting.
|
||||
API tokens are unaffected by this setting.
|
||||
|
||||
#### Known limitations
|
||||
|
||||
@@ -297,8 +271,6 @@ and classifies the token:
|
||||
|
||||
1. **Contains `.`** → JWT → validate HS256 signature and expiry
|
||||
2. **Starts with `ts_`** → API token → SHA-256 hash, database lookup
|
||||
3. **Otherwise** → config-file token → `hmac.compare_digest` against
|
||||
each configured token
|
||||
|
||||
If a session cookie is present and no `Authorization` header is sent,
|
||||
the cookie value is treated as a JWT (step 1).
|
||||
@@ -332,16 +304,10 @@ deployments.
|
||||
| Signing secret | `[auth] jwt_secret` | `TURNSTONE_JWT_SECRET` | Auto-generated ephemeral (warning logged) |
|
||||
| Expiry | `[auth] jwt_expiry_hours` | — | 24 hours |
|
||||
| Algorithm | — | — | HS256 (not configurable) |
|
||||
| Minimum secret length | — | — | 32 characters (warning if shorter) |
|
||||
| Minimum secret length | — | — | 32 characters (exits if shorter) |
|
||||
|
||||
All service nodes that need to validate JWTs must share the same signing
|
||||
secret. If no secret is configured, an ephemeral key is generated at
|
||||
startup and a warning is logged — JWTs will not survive restarts or work
|
||||
across nodes.
|
||||
|
||||
The console **requires** `TURNSTONE_JWT_SECRET` when no `--auth-token`
|
||||
is provided. It exits with an error if the secret is missing, since
|
||||
ephemeral secrets would silently break inter-service communication.
|
||||
All services require `TURNSTONE_JWT_SECRET` and exit at startup if it is
|
||||
missing or shorter than 32 characters.
|
||||
|
||||
---
|
||||
|
||||
@@ -442,16 +408,15 @@ Console (cluster-wide) Server (per-node)
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ User/Token CRUD (DB) │ │ JWT validation only │
|
||||
│ Login: creds → JWT │ │ (shared signing key) │
|
||||
│ Admin API endpoints │ │ Config tokens: hmac │
|
||||
│ Storage: users, │ │ No auth DB needed │
|
||||
│ Admin API endpoints │ │ No auth DB needed │
|
||||
│ Storage: users, │ │ │
|
||||
│ api_tokens tables │ │ │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
The console owns the credential database and handles all user/token
|
||||
CRUD. Individual server nodes only need the JWT signing secret to
|
||||
validate session tokens. Config-file tokens are validated locally
|
||||
without any database.
|
||||
validate session tokens.
|
||||
|
||||
### Proxy auth forwarding
|
||||
|
||||
@@ -478,8 +443,7 @@ distinguish proxied requests from direct logins in audit logs.
|
||||
|
||||
When no user context is available (auth disabled, or internal requests),
|
||||
the proxy falls back to a `ServiceTokenManager` with service identity
|
||||
`console-proxy` and full scopes. If `--auth-token` is provided, that
|
||||
static token is used as a final fallback.
|
||||
`console-proxy` and full scopes.
|
||||
|
||||
### Service-to-service authentication
|
||||
|
||||
@@ -518,22 +482,17 @@ channel gateway endpoint, and vice versa.
|
||||
|
||||
```toml
|
||||
[auth]
|
||||
enabled = true
|
||||
jwt_secret = "your-secret-key-here"
|
||||
jwt_expiry_hours = 24
|
||||
|
||||
[[auth.tokens]]
|
||||
value = "tok_legacy"
|
||||
role = "full"
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TURNSTONE_AUTH_ENABLED=1` | Enable authentication |
|
||||
| `TURNSTONE_AUTH_TOKEN=tok_xxx` | Register a config-file token with `full` access |
|
||||
| `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (must match across nodes) |
|
||||
| `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (required, must match across nodes) |
|
||||
| `TURNSTONE_CORS_ORIGINS=` | CORS allowed origins (comma-separated; empty = same-origin only) |
|
||||
|
||||
---
|
||||
@@ -571,8 +530,6 @@ and browsers enforce same-origin policy.
|
||||
|
||||
## Security Properties
|
||||
|
||||
- **Timing-safe comparison** for config-file tokens via
|
||||
`hmac.compare_digest` — no timing side-channel.
|
||||
- **Hash-based lookup** for API tokens — the database stores only
|
||||
SHA-256 hashes, eliminating timing attacks on token comparison.
|
||||
- **Local JWT validation** — no network call or database query needed
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ turnstone-admin tls-ca-cert --out ca.pem --console-url http://console:8080
|
||||
turnstone-admin tls-issue worker-1.internal --out /certs --console-url http://console:8080
|
||||
|
||||
# List issued certs
|
||||
turnstone-admin tls-list --console-url http://console:8080 --auth-token $TOKEN
|
||||
turnstone-admin tls-list --console-url http://console:8080
|
||||
```
|
||||
|
||||
### Console URL Discovery
|
||||
|
||||
+1
-1
@@ -593,7 +593,7 @@ current turn and letting it search for them on demand.
|
||||
Tool search uses the best available mechanism for each provider:
|
||||
|
||||
1. **Anthropic (native)** -- Models that support it receive `defer_loading: true`
|
||||
on deferred tool definitions plus the `tool_search_tool_bm25_20251119` server-side
|
||||
on deferred tool definitions plus the `tool_search_tool_bm25` server-side
|
||||
search tool. Anthropic's API handles search and expansion transparently.
|
||||
|
||||
2. **OpenAI GPT-5.4+ (native)** -- Models with hosted tool search receive
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# MCP Cluster Ops
|
||||
|
||||
An MCP server that exposes tools for executing commands across a [Turnstone](https://github.com/turnstonelabs/turnstone) cluster. Serves as a reference implementation for both MCP server patterns and Turnstone SDK usage.
|
||||
An MCP server that exposes tools for executing commands across a Turnstone cluster. Serves as a reference implementation for both MCP server patterns and Turnstone SDK usage.
|
||||
|
||||
## How it works
|
||||
|
||||
This server uses Turnstone's SDK client (`TurnstoneServer`) to dispatch shell commands to specific nodes via HTTP. Remote agents execute the command and the raw bash output is captured directly from the `ToolResultEvent` stream — bypassing the costly "agent reads output → re-generates output as completion tokens" round-trip.
|
||||
This server uses the Turnstone console SDK (`TurnstoneConsole`) for node discovery and routing, and `TurnstoneServer` for per-node SSE streaming. The dispatch flow for each command is:
|
||||
|
||||
1. **Route** — `TurnstoneConsole.route_create_workstream(target_node=..., auto_approve=True)` creates a workstream pinned to the target node via the console's hash-ring routing proxy, returning `ws_id` and `node_url`.
|
||||
2. **Execute** — `TurnstoneServer(node_url, token=...)` connects directly to the node's SSE stream using the same `TURNSTONE_API_TOKEN`. `send_and_wait(prompt, ws_id)` runs the command and the raw bash output is captured from the `ToolResultEvent` — bypassing the costly "agent reads output then re-generates output as completion tokens" round-trip.
|
||||
3. **Cleanup** — `TurnstoneConsole.route_close(ws_id)` closes the workstream.
|
||||
|
||||
Multi-node dispatches run in parallel via `asyncio.gather`, so total wall time is bounded by the slowest node rather than the sum.
|
||||
|
||||
@@ -19,7 +23,7 @@ Multi-node dispatches run in parallel via `asyncio.gather`, so total wall time i
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running Turnstone cluster (at least one `turnstone-server`)
|
||||
- A running Turnstone cluster with at least one `turnstone-server` and a `turnstone-console`
|
||||
- Python 3.11+
|
||||
|
||||
## Installation
|
||||
@@ -35,8 +39,8 @@ pip install -e ./examples/mcp-cluster-ops
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_SERVER_URL` | `http://localhost:8080` | Server URL |
|
||||
| `TURNSTONE_API_TOKEN` | _(none)_ | API token for authentication |
|
||||
| `TURNSTONE_CONSOLE_URL` | `http://localhost:8090` | Console URL for node discovery and routing |
|
||||
| `TURNSTONE_API_TOKEN` | _(none)_ | API token / JWT for authentication |
|
||||
| `MCP_CLUSTER_OPS_TIMEOUT` | `120` | Default command timeout (seconds, clamped 5-3600) |
|
||||
| `MCP_CLUSTER_OPS_MAX_OUTPUT` | `8192` | Max output bytes per node (0 = unlimited) |
|
||||
| `MCP_CLUSTER_OPS_MAX_NODES` | `32` | Max concurrent node dispatches |
|
||||
@@ -51,7 +55,7 @@ pip install -e ./examples/mcp-cluster-ops
|
||||
command = "mcp-cluster-ops"
|
||||
|
||||
[mcp.servers.cluster-ops.env]
|
||||
TURNSTONE_SERVER_URL = "http://turnstone.example.com:8080"
|
||||
TURNSTONE_CONSOLE_URL = "http://console.example.com:8090"
|
||||
```
|
||||
|
||||
**JSON** (via `--mcp-config`):
|
||||
@@ -62,7 +66,7 @@ TURNSTONE_SERVER_URL = "http://turnstone.example.com:8080"
|
||||
"cluster-ops": {
|
||||
"command": "mcp-cluster-ops",
|
||||
"env": {
|
||||
"TURNSTONE_SERVER_URL": "http://turnstone.example.com:8080"
|
||||
"TURNSTONE_CONSOLE_URL": "http://console.example.com:8090"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""MCP server for Turnstone cluster operations.
|
||||
|
||||
Exposes tools to execute commands on specific nodes in a Turnstone cluster.
|
||||
Uses the SDK client (``TurnstoneServer``) for direct node targeting via HTTP.
|
||||
Uses the SDK console client (``TurnstoneConsole``) for node discovery and
|
||||
routing, and ``TurnstoneServer`` for per-node SSE streaming.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -14,12 +15,12 @@ Configure in ``~/.config/turnstone/config.toml``::
|
||||
command = "mcp-cluster-ops"
|
||||
|
||||
[mcp.servers.cluster-ops.env]
|
||||
TURNSTONE_SERVER_URL = "http://localhost:8080"
|
||||
TURNSTONE_CONSOLE_URL = "http://localhost:8090"
|
||||
|
||||
Environment variables
|
||||
---------------------
|
||||
TURNSTONE_SERVER_URL Server URL (default: http://localhost:8080)
|
||||
TURNSTONE_API_TOKEN API token for authentication (default: none)
|
||||
TURNSTONE_CONSOLE_URL Console URL (default: http://localhost:8090)
|
||||
TURNSTONE_API_TOKEN API token / JWT for authentication (default: none)
|
||||
MCP_CLUSTER_OPS_TIMEOUT Default command timeout in seconds (default: 120)
|
||||
MCP_CLUSTER_OPS_MAX_OUTPUT Max output bytes per node (default: 8192, 0=unlimited)
|
||||
|
||||
@@ -43,7 +44,7 @@ from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from turnstone.sdk import TurnResult, TurnstoneServer
|
||||
from turnstone.sdk import TurnResult, TurnstoneConsole, TurnstoneServer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -66,15 +67,12 @@ _MAX_TIMEOUT = 3600
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _server_kwargs() -> dict[str, Any]:
|
||||
"""Build TurnstoneServer connection kwargs from environment variables."""
|
||||
kwargs: dict[str, Any] = {
|
||||
"base_url": os.environ.get("TURNSTONE_SERVER_URL", "http://localhost:8080"),
|
||||
def _console_kwargs() -> dict[str, Any]:
|
||||
"""Build TurnstoneConsole connection kwargs from environment variables."""
|
||||
return {
|
||||
"base_url": os.environ.get("TURNSTONE_CONSOLE_URL", "http://localhost:8090"),
|
||||
"token": os.environ.get("TURNSTONE_API_TOKEN", ""),
|
||||
}
|
||||
token = os.environ.get("TURNSTONE_API_TOKEN")
|
||||
if token:
|
||||
kwargs["token"] = token
|
||||
return kwargs
|
||||
|
||||
|
||||
def _exec_prompt(command: str) -> str:
|
||||
@@ -141,6 +139,11 @@ def _validate_command(command: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_node_ids(nodes: list[dict[str, Any]]) -> list[str]:
|
||||
"""Extract unique, non-empty node IDs from a list of node dicts."""
|
||||
return list(dict.fromkeys(n["node_id"].strip() for n in nodes if n.get("node_id", "").strip()))
|
||||
|
||||
|
||||
def _format_node_result(
|
||||
node_id: str,
|
||||
result: TurnResult,
|
||||
@@ -165,12 +168,12 @@ def _format_node_result(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core dispatch functions (testable with mocked TurnstoneServer)
|
||||
# Core dispatch functions (testable with mocked SDK clients)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _exec_on_node_sync(
|
||||
server_kw: dict[str, Any],
|
||||
console_kw: dict[str, Any],
|
||||
node_id: str,
|
||||
command: str,
|
||||
timeout: float,
|
||||
@@ -178,22 +181,35 @@ def _exec_on_node_sync(
|
||||
"""Dispatch *command* to *node_id* and block until complete.
|
||||
|
||||
Runs inside ``asyncio.to_thread`` so it does not block the event loop.
|
||||
Each call creates its own ``TurnstoneServer`` client to avoid state
|
||||
conflicts between concurrent dispatches.
|
||||
|
||||
Flow:
|
||||
1. Create a workstream on the target node via the console routing proxy
|
||||
2. Connect directly to the node's SSE stream to send + collect output
|
||||
3. Close the workstream via the routing proxy
|
||||
"""
|
||||
prompt = _exec_prompt(command)
|
||||
with TurnstoneServer(**server_kw) as client:
|
||||
result = client.send_and_wait(
|
||||
message=prompt,
|
||||
target_node=node_id,
|
||||
auto_approve=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
ws_id = ""
|
||||
with TurnstoneConsole(**console_kw) as console:
|
||||
try:
|
||||
route_resp = console.route_create_workstream(
|
||||
target_node=node_id,
|
||||
auto_approve=True,
|
||||
)
|
||||
ws_id = route_resp["ws_id"]
|
||||
node_url: str = route_resp["node_url"]
|
||||
with TurnstoneServer(
|
||||
base_url=node_url,
|
||||
token=console_kw["token"],
|
||||
) as server:
|
||||
result = server.send_and_wait(prompt, ws_id, timeout=timeout)
|
||||
finally:
|
||||
if ws_id:
|
||||
console.route_close(ws_id)
|
||||
return node_id, result
|
||||
|
||||
|
||||
async def _dispatch_parallel(
|
||||
server_kw: dict[str, Any],
|
||||
console_kw: dict[str, Any],
|
||||
node_ids: list[str],
|
||||
command: str,
|
||||
timeout: float,
|
||||
@@ -204,7 +220,7 @@ async def _dispatch_parallel(
|
||||
Total wall time is bounded by the slowest node.
|
||||
"""
|
||||
tasks = [
|
||||
asyncio.to_thread(_exec_on_node_sync, server_kw, nid, command, timeout) for nid in node_ids
|
||||
asyncio.to_thread(_exec_on_node_sync, console_kw, nid, command, timeout) for nid in node_ids
|
||||
]
|
||||
outcomes = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
@@ -220,16 +236,22 @@ async def _dispatch_parallel(
|
||||
return results
|
||||
|
||||
|
||||
def _list_nodes_sync(server_kw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""List active cluster nodes (blocking)."""
|
||||
with TurnstoneServer(**server_kw) as client:
|
||||
nodes: list[dict[str, Any]] = client.list_nodes()
|
||||
return nodes
|
||||
def _list_nodes_sync(console_kw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""List active cluster nodes (blocking), paginating if needed."""
|
||||
page_size = 100
|
||||
nodes: list[dict[str, Any]] = []
|
||||
with TurnstoneConsole(**console_kw) as console:
|
||||
while True:
|
||||
resp = console.nodes(limit=page_size, offset=len(nodes))
|
||||
nodes.extend(n.model_dump() for n in resp.nodes)
|
||||
if len(nodes) >= resp.total or not resp.nodes:
|
||||
break
|
||||
return nodes
|
||||
|
||||
|
||||
async def _list_nodes_impl(server_kw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
async def _list_nodes_impl(console_kw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""List active cluster nodes."""
|
||||
return await asyncio.to_thread(_list_nodes_sync, server_kw)
|
||||
return await asyncio.to_thread(_list_nodes_sync, console_kw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -239,9 +261,9 @@ async def _list_nodes_impl(server_kw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(server: FastMCP[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Lifespan context — stores server connection kwargs for tool handlers."""
|
||||
kw = _server_kwargs()
|
||||
yield {"server_kwargs": kw}
|
||||
"""Lifespan context — stores console connection kwargs for tool handlers."""
|
||||
kw = _console_kwargs()
|
||||
yield {"console_kwargs": kw}
|
||||
|
||||
|
||||
mcp = FastMCP(
|
||||
@@ -263,8 +285,8 @@ async def list_nodes(ctx: Context[Any, Any, Any]) -> str:
|
||||
Call this before dispatching work to discover available node IDs.
|
||||
Returns a JSON array of node metadata objects.
|
||||
"""
|
||||
server_kw: dict[str, Any] = ctx.request_context.lifespan_context["server_kwargs"]
|
||||
nodes = await _list_nodes_impl(server_kw)
|
||||
console_kw: dict[str, Any] = ctx.request_context.lifespan_context["console_kwargs"]
|
||||
nodes = await _list_nodes_impl(console_kw)
|
||||
return json.dumps(nodes, indent=2)
|
||||
|
||||
|
||||
@@ -291,13 +313,16 @@ async def run_on_node(
|
||||
if cmd_err:
|
||||
return json.dumps({"error": cmd_err})
|
||||
|
||||
server_kw: dict[str, Any] = ctx.request_context.lifespan_context["server_kwargs"]
|
||||
console_kw: dict[str, Any] = ctx.request_context.lifespan_context["console_kwargs"]
|
||||
max_output = _DEFAULT_MAX_OUTPUT
|
||||
|
||||
log.info("run_on_node node=%s cmd=%r", node_id, command)
|
||||
_, result = await asyncio.to_thread(
|
||||
_exec_on_node_sync, server_kw, node_id, command, _clamp_timeout(timeout)
|
||||
)
|
||||
try:
|
||||
_, result = await asyncio.to_thread(
|
||||
_exec_on_node_sync, console_kw, node_id, command, _clamp_timeout(timeout)
|
||||
)
|
||||
except Exception as exc:
|
||||
return json.dumps({"node": node_id, "ok": False, "error": str(exc)}, indent=2)
|
||||
formatted = _format_node_result(node_id, result, max_output)
|
||||
return json.dumps(formatted, indent=2)
|
||||
|
||||
@@ -323,7 +348,7 @@ async def run_on_nodes(
|
||||
if cmd_err:
|
||||
return json.dumps({"error": cmd_err})
|
||||
|
||||
server_kw: dict[str, Any] = ctx.request_context.lifespan_context["server_kwargs"]
|
||||
console_kw: dict[str, Any] = ctx.request_context.lifespan_context["console_kwargs"]
|
||||
max_output = _DEFAULT_MAX_OUTPUT
|
||||
|
||||
clean_ids = list(dict.fromkeys(nid.strip() for nid in node_ids if nid.strip()))
|
||||
@@ -336,7 +361,7 @@ async def run_on_nodes(
|
||||
|
||||
log.info("run_on_nodes nodes=%s cmd=%r", clean_ids, command)
|
||||
results = await _dispatch_parallel(
|
||||
server_kw, clean_ids, command, _clamp_timeout(timeout), max_output
|
||||
console_kw, clean_ids, command, _clamp_timeout(timeout), max_output
|
||||
)
|
||||
return json.dumps(results, indent=2)
|
||||
|
||||
@@ -361,18 +386,14 @@ async def run_on_all_nodes(
|
||||
if cmd_err:
|
||||
return json.dumps({"error": cmd_err})
|
||||
|
||||
server_kw: dict[str, Any] = ctx.request_context.lifespan_context["server_kwargs"]
|
||||
console_kw: dict[str, Any] = ctx.request_context.lifespan_context["console_kwargs"]
|
||||
max_output = _DEFAULT_MAX_OUTPUT
|
||||
|
||||
nodes = await _list_nodes_impl(server_kw)
|
||||
nodes = await _list_nodes_impl(console_kw)
|
||||
if not nodes:
|
||||
return json.dumps({"error": "No active nodes found in cluster"})
|
||||
|
||||
node_ids = list(
|
||||
dict.fromkeys(
|
||||
nid.strip() for n in nodes if (nid := n.get("node_id") or n.get("id")) and nid.strip()
|
||||
)
|
||||
)
|
||||
node_ids = _extract_node_ids(nodes)
|
||||
if not node_ids:
|
||||
return json.dumps({"error": "No nodes with identifiable IDs found"})
|
||||
if len(node_ids) > _MAX_CONCURRENT_NODES:
|
||||
@@ -381,7 +402,7 @@ async def run_on_all_nodes(
|
||||
)
|
||||
log.info("run_on_all_nodes nodes=%s cmd=%r", node_ids, command)
|
||||
results = await _dispatch_parallel(
|
||||
server_kw, node_ids, command, _clamp_timeout(timeout), max_output
|
||||
console_kw, node_ids, command, _clamp_timeout(timeout), max_output
|
||||
)
|
||||
return json.dumps(results, indent=2)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from turnstone.sdk import TurnResult
|
||||
from mcp_cluster_ops.server import (
|
||||
_clamp_timeout,
|
||||
_exec_prompt,
|
||||
_extract_node_ids,
|
||||
_extract_output,
|
||||
_format_node_result,
|
||||
_truncate,
|
||||
@@ -190,3 +191,53 @@ class TestClampTimeout:
|
||||
|
||||
def test_negative(self):
|
||||
assert _clamp_timeout(-1) == 5.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_node_ids
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractNodeIds:
|
||||
def test_normal(self):
|
||||
nodes = [
|
||||
{"node_id": "a", "server_url": "http://a:8080"},
|
||||
{"node_id": "b", "server_url": "http://b:8080"},
|
||||
]
|
||||
assert _extract_node_ids(nodes) == ["a", "b"]
|
||||
|
||||
def test_deduplicates(self):
|
||||
nodes = [
|
||||
{"node_id": "a"},
|
||||
{"node_id": "a"},
|
||||
{"node_id": "b"},
|
||||
]
|
||||
assert _extract_node_ids(nodes) == ["a", "b"]
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
nodes = [{"node_id": " a "}, {"node_id": "b "}]
|
||||
assert _extract_node_ids(nodes) == ["a", "b"]
|
||||
|
||||
def test_skips_empty(self):
|
||||
nodes = [
|
||||
{"node_id": "a"},
|
||||
{"node_id": ""},
|
||||
{"node_id": " "},
|
||||
{"node_id": "b"},
|
||||
]
|
||||
assert _extract_node_ids(nodes) == ["a", "b"]
|
||||
|
||||
def test_skips_missing_key(self):
|
||||
nodes = [
|
||||
{"node_id": "a"},
|
||||
{"server_url": "http://orphan:8080"},
|
||||
{"node_id": "b"},
|
||||
]
|
||||
assert _extract_node_ids(nodes) == ["a", "b"]
|
||||
|
||||
def test_empty_list(self):
|
||||
assert _extract_node_ids([]) == []
|
||||
|
||||
def test_all_empty_ids(self):
|
||||
nodes = [{"node_id": ""}, {"node_id": " "}]
|
||||
assert _extract_node_ids(nodes) == []
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Tests for MCP tool handlers with mocked TurnstoneServer."""
|
||||
"""Tests for MCP tool handlers with mocked SDK clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from turnstone.sdk import TurnResult
|
||||
|
||||
from mcp_cluster_ops.server import (
|
||||
@@ -14,6 +16,22 @@ from mcp_cluster_ops.server import (
|
||||
_list_nodes_impl,
|
||||
)
|
||||
|
||||
_CONSOLE_KW: dict[str, Any] = {"base_url": "http://localhost:8090", "token": ""}
|
||||
_CONSOLE_KW_AUTH: dict[str, Any] = {"base_url": "http://localhost:8090", "token": "tok_test"}
|
||||
|
||||
|
||||
def _mock_console_ctx(mock_cls: MagicMock, mock_client: MagicMock) -> None:
|
||||
"""Wire up a TurnstoneConsole mock as a context manager."""
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
|
||||
def _mock_server_ctx(mock_cls: MagicMock, mock_server: MagicMock) -> None:
|
||||
"""Wire up a TurnstoneServer mock as a context manager."""
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_server)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _list_nodes_impl
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -21,26 +39,71 @@ from mcp_cluster_ops.server import (
|
||||
|
||||
class TestListNodesImpl:
|
||||
def test_returns_nodes(self):
|
||||
nodes = [{"node_id": "a", "model": "gpt-5"}, {"node_id": "b", "model": "gpt-5"}]
|
||||
with patch("mcp_cluster_ops.server.TurnstoneServer") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_nodes.return_value = nodes
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
mock_node_a = MagicMock()
|
||||
mock_node_a.model_dump.return_value = {"node_id": "a", "server_url": "http://a:8080"}
|
||||
mock_node_b = MagicMock()
|
||||
mock_node_b.model_dump.return_value = {"node_id": "b", "server_url": "http://b:8080"}
|
||||
|
||||
result = asyncio.run(_list_nodes_impl({"host": "localhost"}))
|
||||
assert result == nodes
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.nodes = [mock_node_a, mock_node_b]
|
||||
mock_resp.total = 2
|
||||
|
||||
with patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.nodes.return_value = mock_resp
|
||||
_mock_console_ctx(mock_cls, mock_client)
|
||||
|
||||
result = asyncio.run(_list_nodes_impl(_CONSOLE_KW))
|
||||
assert len(result) == 2
|
||||
assert result[0]["node_id"] == "a"
|
||||
assert result[1]["node_id"] == "b"
|
||||
|
||||
def test_empty_cluster(self):
|
||||
with patch("mcp_cluster_ops.server.TurnstoneServer") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_nodes.return_value = []
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.nodes = []
|
||||
mock_resp.total = 0
|
||||
|
||||
result = asyncio.run(_list_nodes_impl({"host": "localhost"}))
|
||||
with patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.nodes.return_value = mock_resp
|
||||
_mock_console_ctx(mock_cls, mock_client)
|
||||
|
||||
result = asyncio.run(_list_nodes_impl(_CONSOLE_KW))
|
||||
assert result == []
|
||||
|
||||
def test_paginates_large_clusters(self):
|
||||
"""Clusters with >100 nodes are fetched across multiple pages."""
|
||||
|
||||
def _make_node(nid: str) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.model_dump.return_value = {"node_id": nid}
|
||||
return m
|
||||
|
||||
page1_nodes = [_make_node(f"n-{i}") for i in range(100)]
|
||||
page2_nodes = [_make_node(f"n-{i}") for i in range(100, 150)]
|
||||
|
||||
page1_resp = MagicMock()
|
||||
page1_resp.nodes = page1_nodes
|
||||
page1_resp.total = 150
|
||||
|
||||
page2_resp = MagicMock()
|
||||
page2_resp.nodes = page2_nodes
|
||||
page2_resp.total = 150
|
||||
|
||||
with patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.nodes.side_effect = [page1_resp, page2_resp]
|
||||
_mock_console_ctx(mock_cls, mock_client)
|
||||
|
||||
result = asyncio.run(_list_nodes_impl(_CONSOLE_KW))
|
||||
assert len(result) == 150
|
||||
assert result[0]["node_id"] == "n-0"
|
||||
assert result[149]["node_id"] == "n-149"
|
||||
assert mock_client.nodes.call_count == 2
|
||||
# Verify offset was passed correctly
|
||||
mock_client.nodes.assert_any_call(limit=100, offset=0)
|
||||
mock_client.nodes.assert_any_call(limit=100, offset=100)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _exec_on_node_sync
|
||||
@@ -50,35 +113,111 @@ class TestListNodesImpl:
|
||||
class TestExecOnNodeSync:
|
||||
def test_success(self):
|
||||
turn_result = TurnResult(
|
||||
ws_id="ws-123",
|
||||
tool_results=[("bash", "hello world")],
|
||||
)
|
||||
with patch("mcp_cluster_ops.server.TurnstoneServer") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_and_wait.return_value = turn_result
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
with (
|
||||
patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_console_cls,
|
||||
patch("mcp_cluster_ops.server.TurnstoneServer") as mock_server_cls,
|
||||
):
|
||||
mock_console = MagicMock()
|
||||
mock_console.route_create_workstream.return_value = {
|
||||
"ws_id": "ws-123",
|
||||
"node_url": "http://node-1:8080",
|
||||
"node_id": "node-1",
|
||||
"name": "ws-123",
|
||||
}
|
||||
_mock_console_ctx(mock_console_cls, mock_console)
|
||||
|
||||
node_id, result = _exec_on_node_sync(
|
||||
{"host": "localhost"}, "node-1", "echo hello", 60.0
|
||||
)
|
||||
mock_server = MagicMock()
|
||||
mock_server.send_and_wait.return_value = turn_result
|
||||
_mock_server_ctx(mock_server_cls, mock_server)
|
||||
|
||||
node_id, result = _exec_on_node_sync(_CONSOLE_KW_AUTH, "node-1", "echo hello", 60.0)
|
||||
assert node_id == "node-1"
|
||||
assert result.ok
|
||||
mock_client.send_and_wait.assert_called_once()
|
||||
call_kwargs = mock_client.send_and_wait.call_args
|
||||
assert call_kwargs.kwargs["target_node"] == "node-1"
|
||||
assert call_kwargs.kwargs["auto_approve"] is True
|
||||
|
||||
# Verify console created ws on the right node
|
||||
mock_console.route_create_workstream.assert_called_once_with(
|
||||
target_node="node-1",
|
||||
auto_approve=True,
|
||||
)
|
||||
|
||||
# Verify server connected to the node URL with the token
|
||||
mock_server_cls.assert_called_once_with(
|
||||
base_url="http://node-1:8080",
|
||||
token="tok_test",
|
||||
)
|
||||
|
||||
# Verify send_and_wait got the right ws_id
|
||||
call_kwargs = mock_server.send_and_wait.call_args
|
||||
assert call_kwargs.args[1] == "ws-123"
|
||||
|
||||
# Verify workstream was closed
|
||||
mock_console.route_close.assert_called_once_with("ws-123")
|
||||
|
||||
def test_timeout(self):
|
||||
turn_result = TurnResult(timed_out=True)
|
||||
with patch("mcp_cluster_ops.server.TurnstoneServer") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_and_wait.return_value = turn_result
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
turn_result = TurnResult(ws_id="ws-456", timed_out=True)
|
||||
with (
|
||||
patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_console_cls,
|
||||
patch("mcp_cluster_ops.server.TurnstoneServer") as mock_server_cls,
|
||||
):
|
||||
mock_console = MagicMock()
|
||||
mock_console.route_create_workstream.return_value = {
|
||||
"ws_id": "ws-456",
|
||||
"node_url": "http://node-1:8080",
|
||||
"node_id": "node-1",
|
||||
}
|
||||
_mock_console_ctx(mock_console_cls, mock_console)
|
||||
|
||||
_, result = _exec_on_node_sync({"host": "localhost"}, "node-1", "sleep 9999", 1.0)
|
||||
mock_server = MagicMock()
|
||||
mock_server.send_and_wait.return_value = turn_result
|
||||
_mock_server_ctx(mock_server_cls, mock_server)
|
||||
|
||||
_, result = _exec_on_node_sync(_CONSOLE_KW, "node-1", "sleep 9999", 1.0)
|
||||
assert result.timed_out
|
||||
assert not result.ok
|
||||
# Workstream still closed even on timeout
|
||||
mock_console.route_close.assert_called_once_with("ws-456")
|
||||
|
||||
def test_send_failure_still_closes_workstream(self):
|
||||
"""Workstream must be closed even if send_and_wait raises."""
|
||||
with (
|
||||
patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_console_cls,
|
||||
patch("mcp_cluster_ops.server.TurnstoneServer") as mock_server_cls,
|
||||
):
|
||||
mock_console = MagicMock()
|
||||
mock_console.route_create_workstream.return_value = {
|
||||
"ws_id": "ws-789",
|
||||
"node_url": "http://node-1:8080",
|
||||
"node_id": "node-1",
|
||||
}
|
||||
_mock_console_ctx(mock_console_cls, mock_console)
|
||||
|
||||
mock_server = MagicMock()
|
||||
mock_server.send_and_wait.side_effect = ConnectionError("lost connection")
|
||||
_mock_server_ctx(mock_server_cls, mock_server)
|
||||
|
||||
with contextlib.suppress(ConnectionError):
|
||||
_exec_on_node_sync(_CONSOLE_KW, "node-1", "echo hi", 60.0)
|
||||
|
||||
mock_console.route_close.assert_called_once_with("ws-789")
|
||||
|
||||
def test_malformed_route_response_no_leak(self):
|
||||
"""If route response is missing ws_id, no route_close is attempted."""
|
||||
with patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_console_cls:
|
||||
mock_console = MagicMock()
|
||||
mock_console.route_create_workstream.return_value = {
|
||||
# Missing "ws_id" and "node_url"
|
||||
"node_id": "node-1",
|
||||
}
|
||||
_mock_console_ctx(mock_console_cls, mock_console)
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
_exec_on_node_sync(_CONSOLE_KW, "node-1", "echo hi", 60.0)
|
||||
|
||||
# route_close must NOT be called — ws_id was never assigned
|
||||
mock_console.route_close.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -88,40 +227,32 @@ class TestExecOnNodeSync:
|
||||
|
||||
class TestDispatchParallel:
|
||||
def test_parallel_success(self):
|
||||
def fake_exec(server_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
return (node_id, TurnResult(tool_results=[("bash", f"output-{node_id}")]))
|
||||
def fake_exec(console_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
return (
|
||||
node_id,
|
||||
TurnResult(tool_results=[("bash", f"output-{node_id}")]),
|
||||
)
|
||||
|
||||
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
|
||||
results = asyncio.run(
|
||||
_dispatch_parallel(
|
||||
{"host": "localhost"},
|
||||
["a", "b", "c"],
|
||||
"echo hi",
|
||||
60.0,
|
||||
8192,
|
||||
)
|
||||
_dispatch_parallel(_CONSOLE_KW, ["a", "b", "c"], "echo hi", 60.0, 8192)
|
||||
)
|
||||
assert len(results) == 3
|
||||
assert all(r["ok"] for r in results)
|
||||
outputs = {r["node"]: r["output"] for r in results}
|
||||
assert outputs["a"] == "output-a"
|
||||
assert outputs["b"] == "output-b"
|
||||
assert outputs["c"] == "output-c"
|
||||
|
||||
def test_partial_failure(self):
|
||||
def fake_exec(server_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
def fake_exec(console_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
if node_id == "bad":
|
||||
raise ConnectionError("connection refused")
|
||||
return (node_id, TurnResult(tool_results=[("bash", "ok")]))
|
||||
|
||||
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
|
||||
results = asyncio.run(
|
||||
_dispatch_parallel(
|
||||
{"host": "localhost"},
|
||||
["good", "bad"],
|
||||
"echo hi",
|
||||
60.0,
|
||||
8192,
|
||||
)
|
||||
_dispatch_parallel(_CONSOLE_KW, ["good", "bad"], "echo hi", 60.0, 8192)
|
||||
)
|
||||
assert len(results) == 2
|
||||
good = next(r for r in results if r["node"] == "good")
|
||||
@@ -131,18 +262,12 @@ class TestDispatchParallel:
|
||||
assert "connection refused" in bad["error"]
|
||||
|
||||
def test_all_fail(self):
|
||||
def fake_exec(server_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
def fake_exec(console_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
raise RuntimeError(f"fail-{node_id}")
|
||||
|
||||
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
|
||||
results = asyncio.run(
|
||||
_dispatch_parallel(
|
||||
{"host": "localhost"},
|
||||
["a", "b"],
|
||||
"echo hi",
|
||||
60.0,
|
||||
8192,
|
||||
)
|
||||
_dispatch_parallel(_CONSOLE_KW, ["a", "b"], "echo hi", 60.0, 8192)
|
||||
)
|
||||
assert all(not r["ok"] for r in results)
|
||||
assert "fail-a" in results[0]["error"]
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.9.7"
|
||||
version = "1.0.0"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
@@ -12,7 +12,7 @@ requires-python = ">=3.11"
|
||||
authors = [{name = "Patrick Buckley", email = "buckleypm@gmail.com"}]
|
||||
keywords = ["ai", "chat", "llm", "agent", "tools", "openai"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
@@ -78,7 +78,7 @@ include = [
|
||||
"turnstone/shared_static/*.js",
|
||||
"turnstone/shared_static/katex-0.16.44/**/*",
|
||||
"turnstone/shared_static/hljs-11.11.1/**/*",
|
||||
"turnstone/shared_static/mermaid-11.13.0/**/*",
|
||||
"turnstone/shared_static/mermaid-11.14.0/**/*",
|
||||
"turnstone/sdk/py.typed",
|
||||
]
|
||||
|
||||
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Bump version, regenerate lockfile, commit, and tag.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/release.sh 1.0.0 # stable release
|
||||
# scripts/release.sh 1.1.0a1 # experimental pre-release
|
||||
# scripts/release.sh 1.0.1 --push # bump + push tag to origin
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?Usage: scripts/release.sh VERSION [--push]}"
|
||||
PUSH="${2:-}"
|
||||
|
||||
# Validate PEP 440 version
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(a[0-9]+|b[0-9]+|rc[0-9]+)?$'; then
|
||||
echo "error: invalid PEP 440 version: $VERSION" >&2
|
||||
echo " examples: 1.0.0, 1.1.0a1, 1.0.1rc2" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="v${VERSION}"
|
||||
|
||||
# Check for clean working tree
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "error: working tree is dirty — commit or stash first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check tag doesn't already exist
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "error: tag $TAG already exists" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect current version
|
||||
CURRENT=$(grep -oP '(?<=^version = ")[^"]+' pyproject.toml)
|
||||
echo "Bumping $CURRENT → $VERSION"
|
||||
|
||||
# Update version in both files
|
||||
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
|
||||
sed -i "s/^__version__ = \".*\"/__version__ = \"$VERSION\"/" turnstone/__init__.py
|
||||
|
||||
# Regenerate lockfile
|
||||
echo "Regenerating uv.lock..."
|
||||
uv lock
|
||||
|
||||
# Commit and tag
|
||||
git add pyproject.toml turnstone/__init__.py uv.lock
|
||||
git commit -m "chore: bump version to $VERSION"
|
||||
git tag "$TAG"
|
||||
|
||||
echo ""
|
||||
echo "Created commit and tag $TAG"
|
||||
|
||||
if [ "$PUSH" = "--push" ]; then
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo "Pushing $BRANCH + $TAG to origin..."
|
||||
git push origin "$BRANCH" "$TAG"
|
||||
else
|
||||
echo "Run 'git push origin <branch> $TAG' to publish"
|
||||
fi
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* const client = new TurnstoneServer({
|
||||
* baseUrl: "http://localhost:8080",
|
||||
* token: "tok_xxx",
|
||||
* token: "ts_your_api_token",
|
||||
* });
|
||||
*
|
||||
* const ws = await client.createWorkstream({ name: "demo" });
|
||||
|
||||
@@ -6,6 +6,37 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# Shared test auth — JWT-based
|
||||
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
|
||||
def _server_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-versioning",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_SERVER,
|
||||
)
|
||||
|
||||
|
||||
def _console_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-versioning",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
|
||||
|
||||
_SERVER_AUTH_HEADERS = {"Authorization": f"Bearer {_server_jwt()}"}
|
||||
_CONSOLE_AUTH_HEADERS = {"Authorization": f"Bearer {_console_jwt()}"}
|
||||
|
||||
|
||||
class TestServerVersioning:
|
||||
"""Test /v1/ routes and OpenAPI endpoints on the server."""
|
||||
@@ -14,7 +45,6 @@ class TestServerVersioning:
|
||||
def client(self):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.core.auth import AuthConfig
|
||||
from turnstone.server import create_app
|
||||
|
||||
mock_mgr = MagicMock()
|
||||
@@ -26,19 +56,19 @@ class TestServerVersioning:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
def test_v1_workstreams(self, client):
|
||||
resp = client.get("/v1/api/workstreams")
|
||||
resp = client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
assert "workstreams" in resp.json()
|
||||
|
||||
def test_unversioned_api_404(self, client):
|
||||
resp = client.get("/api/workstreams")
|
||||
resp = client.get("/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_openapi_json(self, client):
|
||||
@@ -72,7 +102,6 @@ class TestConsoleVersioning:
|
||||
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
@@ -84,18 +113,18 @@ class TestConsoleVersioning:
|
||||
}
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
def test_v1_cluster_overview(self, client):
|
||||
resp = client.get("/v1/api/cluster/overview")
|
||||
resp = client.get("/v1/api/cluster/overview", headers=_CONSOLE_AUTH_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_unversioned_api_404(self, client):
|
||||
resp = client.get("/api/cluster/overview")
|
||||
resp = client.get("/api/cluster/overview", headers=_CONSOLE_AUTH_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_openapi_json(self, client):
|
||||
|
||||
+263
-332
@@ -9,12 +9,11 @@ import pytest
|
||||
|
||||
from turnstone.core.auth import (
|
||||
WRITE_PATHS,
|
||||
AuthConfig,
|
||||
_extract_bearer,
|
||||
_extract_cookie,
|
||||
check_request,
|
||||
create_jwt,
|
||||
is_public_path,
|
||||
load_auth_config,
|
||||
make_clear_cookie,
|
||||
make_set_cookie,
|
||||
required_scope,
|
||||
@@ -199,37 +198,6 @@ class TestRequiredScope:
|
||||
assert required_scope("GET", "/api/_internal/mcp-reload") == "read"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAuthConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthConfig:
|
||||
def test_check_valid_full_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full", "tok_read": "read"})
|
||||
assert cfg.check("tok_full") == "full"
|
||||
|
||||
def test_check_valid_read_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full", "tok_read": "read"})
|
||||
assert cfg.check("tok_read") == "read"
|
||||
|
||||
def test_check_invalid_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
assert cfg.check("wrong") is None
|
||||
|
||||
def test_check_none_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
assert cfg.check(None) is None
|
||||
|
||||
def test_check_empty_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
assert cfg.check("") is None
|
||||
|
||||
def test_check_no_tokens(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={})
|
||||
assert cfg.check("anything") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestExtractBearer
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -353,167 +321,157 @@ class TestMakeClearCookie:
|
||||
class TestCheckRequest:
|
||||
"""Tests for the main check_request() entry point."""
|
||||
|
||||
@pytest.fixture()
|
||||
def disabled(self):
|
||||
return AuthConfig(enabled=False)
|
||||
_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
@pytest.fixture()
|
||||
def enabled(self):
|
||||
return AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
)
|
||||
def read_jwt(self):
|
||||
return f"Bearer {create_jwt('u1', frozenset({'read'}), 'test', self._SECRET)}"
|
||||
|
||||
def test_disabled_allows_all(self, disabled):
|
||||
allowed, status, msg, _result = check_request(disabled, "POST", "/api/send", None)
|
||||
@pytest.fixture()
|
||||
def full_jwt(self):
|
||||
return f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', self._SECRET)}"
|
||||
|
||||
def test_public_path_no_token_ok(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/health", None)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_disabled_allows_no_header(self, disabled):
|
||||
allowed, status, msg, _result = check_request(disabled, "GET", "/api/workstreams", None)
|
||||
def test_public_root_no_token_ok(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/", None)
|
||||
assert allowed is True
|
||||
|
||||
def test_public_path_no_token_ok(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/health", None)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_public_root_no_token_ok(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/", None)
|
||||
def test_public_static_no_token_ok(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/static/style.css", None)
|
||||
assert allowed is True
|
||||
|
||||
def test_public_static_no_token_ok(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/static/style.css", None)
|
||||
assert allowed is True
|
||||
|
||||
def test_api_no_token_401(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/api/workstreams", None)
|
||||
def test_api_no_token_401(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/api/workstreams", None)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
assert "Unauthorized" in msg
|
||||
|
||||
def test_api_invalid_token_401(self, enabled):
|
||||
def test_api_invalid_token_401(self):
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "GET", "/api/workstreams", "Bearer wrong_token"
|
||||
"GET", "/api/workstreams", "Bearer wrong_token"
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
def test_api_read_token_ok(self, enabled):
|
||||
def test_api_read_token_ok(self, read_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "GET", "/api/workstreams", "Bearer tok_read"
|
||||
"GET", "/api/workstreams", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_api_full_token_ok(self, enabled):
|
||||
def test_api_full_token_ok(self, full_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "GET", "/api/workstreams", "Bearer tok_full"
|
||||
"GET", "/api/workstreams", full_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_write_read_token_403(self, enabled):
|
||||
def test_write_read_token_403(self, read_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/send", "Bearer tok_read"
|
||||
"POST", "/api/send", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
assert "Forbidden" in msg
|
||||
|
||||
def test_write_full_token_ok(self, enabled):
|
||||
def test_write_full_token_ok(self, full_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/send", "Bearer tok_full"
|
||||
"POST", "/api/send", full_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_approve_read_token_403(self, enabled):
|
||||
def test_approve_read_token_403(self, read_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/approve", "Bearer tok_read"
|
||||
"POST", "/api/approve", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_proxy_write_read_token_403(self, enabled):
|
||||
def test_proxy_write_read_token_403(self, read_jwt):
|
||||
"""Read tokens cannot escalate to write ops via proxy routes."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/node/node-a/api/send", "Bearer tok_read"
|
||||
"POST", "/node/node-a/api/send", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_proxy_write_trailing_slash_read_token_403(self, enabled):
|
||||
def test_proxy_write_trailing_slash_read_token_403(self, read_jwt):
|
||||
"""Trailing slash must not bypass write-role check on proxy routes."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/node/node-a/api/send/", "Bearer tok_read"
|
||||
"POST", "/node/node-a/api/send/", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_direct_write_trailing_slash_read_token_403(self, enabled):
|
||||
def test_direct_write_trailing_slash_read_token_403(self, read_jwt):
|
||||
"""Trailing slash must not bypass write-role check on direct routes."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/send/", "Bearer tok_read"
|
||||
"POST", "/api/send/", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_proxy_write_full_token_ok(self, enabled):
|
||||
def test_proxy_write_full_token_ok(self, full_jwt):
|
||||
"""Full tokens pass through proxy write routes."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/node/node-a/api/send", "Bearer tok_full"
|
||||
"POST", "/node/node-a/api/send", full_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_proxy_v1_write_read_token_403(self, enabled):
|
||||
def test_proxy_v1_write_read_token_403(self, read_jwt):
|
||||
"""Read tokens cannot escalate to write ops via v1 proxy routes."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/node/node-a/v1/api/send", "Bearer tok_read"
|
||||
"POST", "/node/node-a/v1/api/send", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_proxy_v1_write_full_token_ok(self, enabled):
|
||||
def test_proxy_v1_write_full_token_ok(self, full_jwt):
|
||||
"""Full tokens pass through v1 proxy write routes."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/node/node-a/v1/api/send", "Bearer tok_full"
|
||||
"POST", "/node/node-a/v1/api/send", full_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_proxy_v1_cluster_ws_new_read_403(self, enabled):
|
||||
def test_proxy_v1_cluster_ws_new_read_403(self, read_jwt):
|
||||
"""Read tokens cannot create workstreams via v1 proxy."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/node/node-a/v1/api/cluster/workstreams/new",
|
||||
"Bearer tok_read",
|
||||
read_jwt,
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_proxy_read_endpoint_read_token_ok(self, enabled):
|
||||
def test_proxy_read_endpoint_read_token_ok(self, read_jwt):
|
||||
"""Read tokens can access proxy read endpoints."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "GET", "/node/node-a/api/workstreams", "Bearer tok_read"
|
||||
"GET", "/node/node-a/api/workstreams", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_console_create_ws_read_token_403(self, enabled):
|
||||
def test_console_create_ws_read_token_403(self, read_jwt):
|
||||
"""Read tokens cannot create workstreams."""
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/cluster/workstreams/new", "Bearer tok_read"
|
||||
"POST", "/api/cluster/workstreams/new", read_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_approve_full_token_ok(self, enabled):
|
||||
def test_approve_full_token_ok(self, full_jwt):
|
||||
allowed, status, msg, _result = check_request(
|
||||
enabled, "POST", "/api/approve", "Bearer tok_full"
|
||||
"POST", "/api/approve", full_jwt, jwt_secret=self._SECRET
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_no_auth_header_string(self, enabled):
|
||||
allowed, status, msg, _result = check_request(enabled, "GET", "/api/dashboard", "")
|
||||
def test_no_auth_header_string(self):
|
||||
allowed, status, msg, _result = check_request("GET", "/api/dashboard", "")
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
@@ -526,70 +484,71 @@ class TestCheckRequest:
|
||||
class TestCheckRequestWithCookie:
|
||||
"""Tests for cookie-based auth fallback in check_request."""
|
||||
|
||||
@pytest.fixture()
|
||||
def enabled(self):
|
||||
return AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
)
|
||||
_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
def test_cookie_fallback_when_no_bearer(self, enabled):
|
||||
@pytest.fixture()
|
||||
def read_jwt(self):
|
||||
return create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
||||
|
||||
@pytest.fixture()
|
||||
def full_jwt(self):
|
||||
return create_jwt("u1", frozenset({"read", "write", "approve"}), "test", self._SECRET)
|
||||
|
||||
def test_cookie_fallback_when_no_bearer(self, read_jwt):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
None,
|
||||
cookie_header="turnstone_auth=tok_read",
|
||||
cookie_header=f"turnstone_auth={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is True
|
||||
assert status == 200
|
||||
|
||||
def test_bearer_takes_precedence_over_cookie(self, enabled):
|
||||
# Bearer is full, cookie is read — Bearer should win
|
||||
def test_bearer_takes_precedence_over_cookie(self, read_jwt, full_jwt):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/send",
|
||||
"Bearer tok_full",
|
||||
cookie_header="turnstone_auth=tok_read",
|
||||
f"Bearer {full_jwt}",
|
||||
cookie_header=f"turnstone_auth={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_invalid_cookie_401(self, enabled):
|
||||
def test_invalid_cookie_401(self):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
None,
|
||||
cookie_header="turnstone_auth=wrong_token",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
def test_cookie_read_on_write_403(self, enabled):
|
||||
def test_cookie_read_on_write_403(self, read_jwt):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/send",
|
||||
None,
|
||||
cookie_header="turnstone_auth=tok_read",
|
||||
cookie_header=f"turnstone_auth={read_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is False
|
||||
assert status == 403
|
||||
|
||||
def test_cookie_full_on_write_ok(self, enabled):
|
||||
def test_cookie_full_on_write_ok(self, full_jwt):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/send",
|
||||
None,
|
||||
cookie_header="turnstone_auth=tok_full",
|
||||
cookie_header=f"turnstone_auth={full_jwt}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_no_cookie_no_bearer_401(self, enabled):
|
||||
def test_no_cookie_no_bearer_401(self):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"GET",
|
||||
"/api/workstreams",
|
||||
None,
|
||||
@@ -598,18 +557,16 @@ class TestCheckRequestWithCookie:
|
||||
assert allowed is False
|
||||
assert status == 401
|
||||
|
||||
def test_login_path_public(self, enabled):
|
||||
def test_login_path_public(self):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
None,
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
def test_logout_path_public(self, enabled):
|
||||
def test_logout_path_public(self):
|
||||
allowed, status, _, _r = check_request(
|
||||
enabled,
|
||||
"POST",
|
||||
"/api/auth/logout",
|
||||
None,
|
||||
@@ -617,139 +574,6 @@ class TestCheckRequestWithCookie:
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestLoadAuthConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadAuthConfig:
|
||||
"""Tests for load_auth_config with mocked config + env vars."""
|
||||
|
||||
def test_default_enabled(self):
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is True
|
||||
assert cfg.tokens == {}
|
||||
|
||||
def test_explicit_disable(self):
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={"enabled": False}),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_env_disable(self):
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "0"}, clear=True),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_config_file_tokens(self):
|
||||
mock_cfg = {
|
||||
"enabled": True,
|
||||
"tokens": [
|
||||
{"value": "tok_a", "role": "full"},
|
||||
{"value": "tok_b", "role": "read"},
|
||||
],
|
||||
}
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value=mock_cfg),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is True
|
||||
assert cfg.tokens == {"tok_a": "full", "tok_b": "read"}
|
||||
|
||||
def test_env_var_enabled(self):
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "1"}, clear=False),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is True
|
||||
|
||||
def test_env_var_token(self):
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {"TURNSTONE_AUTH_TOKEN": "tok_env"}, clear=False),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert "tok_env" in cfg.tokens
|
||||
assert cfg.tokens["tok_env"] == "full"
|
||||
|
||||
def test_config_plus_env_merge(self):
|
||||
mock_cfg = {
|
||||
"enabled": True,
|
||||
"tokens": [{"value": "tok_cfg", "role": "read"}],
|
||||
}
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value=mock_cfg),
|
||||
patch.dict(os.environ, {"TURNSTONE_AUTH_TOKEN": "tok_env"}, clear=False),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.tokens["tok_cfg"] == "read"
|
||||
assert cfg.tokens["tok_env"] == "full"
|
||||
|
||||
def test_invalid_role_skipped(self):
|
||||
mock_cfg = {
|
||||
"enabled": True,
|
||||
"tokens": [
|
||||
{"value": "tok_ok", "role": "full"},
|
||||
{"value": "tok_bad", "role": "admin"},
|
||||
],
|
||||
}
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value=mock_cfg),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert "tok_ok" in cfg.tokens
|
||||
assert "tok_bad" not in cfg.tokens
|
||||
|
||||
def test_empty_value_skipped(self):
|
||||
mock_cfg = {
|
||||
"enabled": True,
|
||||
"tokens": [{"value": "", "role": "full"}],
|
||||
}
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value=mock_cfg),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert len(cfg.tokens) == 0
|
||||
|
||||
def test_non_dict_token_entry_skipped(self):
|
||||
mock_cfg = {
|
||||
"enabled": True,
|
||||
"tokens": ["not_a_dict", {"value": "tok_ok", "role": "full"}],
|
||||
}
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value=mock_cfg),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.tokens == {"tok_ok": "full"}
|
||||
|
||||
def test_env_enabled_true(self):
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "true"}, clear=False),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is True
|
||||
|
||||
def test_env_enabled_yes(self):
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "yes"}, clear=False),
|
||||
):
|
||||
cfg = load_auth_config()
|
||||
assert cfg.enabled is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — actual HTTP server with auth enabled
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -785,16 +609,22 @@ class TestServerAuth:
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
mock_mgr.max_workstreams = 10
|
||||
|
||||
from turnstone.core.auth import JWT_AUD_SERVER
|
||||
|
||||
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
|
||||
cls._read_hdr = {
|
||||
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read'}), 'test', cls._jwt_secret, audience=JWT_AUD_SERVER)}"
|
||||
}
|
||||
cls._full_hdr = {
|
||||
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', cls._jwt_secret, audience=JWT_AUD_SERVER)}"
|
||||
}
|
||||
app = srv_mod.create_app(
|
||||
workstreams=mock_mgr,
|
||||
global_queue=queue.Queue(),
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
),
|
||||
jwt_secret=cls._jwt_secret,
|
||||
cors_origins=["*"],
|
||||
)
|
||||
cls.client = TestClient(app, raise_server_exceptions=False)
|
||||
@@ -809,7 +639,6 @@ class TestServerAuth:
|
||||
|
||||
def test_metrics_no_token_passes_auth(self):
|
||||
resp = self.client.get("/metrics")
|
||||
# Public path — should never be 401/403
|
||||
assert resp.status_code not in (401, 403)
|
||||
|
||||
def test_root_no_token_200(self):
|
||||
@@ -826,23 +655,17 @@ class TestServerAuth:
|
||||
assert "Unauthorized" in resp.json().get("error", "")
|
||||
|
||||
def test_api_workstreams_read_token_200(self):
|
||||
resp = self.client.get(
|
||||
"/v1/api/workstreams",
|
||||
headers={"Authorization": "Bearer tok_read"},
|
||||
)
|
||||
resp = self.client.get("/v1/api/workstreams", headers=self._read_hdr)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_api_workstreams_full_token_200(self):
|
||||
resp = self.client.get(
|
||||
"/v1/api/workstreams",
|
||||
headers={"Authorization": "Bearer tok_full"},
|
||||
)
|
||||
resp = self.client.get("/v1/api/workstreams", headers=self._full_hdr)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_api_send_read_token_403(self):
|
||||
resp = self.client.post(
|
||||
"/v1/api/send",
|
||||
headers={"Authorization": "Bearer tok_read"},
|
||||
headers=self._read_hdr,
|
||||
json={"message": "hello", "ws_id": "x"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -851,10 +674,9 @@ class TestServerAuth:
|
||||
def test_api_send_full_token_passes_auth(self):
|
||||
resp = self.client.post(
|
||||
"/v1/api/send",
|
||||
headers={"Authorization": "Bearer tok_full"},
|
||||
headers=self._full_hdr,
|
||||
json={"message": "hello", "ws_id": "nonexistent"},
|
||||
)
|
||||
# Should get 404 (unknown workstream), not 401/403
|
||||
assert resp.status_code not in (401, 403)
|
||||
|
||||
def test_api_send_no_token_401(self):
|
||||
@@ -921,12 +743,18 @@ class TestConsoleAuth:
|
||||
"aggregate": {"total_tokens": 100},
|
||||
}
|
||||
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE
|
||||
|
||||
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
|
||||
cls._read_hdr = {
|
||||
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read'}), 'test', cls._jwt_secret, audience=JWT_AUD_CONSOLE)}"
|
||||
}
|
||||
cls._full_hdr = {
|
||||
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', cls._jwt_secret, audience=JWT_AUD_CONSOLE)}"
|
||||
}
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
),
|
||||
jwt_secret=cls._jwt_secret,
|
||||
)
|
||||
cls.test_client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@@ -947,17 +775,11 @@ class TestConsoleAuth:
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_api_overview_read_token_200(self):
|
||||
resp = self.test_client.get(
|
||||
"/v1/api/cluster/overview",
|
||||
headers={"Authorization": "Bearer tok_read"},
|
||||
)
|
||||
resp = self.test_client.get("/v1/api/cluster/overview", headers=self._read_hdr)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_api_overview_full_token_200(self):
|
||||
resp = self.test_client.get(
|
||||
"/v1/api/cluster/overview",
|
||||
headers={"Authorization": "Bearer tok_full"},
|
||||
)
|
||||
resp = self.test_client.get("/v1/api/cluster/overview", headers=self._full_hdr)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_invalid_token_401(self):
|
||||
@@ -1003,16 +825,33 @@ class TestServerLogin:
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
mock_mgr.max_workstreams = 10
|
||||
|
||||
# Mock storage with a test user for password login
|
||||
from turnstone.core.auth import hash_password
|
||||
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.get_user_by_username.side_effect = lambda u: (
|
||||
{
|
||||
"user_id": "uid_test",
|
||||
"username": "testuser",
|
||||
"password_hash": hash_password("testpass"),
|
||||
"display_name": "Test",
|
||||
}
|
||||
if u == "testuser"
|
||||
else None
|
||||
)
|
||||
mock_storage.list_user_roles.return_value = [
|
||||
{"role_id": "builtin-admin", "scopes": "read,write,approve"}
|
||||
]
|
||||
|
||||
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
|
||||
app = srv_mod.create_app(
|
||||
workstreams=mock_mgr,
|
||||
global_queue=queue.Queue(),
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
),
|
||||
jwt_secret=cls._jwt_secret,
|
||||
auth_storage=mock_storage,
|
||||
)
|
||||
cls.test_client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@@ -1020,36 +859,36 @@ class TestServerLogin:
|
||||
def teardown_class(cls):
|
||||
cls.test_client.close()
|
||||
|
||||
def test_login_valid_token_sets_cookie(self):
|
||||
def test_login_config_token_rejected(self):
|
||||
"""Config token exchange is no longer allowed."""
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"token": "tok_full"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["role"] == "full"
|
||||
cookie = resp.headers.get("set-cookie", "")
|
||||
assert "turnstone_auth=tok_full" in cookie
|
||||
assert "HttpOnly" in cookie
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_invalid_token_401(self):
|
||||
def test_login_invalid_credentials_401(self):
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"token": "wrong"},
|
||||
json={"username": "testuser", "password": "wrong"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_no_auth_required(self):
|
||||
# /v1/api/auth/login is public — shouldn't require auth itself
|
||||
def test_login_password_ok(self):
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"token": "tok_read"},
|
||||
json={"username": "testuser", "password": "testpass"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "jwt" in data
|
||||
|
||||
def test_cookie_auth_on_api(self):
|
||||
# Login to get cookie (TestClient tracks cookies automatically)
|
||||
login_resp = self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
|
||||
login_resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"username": "testuser", "password": "testpass"},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
# Use cookie to access API — TestClient forwards cookies
|
||||
@@ -1057,7 +896,10 @@ class TestServerLogin:
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_logout_clears_cookie(self):
|
||||
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
|
||||
self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"username": "testuser", "password": "testpass"},
|
||||
)
|
||||
|
||||
# Logout
|
||||
logout_resp = self.test_client.post("/v1/api/auth/logout")
|
||||
@@ -1081,6 +923,7 @@ class TestConsoleLogin:
|
||||
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import hash_password
|
||||
|
||||
_load_static()
|
||||
|
||||
@@ -1092,12 +935,26 @@ class TestConsoleLogin:
|
||||
"aggregate": {"total_tokens": 100},
|
||||
}
|
||||
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.get_user_by_username.side_effect = lambda u: (
|
||||
{
|
||||
"user_id": "uid_test",
|
||||
"username": "testuser",
|
||||
"password_hash": hash_password("testpass"),
|
||||
"display_name": "Test",
|
||||
}
|
||||
if u == "testuser"
|
||||
else None
|
||||
)
|
||||
mock_storage.list_user_roles.return_value = [
|
||||
{"role_id": "builtin-admin", "scopes": "read,write,approve"}
|
||||
]
|
||||
|
||||
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(
|
||||
enabled=True,
|
||||
tokens={"tok_full": "full", "tok_read": "read"},
|
||||
),
|
||||
jwt_secret=cls._jwt_secret,
|
||||
auth_storage=mock_storage,
|
||||
)
|
||||
cls.test_client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@@ -1105,28 +962,34 @@ class TestConsoleLogin:
|
||||
def teardown_class(cls):
|
||||
cls.test_client.close()
|
||||
|
||||
def test_login_valid_token(self):
|
||||
def test_login_config_token_rejected(self):
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"token": "tok_read"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_password_ok(self):
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"username": "testuser", "password": "testpass"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "turnstone_auth" in resp.headers.get("set-cookie", "")
|
||||
|
||||
def test_login_invalid_token(self):
|
||||
resp = self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"token": "wrong"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_cookie_auth_on_api(self):
|
||||
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
|
||||
self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"username": "testuser", "password": "testpass"},
|
||||
)
|
||||
resp = self.test_client.get("/v1/api/cluster/overview")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_logout_then_api_fails(self):
|
||||
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
|
||||
self.test_client.post(
|
||||
"/v1/api/auth/login",
|
||||
json={"username": "testuser", "password": "testpass"},
|
||||
)
|
||||
self.test_client.post("/v1/api/auth/logout")
|
||||
resp = self.test_client.get("/v1/api/cluster/overview")
|
||||
assert resp.status_code == 401
|
||||
@@ -1385,25 +1248,29 @@ class TestIsSecureRequest:
|
||||
|
||||
|
||||
class TestSecretStrength:
|
||||
def test_short_secret_warns(self, caplog):
|
||||
import logging
|
||||
def test_short_secret_exits(self):
|
||||
import turnstone.core.auth as auth_mod
|
||||
|
||||
from turnstone.core.auth import _MIN_SECRET_LENGTH
|
||||
old = os.environ.get("TURNSTONE_JWT_SECRET", "")
|
||||
os.environ["TURNSTONE_JWT_SECRET"] = "short"
|
||||
try:
|
||||
with pytest.raises(SystemExit):
|
||||
auth_mod.load_jwt_secret()
|
||||
finally:
|
||||
if old:
|
||||
os.environ["TURNSTONE_JWT_SECRET"] = old
|
||||
else:
|
||||
os.environ.pop("TURNSTONE_JWT_SECRET", None)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="turnstone.core.auth"):
|
||||
import turnstone.core.auth as auth_mod
|
||||
def test_missing_secret_exits(self):
|
||||
import turnstone.core.auth as auth_mod
|
||||
|
||||
old = os.environ.get("TURNSTONE_JWT_SECRET", "")
|
||||
os.environ["TURNSTONE_JWT_SECRET"] = "short"
|
||||
try:
|
||||
secret = auth_mod.load_jwt_secret()
|
||||
assert secret == "short"
|
||||
assert any(str(_MIN_SECRET_LENGTH) in r.message for r in caplog.records)
|
||||
finally:
|
||||
if old:
|
||||
os.environ["TURNSTONE_JWT_SECRET"] = old
|
||||
else:
|
||||
os.environ.pop("TURNSTONE_JWT_SECRET", None)
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
auth_mod.load_jwt_secret()
|
||||
|
||||
|
||||
class TestCorsConfigurable:
|
||||
@@ -1424,7 +1291,6 @@ class TestCorsConfigurable:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(enabled=False),
|
||||
)
|
||||
client = TestClient(app)
|
||||
resp = client.get("/health", headers={"Origin": "http://evil.com"})
|
||||
@@ -1446,7 +1312,6 @@ class TestCorsConfigurable:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(enabled=False),
|
||||
cors_origins=["http://example.com"],
|
||||
)
|
||||
client = TestClient(app)
|
||||
@@ -1502,3 +1367,69 @@ class TestOIDCPublicPaths:
|
||||
def test_oidc_callback_is_public(self):
|
||||
assert is_public_path("/api/auth/oidc/callback") is True
|
||||
assert is_public_path("/v1/api/auth/oidc/callback") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestRequirePermissionServiceScope — service scope bypasses permission checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRequirePermissionServiceScope:
|
||||
"""Verify require_permission() behaviour with the service scope."""
|
||||
|
||||
def _make_request(self, auth_result):
|
||||
"""Build a mock Starlette request with the given AuthResult on state."""
|
||||
request = MagicMock()
|
||||
request.state.auth_result = auth_result
|
||||
return request
|
||||
|
||||
def test_service_scope_bypasses_permission(self):
|
||||
"""Service-scoped tokens bypass all permission checks (returns None)."""
|
||||
from turnstone.core.auth import AuthResult, require_permission
|
||||
|
||||
auth = AuthResult(
|
||||
user_id="svc-agent",
|
||||
scopes=frozenset({"service"}),
|
||||
token_source="jwt",
|
||||
)
|
||||
request = self._make_request(auth)
|
||||
result = require_permission(request, "admin.users")
|
||||
assert result is None # bypass — no 403
|
||||
|
||||
def test_without_service_scope_and_without_permission_returns_403(self):
|
||||
"""Non-service tokens without the required permission get 403."""
|
||||
from turnstone.core.auth import AuthResult, require_permission
|
||||
|
||||
auth = AuthResult(
|
||||
user_id="regular-user",
|
||||
scopes=frozenset({"read", "write"}),
|
||||
token_source="jwt",
|
||||
)
|
||||
request = self._make_request(auth)
|
||||
result = require_permission(request, "admin.users")
|
||||
assert result is not None
|
||||
assert result.status_code == 403
|
||||
|
||||
def test_without_service_scope_with_permission_returns_none(self):
|
||||
"""Non-service tokens with the required permission pass."""
|
||||
from turnstone.core.auth import AuthResult, require_permission
|
||||
|
||||
auth = AuthResult(
|
||||
user_id="admin-user",
|
||||
scopes=frozenset({"read", "write", "approve"}),
|
||||
token_source="jwt",
|
||||
permissions=frozenset({"admin.users"}),
|
||||
)
|
||||
request = self._make_request(auth)
|
||||
result = require_permission(request, "admin.users")
|
||||
assert result is None # granted — no 403
|
||||
|
||||
def test_no_auth_result_returns_401(self):
|
||||
"""Missing auth_result on request state returns 401."""
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
request = MagicMock()
|
||||
del request.state.auth_result # ensure attribute is absent
|
||||
result = require_permission(request, "admin.users")
|
||||
assert result is not None
|
||||
assert result.status_code == 401
|
||||
|
||||
+37
-57
@@ -7,7 +7,6 @@ import time
|
||||
import pytest
|
||||
|
||||
from turnstone.core.auth import (
|
||||
AuthConfig,
|
||||
AuthResult,
|
||||
_authenticate_token,
|
||||
check_request,
|
||||
@@ -203,24 +202,10 @@ class TestRequiredScope:
|
||||
|
||||
|
||||
class TestAuthenticateToken:
|
||||
def test_config_token_read(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
result = _authenticate_token("tok_read", cfg)
|
||||
assert result is not None
|
||||
assert result.scopes == frozenset({"read"})
|
||||
assert result.token_source == "config"
|
||||
|
||||
def test_config_token_full(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
result = _authenticate_token("tok_full", cfg)
|
||||
assert result is not None
|
||||
assert result.scopes == frozenset({"read", "write", "approve"})
|
||||
|
||||
def test_jwt_token(self):
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("user1", frozenset({"read", "write"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(jwt_tok, cfg, jwt_secret=secret)
|
||||
result = _authenticate_token(jwt_tok, jwt_secret=secret)
|
||||
assert result is not None
|
||||
assert result.user_id == "user1"
|
||||
assert result.token_source == "db"
|
||||
@@ -243,8 +228,7 @@ class TestAuthenticateToken:
|
||||
}
|
||||
return None
|
||||
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(raw, cfg, storage=MockStorage())
|
||||
result = _authenticate_token(raw, storage=MockStorage())
|
||||
assert result is not None
|
||||
assert result.user_id == "user1"
|
||||
assert result.has_scope("write")
|
||||
@@ -266,13 +250,11 @@ class TestAuthenticateToken:
|
||||
"expires": "2020-01-02T00:00:00",
|
||||
}
|
||||
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(raw, cfg, storage=MockStorage())
|
||||
result = _authenticate_token(raw, storage=MockStorage())
|
||||
assert result is None
|
||||
|
||||
def test_unknown_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok": "full"})
|
||||
result = _authenticate_token("unknown", cfg)
|
||||
result = _authenticate_token("unknown")
|
||||
assert result is None
|
||||
|
||||
|
||||
@@ -282,76 +264,74 @@ class TestAuthenticateToken:
|
||||
|
||||
|
||||
class TestCheckRequestScopes:
|
||||
def test_config_read_on_write_403(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
allowed, status, msg, _ = check_request(cfg, "POST", "/api/send", "Bearer tok_read")
|
||||
_SECRET = "test-secret-key-for-jwt-min-32b!"
|
||||
|
||||
def test_jwt_read_on_write_403(self):
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
||||
allowed, status, msg, _ = check_request(
|
||||
"POST",
|
||||
"/api/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
assert "write" in msg
|
||||
|
||||
def test_config_read_on_approve_403(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
allowed, status, msg, _ = check_request(cfg, "POST", "/api/approve", "Bearer tok_read")
|
||||
def test_jwt_read_on_approve_403(self):
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
||||
allowed, status, msg, _ = check_request(
|
||||
"POST",
|
||||
"/api/approve",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
assert "approve" in msg
|
||||
|
||||
def test_config_full_on_approve_ok(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
allowed, status, msg, result = check_request(cfg, "POST", "/api/approve", "Bearer tok_full")
|
||||
def test_jwt_full_on_approve_ok(self):
|
||||
jwt_tok = create_jwt("u1", frozenset({"read", "write", "approve"}), "test", self._SECRET)
|
||||
allowed, status, msg, result = check_request(
|
||||
"POST",
|
||||
"/api/approve",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
assert result.has_scope("approve")
|
||||
|
||||
def test_jwt_with_scopes(self):
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", self._SECRET)
|
||||
allowed, status, msg, result = check_request(
|
||||
cfg,
|
||||
"POST",
|
||||
"/api/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=secret,
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
assert result.user_id == "u1"
|
||||
|
||||
def test_jwt_insufficient_scope(self):
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", self._SECRET)
|
||||
allowed, status, msg, _ = check_request(
|
||||
cfg,
|
||||
"POST",
|
||||
"/api/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=secret,
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
|
||||
def test_admin_path_requires_approve(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
||||
allowed, status, msg, _ = check_request(
|
||||
cfg,
|
||||
"GET",
|
||||
"/v1/api/admin/users",
|
||||
"Bearer tok_read",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
|
||||
def test_backward_compat_role_full(self):
|
||||
"""Config tokens with role='full' get all scopes."""
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
allowed, _, _, result = check_request(
|
||||
cfg,
|
||||
"GET",
|
||||
"/v1/api/admin/users",
|
||||
"Bearer tok_full",
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
assert result.has_scope("approve")
|
||||
|
||||
@@ -229,13 +229,15 @@ class TestMessageCog:
|
||||
|
||||
ts.router.send_message.assert_not_awaited()
|
||||
|
||||
def test_ignores_dms(self):
|
||||
def test_dm_without_reference_sends_guidance(self):
|
||||
cog, ts, _bot = self._make_cog()
|
||||
msg = _make_message(guild=False)
|
||||
dm_channel = AsyncMock()
|
||||
msg = _make_message(guild=False, channel=dm_channel)
|
||||
|
||||
_run(cog._on_message(msg))
|
||||
|
||||
ts.router.send_message.assert_not_awaited()
|
||||
dm_channel.send.assert_awaited_once()
|
||||
|
||||
def test_ignores_non_allowed_channels(self):
|
||||
cog, ts, _bot = self._make_cog()
|
||||
@@ -329,6 +331,8 @@ class TestWsEventFinalization:
|
||||
bot.config.auto_approve = False
|
||||
bot.config.auto_approve_tools = []
|
||||
bot._streaming = {}
|
||||
bot._thinking_msgs = {}
|
||||
bot._tool_info_msgs = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_reply_channels = {}
|
||||
|
||||
@@ -358,6 +362,8 @@ class TestWsEventFinalization:
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot._streaming = {}
|
||||
bot._thinking_msgs = {}
|
||||
bot._tool_info_msgs = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_reply_channels = {}
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
@@ -391,6 +397,8 @@ class TestApprovalVerdictDisplay:
|
||||
bot.config.auto_approve_tools = []
|
||||
bot.storage = None
|
||||
bot._streaming = {}
|
||||
bot._thinking_msgs = {}
|
||||
bot._tool_info_msgs = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_reply_channels = {}
|
||||
bot._should_auto_approve = MagicMock(return_value=False)
|
||||
@@ -509,6 +517,8 @@ class TestApprovalVerdictDisplay:
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot._streaming = {}
|
||||
bot._thinking_msgs = {}
|
||||
bot._tool_info_msgs = {}
|
||||
bot._pending_approval_msgs = {"ws-1": MagicMock()}
|
||||
bot._notify_reply_channels = {}
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
@@ -533,6 +543,8 @@ class TestStreamEndBehavior:
|
||||
bot.config.auto_approve = False
|
||||
bot.config.auto_approve_tools = []
|
||||
bot._streaming = {}
|
||||
bot._thinking_msgs = {}
|
||||
bot._tool_info_msgs = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_reply_channels = {}
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
@@ -702,8 +714,8 @@ class TestNotificationTracking:
|
||||
# Should send feedback to the DM channel
|
||||
dm_channel.send.assert_awaited_once_with("*This notification is no longer active.*")
|
||||
|
||||
def test_dm_without_reference_ignored(self):
|
||||
"""DM without a message reference should be ignored."""
|
||||
def test_dm_without_reference_sends_guidance(self):
|
||||
"""DM without a message reference should reply with guidance."""
|
||||
from turnstone.channels.discord.cog import MessageCog
|
||||
|
||||
bot = MagicMock()
|
||||
@@ -718,11 +730,15 @@ class TestNotificationTracking:
|
||||
bot.turnstone = ts
|
||||
|
||||
cog = MessageCog(bot)
|
||||
msg = _make_message(guild=False) # reference=None
|
||||
dm_channel = AsyncMock()
|
||||
msg = _make_message(guild=False, channel=dm_channel) # reference=None
|
||||
|
||||
_run(cog._on_message(msg))
|
||||
|
||||
ts.router.send_message.assert_not_awaited()
|
||||
dm_channel.send.assert_awaited_once()
|
||||
sent_text = dm_channel.send.call_args[0][0]
|
||||
assert "/ask" in sent_text
|
||||
|
||||
def test_dm_reply_unlinked_user_ignored(self):
|
||||
"""DM reply from an unlinked user should be ignored."""
|
||||
@@ -760,6 +776,8 @@ class TestNotificationTracking:
|
||||
bot.config.max_message_length = 2000
|
||||
bot.config.streaming_edit_interval = 1.5
|
||||
bot._streaming = {}
|
||||
bot._thinking_msgs = {}
|
||||
bot._tool_info_msgs = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_ws_map = {}
|
||||
bot._MAX_NOTIFY_TRACKING = 100
|
||||
@@ -797,6 +815,8 @@ class TestNotificationTracking:
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot._streaming = {}
|
||||
bot._thinking_msgs = {}
|
||||
bot._tool_info_msgs = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_ws_map = {}
|
||||
|
||||
@@ -817,6 +837,482 @@ class TestNotificationTracking:
|
||||
assert len(bot._notify_ws_map) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formatter: format_tool_result
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatToolResult:
|
||||
"""Tests for format_tool_result in _formatter.py."""
|
||||
|
||||
def test_basic_output(self):
|
||||
from turnstone.channels._formatter import format_tool_result
|
||||
|
||||
result = format_tool_result("hello world")
|
||||
assert "```" in result
|
||||
assert "hello world" in result
|
||||
|
||||
def test_wraps_in_code_block(self):
|
||||
from turnstone.channels._formatter import format_tool_result
|
||||
|
||||
result = format_tool_result("output text")
|
||||
assert result.startswith("```\n")
|
||||
assert result.endswith("\n```")
|
||||
|
||||
def test_truncates_long_output_by_lines(self):
|
||||
from turnstone.channels._formatter import format_tool_result
|
||||
|
||||
output = "\n".join(f"line {i}" for i in range(20))
|
||||
result = format_tool_result(output)
|
||||
# Should have at most 10 content lines + ellipsis
|
||||
inner = result.split("```")[1]
|
||||
assert inner.strip().count("\n") <= 11
|
||||
|
||||
def test_truncates_long_output_by_chars(self):
|
||||
from turnstone.channels._formatter import format_tool_result
|
||||
|
||||
output = "x" * 600
|
||||
result = format_tool_result(output)
|
||||
# Code block content should be <= 500 chars (497 + ellipsis)
|
||||
inner = result.split("```")[1].strip()
|
||||
assert len(inner) <= 501 # 497 + ellipsis char
|
||||
|
||||
def test_escapes_triple_backticks_in_output(self):
|
||||
from turnstone.channels._formatter import format_tool_result
|
||||
|
||||
output = "before ``` after"
|
||||
result = format_tool_result(output)
|
||||
# Only the opening and closing code fences should remain as ```.
|
||||
assert result.count("```") == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thinking indicator lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestThinkingIndicator:
|
||||
"""Tests for ThinkingStart/Stop event handling in the Discord bot."""
|
||||
|
||||
def _make_bot(self):
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot.config = MagicMock()
|
||||
bot.config.max_message_length = 2000
|
||||
bot.config.streaming_edit_interval = 1.5
|
||||
bot.config.auto_approve = False
|
||||
bot.config.auto_approve_tools = []
|
||||
bot.storage = None
|
||||
bot._streaming = {}
|
||||
bot._thinking_msgs = {}
|
||||
bot._tool_info_msgs = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_reply_channels = {}
|
||||
bot._should_auto_approve = MagicMock(return_value=False)
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
return bot
|
||||
|
||||
def test_thinking_start_sends_message(self):
|
||||
from turnstone.sdk.events import ThinkingStartEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
sent_msg = MagicMock()
|
||||
thread.send = AsyncMock(return_value=sent_msg)
|
||||
|
||||
event = ThinkingStartEvent(ws_id="ws-1")
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
thread.send.assert_awaited_once_with("*Thinking...*")
|
||||
assert bot._thinking_msgs["ws-1"] is sent_msg
|
||||
|
||||
def test_thinking_stop_preserves_message_for_reuse(self):
|
||||
from turnstone.sdk.events import ThinkingStopEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
thinking_msg = MagicMock()
|
||||
thinking_msg.delete = AsyncMock()
|
||||
bot._thinking_msgs["ws-1"] = thinking_msg
|
||||
|
||||
event = ThinkingStopEvent(ws_id="ws-1")
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
# Message kept for next event to reuse via edit.
|
||||
thinking_msg.delete.assert_not_awaited()
|
||||
assert "ws-1" in bot._thinking_msgs
|
||||
|
||||
def test_thinking_stop_without_message_is_noop(self):
|
||||
from turnstone.sdk.events import ThinkingStopEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
event = ThinkingStopEvent(ws_id="ws-1")
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
def test_content_event_reuses_thinking_message(self):
|
||||
from turnstone.sdk.events import ContentEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
thinking_msg = MagicMock()
|
||||
thinking_msg.edit = AsyncMock()
|
||||
bot._thinking_msgs["ws-1"] = thinking_msg
|
||||
|
||||
event = ContentEvent(ws_id="ws-1", text="Hello")
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
# Thinking message becomes the StreamingMessage base — no delete.
|
||||
assert "ws-1" not in bot._thinking_msgs
|
||||
sm = bot._streaming["ws-1"]
|
||||
assert sm._message is thinking_msg
|
||||
|
||||
def test_stream_end_clears_thinking_message(self):
|
||||
from turnstone.sdk.events import StreamEndEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
thinking_msg = MagicMock()
|
||||
thinking_msg.delete = AsyncMock()
|
||||
bot._thinking_msgs["ws-1"] = thinking_msg
|
||||
bot._notify_reply_channels = {}
|
||||
|
||||
event = StreamEndEvent(ws_id="ws-1")
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
thinking_msg.delete.assert_awaited_once()
|
||||
assert "ws-1" not in bot._thinking_msgs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool info / result embeds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolInfoEvent:
|
||||
"""Tests for ToolInfoEvent handling in the Discord bot."""
|
||||
|
||||
def _make_bot(self):
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot.config = MagicMock()
|
||||
bot.config.max_message_length = 2000
|
||||
bot.config.streaming_edit_interval = 1.5
|
||||
bot.config.auto_approve = False
|
||||
bot.config.auto_approve_tools = []
|
||||
bot.storage = None
|
||||
bot._streaming = {}
|
||||
bot._thinking_msgs = {}
|
||||
bot._tool_info_msgs = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_reply_channels = {}
|
||||
bot._should_auto_approve = MagicMock(return_value=False)
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
return bot
|
||||
|
||||
def test_sends_per_item_embed(self):
|
||||
from turnstone.sdk.events import ToolInfoEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
sent_msg = MagicMock()
|
||||
thread.send = AsyncMock(return_value=sent_msg)
|
||||
|
||||
items = [{"func_name": "bash", "preview": "ls -la", "needs_approval": False}]
|
||||
event = ToolInfoEvent(ws_id="ws-1", items=items)
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
thread.send.assert_awaited_once()
|
||||
embed = thread.send.call_args[1]["embed"]
|
||||
assert embed.title == "bash"
|
||||
assert embed.description == "ls -la"
|
||||
# Message tracked for later editing by ToolResultEvent.
|
||||
assert bot._tool_info_msgs["ws-1"] == [("", "bash", "ls -la", sent_msg)]
|
||||
|
||||
def test_multiple_tools_send_multiple_embeds(self):
|
||||
from turnstone.sdk.events import ToolInfoEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
items = [
|
||||
{"func_name": "bash", "preview": "ls", "needs_approval": False},
|
||||
{"func_name": "read_file", "preview": "/etc", "needs_approval": False},
|
||||
]
|
||||
event = ToolInfoEvent(ws_id="ws-1", items=items)
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
assert thread.send.await_count == 2
|
||||
assert len(bot._tool_info_msgs["ws-1"]) == 2
|
||||
|
||||
def test_shows_all_items_regardless_of_approval(self):
|
||||
from turnstone.sdk.events import ToolInfoEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
items = [
|
||||
{"func_name": "bash", "preview": "rm -rf /", "needs_approval": True},
|
||||
{"func_name": "read_file", "preview": "/etc/hosts", "needs_approval": False},
|
||||
]
|
||||
event = ToolInfoEvent(ws_id="ws-1", items=items)
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
# Both items shown — running indicator is separate from approval dialog.
|
||||
assert thread.send.await_count == 2
|
||||
|
||||
def test_reuses_thinking_message_for_first_tool(self):
|
||||
from turnstone.sdk.events import ToolInfoEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
thinking_msg = MagicMock()
|
||||
thinking_msg.edit = AsyncMock()
|
||||
bot._thinking_msgs["ws-1"] = thinking_msg
|
||||
|
||||
items = [{"func_name": "bash", "preview": "ls -la", "needs_approval": False}]
|
||||
event = ToolInfoEvent(ws_id="ws-1", items=items)
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
# Thinking message edited into tool embed, no new message sent.
|
||||
thinking_msg.edit.assert_awaited_once()
|
||||
thread.send.assert_not_awaited()
|
||||
assert "ws-1" not in bot._thinking_msgs
|
||||
# The reused message is tracked for ToolResultEvent editing.
|
||||
assert bot._tool_info_msgs["ws-1"][0][3] is thinking_msg
|
||||
|
||||
|
||||
class TestToolResultEvent:
|
||||
"""Tests for ToolResultEvent handling in the Discord bot."""
|
||||
|
||||
def _make_bot(self):
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot.config = MagicMock()
|
||||
bot.config.max_message_length = 2000
|
||||
bot.config.streaming_edit_interval = 1.5
|
||||
bot.config.auto_approve = False
|
||||
bot.config.auto_approve_tools = []
|
||||
bot.storage = None
|
||||
bot._streaming = {}
|
||||
bot._thinking_msgs = {}
|
||||
bot._tool_info_msgs = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_reply_channels = {}
|
||||
bot._should_auto_approve = MagicMock(return_value=False)
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
return bot
|
||||
|
||||
def test_marks_info_done_and_sends_result(self):
|
||||
from turnstone.sdk.events import ToolResultEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
# Pre-populate a tool info message (as ToolInfoEvent would).
|
||||
info_msg = MagicMock()
|
||||
info_msg.edit = AsyncMock()
|
||||
bot._tool_info_msgs["ws-1"] = [("", "bash", "ls -la", info_msg)]
|
||||
|
||||
event = ToolResultEvent(ws_id="ws-1", name="bash", output="file1\nfile2")
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
# Info embed edited to "Done" status.
|
||||
info_msg.edit.assert_awaited_once()
|
||||
status_embed = info_msg.edit.call_args[1]["embed"]
|
||||
assert "Done" in status_embed.title
|
||||
assert status_embed.description == "ls -la" # preview preserved
|
||||
# Result sent as separate new message.
|
||||
thread.send.assert_awaited_once()
|
||||
result_embed = thread.send.call_args[1]["embed"]
|
||||
assert result_embed.title == "bash"
|
||||
assert "file1" in result_embed.description
|
||||
# Entry consumed from tracking list.
|
||||
assert bot._tool_info_msgs["ws-1"] == []
|
||||
|
||||
def test_result_sent_even_without_info_match(self):
|
||||
from turnstone.sdk.events import ToolResultEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
event = ToolResultEvent(ws_id="ws-1", name="bash", output="file1\nfile2")
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
thread.send.assert_awaited_once()
|
||||
embed = thread.send.call_args[1]["embed"]
|
||||
assert embed.title == "bash"
|
||||
assert "file1" in embed.description
|
||||
|
||||
def test_error_result_uses_red_color(self):
|
||||
from turnstone.sdk.events import ToolResultEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
event = ToolResultEvent(
|
||||
ws_id="ws-1", name="bash", output="command not found", is_error=True
|
||||
)
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
embed = thread.send.call_args[1]["embed"]
|
||||
assert embed.color == discord.Color.red()
|
||||
|
||||
def test_success_result_uses_dark_grey_color(self):
|
||||
from turnstone.sdk.events import ToolResultEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
event = ToolResultEvent(ws_id="ws-1", name="bash", output="ok")
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
embed = thread.send.call_args[1]["embed"]
|
||||
assert embed.color == discord.Color.dark_grey()
|
||||
|
||||
def test_call_id_matching(self):
|
||||
from turnstone.sdk.events import ToolResultEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
first_msg = MagicMock()
|
||||
first_msg.edit = AsyncMock()
|
||||
second_msg = MagicMock()
|
||||
second_msg.edit = AsyncMock()
|
||||
bot._tool_info_msgs["ws-1"] = [
|
||||
("call-1", "bash", "", first_msg),
|
||||
("call-2", "bash", "", second_msg),
|
||||
]
|
||||
|
||||
# Result with call_id matches the correct message regardless of order.
|
||||
event = ToolResultEvent(ws_id="ws-1", call_id="call-2", name="bash", output="result")
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
second_msg.edit.assert_awaited_once()
|
||||
first_msg.edit.assert_not_awaited()
|
||||
|
||||
def test_fifo_fallback_when_no_call_id(self):
|
||||
from turnstone.sdk.events import ToolResultEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
first_msg = MagicMock()
|
||||
first_msg.edit = AsyncMock()
|
||||
second_msg = MagicMock()
|
||||
second_msg.edit = AsyncMock()
|
||||
bot._tool_info_msgs["ws-1"] = [("", "bash", "", first_msg), ("", "bash", "", second_msg)]
|
||||
|
||||
# No call_id — falls back to FIFO name match.
|
||||
event1 = ToolResultEvent(ws_id="ws-1", name="bash", output="result1")
|
||||
_run(bot._on_ws_event("ws-1", thread, event1))
|
||||
first_msg.edit.assert_awaited_once()
|
||||
second_msg.edit.assert_not_awaited()
|
||||
|
||||
event2 = ToolResultEvent(ws_id="ws-1", name="bash", output="result2")
|
||||
_run(bot._on_ws_event("ws-1", thread, event2))
|
||||
second_msg.edit.assert_awaited_once()
|
||||
|
||||
def test_edit_failure_falls_back_to_send(self):
|
||||
from turnstone.sdk.events import ToolResultEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
info_msg = MagicMock()
|
||||
info_msg.edit = AsyncMock(side_effect=Exception("Discord API error"))
|
||||
bot._tool_info_msgs["ws-1"] = [("", "bash", "ls -la", info_msg)]
|
||||
|
||||
event = ToolResultEvent(ws_id="ws-1", name="bash", output="ok")
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
# Edit failed, should fall back to send.
|
||||
info_msg.edit.assert_awaited_once()
|
||||
thread.send.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Approval resolved (timeout / external resolution)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApprovalResolved:
|
||||
"""ApprovalResolvedEvent should disable buttons on the pending approval embed."""
|
||||
|
||||
def _make_bot(self):
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
|
||||
bot = MagicMock(spec=TurnstoneBot)
|
||||
bot.config = MagicMock()
|
||||
bot.config.max_message_length = 2000
|
||||
bot.config.streaming_edit_interval = 1.5
|
||||
bot.config.auto_approve = False
|
||||
bot.config.auto_approve_tools = []
|
||||
bot.storage = None
|
||||
bot._streaming = {}
|
||||
bot._thinking_msgs = {}
|
||||
bot._tool_info_msgs = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_reply_channels = {}
|
||||
bot._should_auto_approve = MagicMock(return_value=False)
|
||||
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
|
||||
return bot
|
||||
|
||||
def test_disables_buttons_on_timeout(self):
|
||||
from turnstone.sdk.events import ApprovalResolvedEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
# Set up a pending approval message with components.
|
||||
approval_msg = MagicMock()
|
||||
approval_msg.embeds = [MagicMock()]
|
||||
approval_msg.components = []
|
||||
approval_msg.edit = AsyncMock()
|
||||
bot._pending_approval_msgs["ws-1"] = approval_msg
|
||||
|
||||
event = ApprovalResolvedEvent(ws_id="ws-1", approved=False, feedback="timeout")
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
approval_msg.edit.assert_awaited_once()
|
||||
# Pending approval message should be removed.
|
||||
assert "ws-1" not in bot._pending_approval_msgs
|
||||
|
||||
def test_disables_buttons_on_approved(self):
|
||||
from turnstone.sdk.events import ApprovalResolvedEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
approval_msg = MagicMock()
|
||||
approval_msg.embeds = [MagicMock()]
|
||||
approval_msg.components = []
|
||||
approval_msg.edit = AsyncMock()
|
||||
bot._pending_approval_msgs["ws-1"] = approval_msg
|
||||
|
||||
event = ApprovalResolvedEvent(ws_id="ws-1", approved=True)
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
|
||||
approval_msg.edit.assert_awaited_once()
|
||||
# Check the embed title was updated with "Approved".
|
||||
edited_embed = approval_msg.edit.call_args[1]["embed"]
|
||||
assert "Approved" in edited_embed.title
|
||||
|
||||
def test_no_pending_approval_is_noop(self):
|
||||
from turnstone.sdk.events import ApprovalResolvedEvent
|
||||
|
||||
bot = self._make_bot()
|
||||
thread = AsyncMock()
|
||||
|
||||
event = ApprovalResolvedEvent(ws_id="ws-1", approved=False)
|
||||
_run(bot._on_ws_event("ws-1", thread, event))
|
||||
# No error, no state change.
|
||||
|
||||
|
||||
class TestChannelCLI:
|
||||
"""Tests for the channel CLI entry point."""
|
||||
|
||||
|
||||
+229
-125
@@ -3,12 +3,30 @@
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.console.collector import ClusterCollector, NodeSnapshot
|
||||
|
||||
# Shared test auth — JWT-based
|
||||
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
|
||||
def _test_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-console",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
|
||||
|
||||
_TEST_AUTH_HEADERS = {"Authorization": f"Bearer {_test_jwt()}"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock storage for collector tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -29,32 +47,15 @@ class MockStorage:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_collector(storage=None, poll_interval=0, discovery_interval=999):
|
||||
"""Create a collector with zero poll interval (no jitter delay in tests)."""
|
||||
def _make_collector(storage=None, discovery_interval=999):
|
||||
"""Create a collector for tests (discovery disabled by default)."""
|
||||
s = storage or MockStorage()
|
||||
return ClusterCollector(
|
||||
storage=s,
|
||||
poll_interval=poll_interval,
|
||||
discovery_interval=discovery_interval,
|
||||
)
|
||||
|
||||
|
||||
def _dashboard_response(workstreams=None, aggregate=None):
|
||||
"""Build a /v1/api/dashboard-style response dict."""
|
||||
return {
|
||||
"workstreams": workstreams or [],
|
||||
"aggregate": aggregate
|
||||
or {
|
||||
"total_tokens": 0,
|
||||
"total_tool_calls": 0,
|
||||
"active_count": 0,
|
||||
"total_count": 0,
|
||||
"uptime_seconds": 0,
|
||||
"node": "local",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ClusterCollector — unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -157,30 +158,35 @@ class TestCollectorDiscovery:
|
||||
assert c._nodes["node-a"].started == 1234567890.0
|
||||
|
||||
|
||||
class TestCollectorPolling:
|
||||
"""Polling /v1/api/dashboard from nodes."""
|
||||
class TestCollectorSnapshot:
|
||||
"""Applying node_snapshot SSE events."""
|
||||
|
||||
def test_apply_poll_populates_workstreams(self):
|
||||
def test_apply_snapshot_populates_workstreams(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
|
||||
dashboard = _dashboard_response(
|
||||
workstreams=[
|
||||
{
|
||||
"id": "ws1",
|
||||
"name": "test",
|
||||
"state": "running",
|
||||
"tokens": 1000,
|
||||
"context_ratio": 0.15,
|
||||
"activity": "bash: ls",
|
||||
"activity_state": "tool",
|
||||
"tool_calls": 3,
|
||||
"title": "My task",
|
||||
},
|
||||
],
|
||||
aggregate={"total_tokens": 1000, "total_tool_calls": 3},
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [
|
||||
{
|
||||
"id": "ws1",
|
||||
"name": "test",
|
||||
"state": "running",
|
||||
"tokens": 1000,
|
||||
"context_ratio": 0.15,
|
||||
"activity": "bash: ls",
|
||||
"activity_state": "tool",
|
||||
"tool_calls": 3,
|
||||
"title": "My task",
|
||||
},
|
||||
],
|
||||
"health": {"status": "ok"},
|
||||
"aggregate": {"total_tokens": 1000, "total_tool_calls": 3},
|
||||
},
|
||||
)
|
||||
c._apply_poll("node-a", dashboard, {"status": "ok"})
|
||||
|
||||
detail = c.get_node_detail("node-a")
|
||||
assert len(detail["workstreams"]) == 1
|
||||
@@ -189,7 +195,7 @@ class TestCollectorPolling:
|
||||
assert detail["workstreams"][0]["server_url"] == "http://a:8080"
|
||||
assert detail["health"]["status"] == "ok"
|
||||
|
||||
def test_apply_poll_replaces_stale_workstreams(self):
|
||||
def test_apply_snapshot_replaces_stale_workstreams(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
@@ -197,30 +203,44 @@ class TestCollectorPolling:
|
||||
workstreams={"old-ws": {"id": "old-ws", "name": "old", "state": "idle"}},
|
||||
)
|
||||
|
||||
dashboard = _dashboard_response(
|
||||
workstreams=[{"id": "new-ws", "name": "new", "state": "running"}]
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [{"id": "new-ws", "name": "new", "state": "running"}],
|
||||
"health": {},
|
||||
"aggregate": {},
|
||||
},
|
||||
)
|
||||
c._apply_poll("node-a", dashboard, {})
|
||||
|
||||
detail = c.get_node_detail("node-a")
|
||||
assert len(detail["workstreams"]) == 1
|
||||
assert detail["workstreams"][0]["id"] == "new-ws"
|
||||
|
||||
def test_apply_poll_ignores_unknown_node(self):
|
||||
def test_apply_snapshot_ignores_unknown_node(self):
|
||||
c = _make_collector()
|
||||
# Should not raise
|
||||
c._apply_poll("unknown", _dashboard_response(), {})
|
||||
c._apply_snapshot(
|
||||
"unknown", {"type": "node_snapshot", "workstreams": [], "health": {}, "aggregate": {}}
|
||||
)
|
||||
|
||||
def test_apply_poll_emits_ws_created_for_new_workstream(self):
|
||||
def test_apply_snapshot_emits_ws_created_for_new_workstream(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
dashboard = _dashboard_response(
|
||||
workstreams=[{"id": "ws1", "name": "new-task", "state": "idle"}]
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [{"id": "ws1", "name": "new-task", "state": "idle"}],
|
||||
"health": {},
|
||||
"aggregate": {},
|
||||
},
|
||||
)
|
||||
c._apply_poll("node-a", dashboard, {})
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_created"
|
||||
@@ -228,7 +248,7 @@ class TestCollectorPolling:
|
||||
assert event["name"] == "new-task"
|
||||
assert event["node_id"] == "node-a"
|
||||
|
||||
def test_apply_poll_emits_ws_closed_for_removed_workstream(self):
|
||||
def test_apply_snapshot_emits_ws_closed_for_removed_workstream(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
@@ -238,13 +258,22 @@ class TestCollectorPolling:
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_poll("node-a", _dashboard_response(), {})
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [],
|
||||
"health": {},
|
||||
"aggregate": {},
|
||||
},
|
||||
)
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_closed"
|
||||
assert event["ws_id"] == "ws1"
|
||||
|
||||
def test_apply_poll_no_events_when_unchanged(self):
|
||||
def test_apply_snapshot_no_events_when_unchanged(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
@@ -254,15 +283,21 @@ class TestCollectorPolling:
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
# Same state — no events expected
|
||||
dashboard = _dashboard_response(
|
||||
workstreams=[{"id": "ws1", "name": "same", "state": "idle"}]
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [{"id": "ws1", "name": "same", "state": "idle"}],
|
||||
"health": {},
|
||||
"aggregate": {},
|
||||
},
|
||||
)
|
||||
c._apply_poll("node-a", dashboard, {})
|
||||
|
||||
assert q.empty()
|
||||
|
||||
def test_apply_poll_emits_state_change(self):
|
||||
def test_apply_snapshot_emits_state_change_as_cluster_state(self):
|
||||
"""State change events must use type 'cluster_state' for the frontend."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
@@ -272,30 +307,141 @@ class TestCollectorPolling:
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
dashboard = _dashboard_response(
|
||||
workstreams=[{"id": "ws1", "name": "same", "state": "running"}]
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [{"id": "ws1", "name": "same", "state": "running"}],
|
||||
"health": {},
|
||||
"aggregate": {},
|
||||
},
|
||||
)
|
||||
c._apply_poll("node-a", dashboard, {})
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_state"
|
||||
assert event["type"] == "cluster_state"
|
||||
assert event["ws_id"] == "ws1"
|
||||
assert event["state"] == "running"
|
||||
|
||||
def test_apply_poll_skips_empty_id_workstream(self):
|
||||
def test_apply_snapshot_skips_empty_id_workstream(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
dashboard = _dashboard_response(workstreams=[{"name": "no-id", "state": "idle"}])
|
||||
c._apply_poll("node-a", dashboard, {})
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [{"name": "no-id", "state": "idle"}],
|
||||
"health": {},
|
||||
"aggregate": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert q.empty()
|
||||
assert len(c._nodes["node-a"].workstreams) == 0
|
||||
|
||||
def test_poll_401_preserves_workstreams_and_marks_unreachable(self):
|
||||
"""A 401 from the server must NOT wipe workstream data."""
|
||||
|
||||
class TestCollectorDelta:
|
||||
"""Applying individual SSE delta events."""
|
||||
|
||||
def test_apply_delta_ws_state_fans_out_as_cluster_state(self):
|
||||
"""Server emits ws_state; collector must translate to cluster_state."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
workstreams={"ws1": {"id": "ws1", "name": "test", "state": "idle"}},
|
||||
)
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_delta(
|
||||
"node-a", {"type": "ws_state", "ws_id": "ws1", "state": "running", "tokens": 500}
|
||||
)
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "cluster_state"
|
||||
assert event["state"] == "running"
|
||||
# Verify in-memory state was updated
|
||||
assert c._nodes["node-a"].workstreams["ws1"]["state"] == "running"
|
||||
|
||||
def test_apply_delta_ws_created(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_delta("node-a", {"type": "ws_created", "ws_id": "ws1", "name": "new"})
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_created"
|
||||
assert "ws1" in c._nodes["node-a"].workstreams
|
||||
|
||||
def test_apply_delta_ws_closed(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
workstreams={"ws1": {"id": "ws1", "name": "old", "state": "idle"}},
|
||||
)
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_delta("node-a", {"type": "ws_closed", "ws_id": "ws1"})
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_closed"
|
||||
assert "ws1" not in c._nodes["node-a"].workstreams
|
||||
|
||||
def test_apply_delta_ws_rename(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
workstreams={"ws1": {"id": "ws1", "name": "old-name", "state": "idle"}},
|
||||
)
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_delta("node-a", {"type": "ws_rename", "ws_id": "ws1", "name": "new-name"})
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_rename"
|
||||
assert event["name"] == "new-name"
|
||||
assert c._nodes["node-a"].workstreams["ws1"]["name"] == "new-name"
|
||||
|
||||
def test_apply_delta_health_changed(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
health={"status": "ok", "backend": {"status": "up", "circuit_state": "closed"}},
|
||||
)
|
||||
|
||||
c._apply_delta("node-a", {"type": "health_changed", "circuit_state": "open"})
|
||||
|
||||
health = c._nodes["node-a"].health
|
||||
assert health["backend"]["circuit_state"] == "open"
|
||||
assert health["backend"]["status"] == "down"
|
||||
assert health["status"] == "degraded"
|
||||
|
||||
def test_apply_delta_aggregate(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
|
||||
c._apply_delta(
|
||||
"node-a",
|
||||
{"type": "aggregate", "total_tokens": 5000, "total_tool_calls": 42, "active_count": 3},
|
||||
)
|
||||
|
||||
assert c._nodes["node-a"].aggregate["total_tokens"] == 5000
|
||||
assert c._nodes["node-a"].aggregate["total_tool_calls"] == 42
|
||||
|
||||
def test_mark_unreachable_preserves_workstreams(self):
|
||||
"""Disconnection marks unreachable but preserves workstream data."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
@@ -304,47 +450,12 @@ class TestCollectorPolling:
|
||||
workstreams={"ws1": {"id": "ws1", "name": "existing", "state": "idle"}},
|
||||
)
|
||||
|
||||
# Mock httpx to return 401
|
||||
import httpx as _httpx
|
||||
c._mark_unreachable("node-a")
|
||||
|
||||
mock_response = _httpx.Response(
|
||||
401,
|
||||
json={"error": "Unauthorized"},
|
||||
request=_httpx.Request("GET", "http://a:8080/v1/api/dashboard"),
|
||||
)
|
||||
|
||||
with patch.object(c._http_client, "get", return_value=mock_response):
|
||||
c._poll_all_nodes()
|
||||
|
||||
# Workstream data must be preserved, node marked unreachable
|
||||
assert c._nodes["node-a"].reachable is False
|
||||
assert "ws1" in c._nodes["node-a"].workstreams
|
||||
assert c._nodes["node-a"].workstreams["ws1"]["name"] == "existing"
|
||||
|
||||
def test_poll_403_preserves_workstreams(self):
|
||||
"""A 403 should also preserve state and mark unreachable."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
reachable=True,
|
||||
workstreams={"ws1": {"id": "ws1", "name": "keep-me", "state": "running"}},
|
||||
)
|
||||
|
||||
import httpx as _httpx
|
||||
|
||||
mock_response = _httpx.Response(
|
||||
403,
|
||||
json={"error": "Forbidden"},
|
||||
request=_httpx.Request("GET", "http://a:8080/v1/api/dashboard"),
|
||||
)
|
||||
|
||||
with patch.object(c._http_client, "get", return_value=mock_response):
|
||||
c._poll_all_nodes()
|
||||
|
||||
assert c._nodes["node-a"].reachable is False
|
||||
assert "ws1" in c._nodes["node-a"].workstreams
|
||||
|
||||
|
||||
class TestCollectorFanout:
|
||||
"""SSE fan-out to registered listeners."""
|
||||
@@ -618,13 +729,11 @@ class TestConsoleHTTPEndpoints:
|
||||
|
||||
_load_static()
|
||||
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
@@ -860,12 +969,11 @@ class TestConsoleWorkstreamCreation:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
|
||||
# Set up a mock proxy_client (lifespan doesn't run in TestClient)
|
||||
@@ -881,7 +989,7 @@ class TestConsoleWorkstreamCreation:
|
||||
mock_proxy.post = mock_post
|
||||
app.state.proxy_client = mock_proxy
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client, mock_post
|
||||
client.close()
|
||||
|
||||
@@ -1058,14 +1166,13 @@ class TestConsoleProxy:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
@@ -1229,14 +1336,13 @@ class TestConsoleVersionEndpoints:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
@@ -1271,7 +1377,6 @@ class TestSharedStatic:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
@@ -1283,9 +1388,9 @@ class TestSharedStatic:
|
||||
}
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
@@ -1388,7 +1493,6 @@ class TestProxySharedStatic:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
@@ -1401,9 +1505,9 @@ class TestProxySharedStatic:
|
||||
collector.get_node_detail.return_value = None
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
resp = client.get("/node/unknown/shared/base.css")
|
||||
assert resp.status_code == 404
|
||||
client.close()
|
||||
@@ -1722,14 +1826,14 @@ class TestProxyAuthHeaders:
|
||||
# Should use ServiceTokenManager, not mint a user JWT
|
||||
assert headers["Authorization"] == f"Bearer {mgr.token}"
|
||||
|
||||
def test_fallback_static_token(self):
|
||||
"""No auth_result, no ServiceTokenManager → uses static proxy_auth_token."""
|
||||
def test_no_mgr_no_user_returns_empty(self):
|
||||
"""No auth_result, no ServiceTokenManager → empty headers."""
|
||||
from turnstone.console.server import _proxy_auth_headers
|
||||
|
||||
req = self._make_request(proxy_auth_token="static-tok-123")
|
||||
req = self._make_request()
|
||||
headers = _proxy_auth_headers(req)
|
||||
|
||||
assert headers == {"Authorization": "Bearer static-tok-123"}
|
||||
assert headers == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -13,6 +13,24 @@ from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.router import ConsoleRouter, NodeRef
|
||||
from turnstone.core.hash_ring import NoAvailableNodeError
|
||||
|
||||
# Shared test auth — JWT-based
|
||||
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
|
||||
def _test_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-routing",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
|
||||
|
||||
_TEST_AUTH_HEADERS: dict[str, str] = {"Authorization": f"Bearer {_test_jwt()}"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -42,12 +60,11 @@ def _make_app(
|
||||
router: Any = None,
|
||||
) -> Any:
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
return create_app(
|
||||
collector=collector or _make_mock_collector(),
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
router=router,
|
||||
)
|
||||
|
||||
@@ -100,6 +117,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test-ws"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -109,6 +127,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test-ws"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -126,6 +145,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"resume_ws": "old_ws_id"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -150,6 +170,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"target_node": "node-c"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -203,6 +224,7 @@ class TestRouteCreate503Retry:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test-ws"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -233,6 +255,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc123", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Verify upstream URL was /v1/api/send (not /v1/api/route/send)
|
||||
@@ -245,6 +268,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/approve",
|
||||
json={"ws_id": "abc123", "approved": True},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -252,6 +276,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/cancel",
|
||||
json={"ws_id": "abc123"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -259,6 +284,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/command",
|
||||
json={"ws_id": "abc123", "command": "status"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -266,6 +292,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/close",
|
||||
json={"ws_id": "abc123"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -288,14 +315,14 @@ class TestRouteLookup:
|
||||
client.close()
|
||||
|
||||
def test_route_lookup(self, client):
|
||||
resp = client.get("/v1/api/route?ws_id=abc123")
|
||||
resp = client.get("/v1/api/route?ws_id=abc123", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["node_url"] == "http://a:8080"
|
||||
assert data["node_id"] == "node-a"
|
||||
|
||||
def test_route_lookup_missing_ws_id(self, client):
|
||||
resp = client.get("/v1/api/route")
|
||||
resp = client.get("/v1/api/route", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 400
|
||||
assert "ws_id" in resp.json()["error"]
|
||||
|
||||
@@ -329,6 +356,7 @@ class TestRouteNotReady:
|
||||
resp = client_no_router.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
@@ -336,6 +364,7 @@ class TestRouteNotReady:
|
||||
resp = client_empty_cache.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
@@ -343,22 +372,24 @@ class TestRouteNotReady:
|
||||
resp = client_no_router.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_route_lookup_no_router_503(self, client_no_router):
|
||||
resp = client_no_router.get("/v1/api/route?ws_id=abc")
|
||||
resp = client_no_router.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_route_proxy_empty_cache_503(self, client_empty_cache):
|
||||
resp = client_empty_cache.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_route_lookup_empty_cache_503(self, client_empty_cache):
|
||||
resp = client_empty_cache.get("/v1/api/route?ws_id=abc")
|
||||
resp = client_empty_cache.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
@@ -384,6 +415,7 @@ class TestRouteNoNode:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert "No available node" in resp.json()["error"]
|
||||
@@ -392,9 +424,10 @@ class TestRouteNoNode:
|
||||
resp = client.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_route_lookup_no_node_503(self, client):
|
||||
resp = client.get("/v1/api/route?ws_id=abc")
|
||||
resp = client.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 503
|
||||
|
||||
@@ -425,11 +425,16 @@ class TestSkillCatalogDisclosure:
|
||||
session._mcp_client = None
|
||||
session._notify_on_complete = "{}"
|
||||
session._tool_error_flags = {}
|
||||
from turnstone.prompts import ClientType
|
||||
|
||||
session._tools = []
|
||||
session._client_type = ClientType.CLI
|
||||
session._username = ""
|
||||
|
||||
# Memory stubs
|
||||
session._memory_config = MagicMock()
|
||||
session._memory_config.fetch_limit = 0
|
||||
session._user_id = ""
|
||||
session._user_id = "test-user"
|
||||
|
||||
with (
|
||||
patch(
|
||||
|
||||
+38
-48
@@ -8,8 +8,26 @@ import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.channels._http import create_channel_app
|
||||
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
_JWT_SECRET = "a" * 32
|
||||
|
||||
|
||||
def _make_jwt() -> str:
|
||||
"""Create a valid JWT for channel auth."""
|
||||
return create_jwt(
|
||||
user_id="system",
|
||||
scopes=frozenset({"write"}),
|
||||
source="service",
|
||||
secret=_JWT_SECRET,
|
||||
audience=JWT_AUD_CHANNEL,
|
||||
)
|
||||
|
||||
|
||||
def _auth_headers() -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {_make_jwt()}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path):
|
||||
@@ -33,22 +51,22 @@ def no_auth_client(storage, mock_adapter):
|
||||
|
||||
@pytest.fixture
|
||||
def client(storage, mock_adapter):
|
||||
"""Default client with static auth token configured."""
|
||||
app = create_channel_app({"discord": mock_adapter}, storage, auth_token="test-secret-token")
|
||||
"""Default client with JWT auth configured."""
|
||||
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authed_client(storage, mock_adapter):
|
||||
"""Alias — same as client, for auth-specific test clarity."""
|
||||
app = create_channel_app({"discord": mock_adapter}, storage, auth_token="test-secret-token")
|
||||
"""Alias -- same as client, for auth-specific test clarity."""
|
||||
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jwt_client(storage, mock_adapter):
|
||||
"""Client with JWT auth configured."""
|
||||
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret="a" * 32)
|
||||
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -58,9 +76,6 @@ class TestNotifyEndpoint:
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {"Authorization": "Bearer test-secret-token"}
|
||||
|
||||
def test_direct_discord_target(self, client, mock_adapter):
|
||||
resp = client.post(
|
||||
"/v1/api/notify",
|
||||
@@ -68,7 +83,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"channel_type": "discord", "channel_id": "123456"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()["results"]
|
||||
@@ -85,7 +100,7 @@ class TestNotifyEndpoint:
|
||||
"message": "Hello!",
|
||||
"title": "Alert",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
mock_adapter.send.assert_called_once_with("123456", "**Alert**\nHello!")
|
||||
@@ -101,7 +116,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"username": "testuser"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()["results"]
|
||||
@@ -116,7 +131,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"username": "nobody"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
error = resp.json()["error"]
|
||||
@@ -132,10 +147,10 @@ class TestNotifyEndpoint:
|
||||
"target": {"username": "testuser"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers={"Authorization": "Bearer test-secret-token"},
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
# Generic message — must not differentiate "not found" vs "no channels"
|
||||
# Generic message -- must not differentiate "not found" vs "no channels"
|
||||
error = resp.json()["error"]
|
||||
assert "testuser" not in error
|
||||
assert "not found or has no linked channels" in error
|
||||
@@ -144,7 +159,7 @@ class TestNotifyEndpoint:
|
||||
resp = client.post(
|
||||
"/v1/api/notify",
|
||||
json={"target": {"username": "x"}},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -152,7 +167,7 @@ class TestNotifyEndpoint:
|
||||
resp = client.post(
|
||||
"/v1/api/notify",
|
||||
json={"message": "Hello!"},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -163,7 +178,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"invalid": "field"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -175,7 +190,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"channel_type": "email", "channel_id": "test@example.com"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()["results"]
|
||||
@@ -189,7 +204,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"channel_type": "discord", "channel_id": "123456"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()["results"]
|
||||
@@ -201,7 +216,7 @@ class TestNotifyEndpoint:
|
||||
content=b"not json",
|
||||
headers={
|
||||
"content-type": "application/json",
|
||||
"Authorization": "Bearer test-secret-token",
|
||||
"Authorization": f"Bearer {_make_jwt()}",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
@@ -214,7 +229,7 @@ class TestNotifyEndpoint:
|
||||
"target": {"channel_type": "discord", "channel_id": "123"},
|
||||
"message": " ",
|
||||
},
|
||||
headers=self._headers(),
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -256,30 +271,9 @@ class TestNotifyAuth:
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_accept_valid_static_token(self, authed_client, mock_adapter):
|
||||
"""Requests with correct static token are accepted."""
|
||||
resp = authed_client.post(
|
||||
"/v1/api/notify",
|
||||
json={
|
||||
"target": {"channel_type": "discord", "channel_id": "123"},
|
||||
"message": "Hello!",
|
||||
},
|
||||
headers={"Authorization": "Bearer test-secret-token"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["results"][0]["status"] == "sent"
|
||||
|
||||
def test_accept_valid_jwt(self, jwt_client, mock_adapter):
|
||||
"""Requests with a valid JWT for the channel audience are accepted."""
|
||||
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt
|
||||
|
||||
token = create_jwt(
|
||||
user_id="system",
|
||||
scopes=frozenset({"write"}),
|
||||
source="service",
|
||||
secret="a" * 32,
|
||||
audience=JWT_AUD_CHANNEL,
|
||||
)
|
||||
token = _make_jwt()
|
||||
resp = jwt_client.post(
|
||||
"/v1/api/notify",
|
||||
json={
|
||||
@@ -292,13 +286,11 @@ class TestNotifyAuth:
|
||||
|
||||
def test_reject_jwt_wrong_audience(self, jwt_client):
|
||||
"""JWTs with wrong audience are rejected."""
|
||||
from turnstone.core.auth import create_jwt
|
||||
|
||||
token = create_jwt(
|
||||
user_id="system",
|
||||
scopes=frozenset({"write"}),
|
||||
source="service",
|
||||
secret="a" * 32,
|
||||
secret=_JWT_SECRET,
|
||||
audience="turnstone-server", # wrong audience
|
||||
)
|
||||
resp = jwt_client.post(
|
||||
@@ -313,8 +305,6 @@ class TestNotifyAuth:
|
||||
|
||||
def test_reject_jwt_wrong_secret(self, jwt_client):
|
||||
"""JWTs signed with wrong secret are rejected."""
|
||||
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt
|
||||
|
||||
token = create_jwt(
|
||||
user_id="system",
|
||||
scopes=frozenset({"write"}),
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Tests for the system message composition harness (turnstone.prompts)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.prompts import (
|
||||
ClientType,
|
||||
SessionContext,
|
||||
compose_system_message,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_CTX = SessionContext(
|
||||
current_datetime="2026-03-31T14:22:00-07:00",
|
||||
timezone="PDT",
|
||||
username="sarah.chen",
|
||||
)
|
||||
|
||||
_ALL_TOOLS: frozenset[str] = frozenset({"web_search", "read_file", "bash"})
|
||||
_NO_TOOLS: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Assembly smoke test per client type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ct", [ClientType.WEB, ClientType.CLI, ClientType.CHAT])
|
||||
def test_smoke_all_client_types(ct: ClientType) -> None:
|
||||
result = compose_system_message(
|
||||
client_type=ct,
|
||||
context=_VALID_CTX,
|
||||
available_tools=_ALL_TOOLS,
|
||||
)
|
||||
# BASE content present
|
||||
assert "resident engineer" in result
|
||||
# CONTEXT present
|
||||
assert "sarah.chen" in result
|
||||
assert "2026-03-31" in result
|
||||
|
||||
|
||||
def test_smoke_web_has_mermaid() -> None:
|
||||
result = compose_system_message(
|
||||
client_type=ClientType.WEB,
|
||||
context=_VALID_CTX,
|
||||
available_tools=_ALL_TOOLS,
|
||||
)
|
||||
assert "Mermaid" in result
|
||||
assert "KaTeX" in result
|
||||
|
||||
|
||||
def test_smoke_cli_no_mermaid() -> None:
|
||||
result = compose_system_message(
|
||||
client_type=ClientType.CLI,
|
||||
context=_VALID_CTX,
|
||||
available_tools=_ALL_TOOLS,
|
||||
)
|
||||
assert "Mermaid" not in result or "Do not use" in result
|
||||
|
||||
|
||||
def test_smoke_chat_no_tables() -> None:
|
||||
result = compose_system_message(
|
||||
client_type=ClientType.CHAT,
|
||||
context=_VALID_CTX,
|
||||
available_tools=_ALL_TOOLS,
|
||||
)
|
||||
assert "Do not use them" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Required field validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_current_datetime() -> None:
|
||||
ctx = SessionContext(current_datetime="", timezone="PDT", username="alice")
|
||||
with pytest.raises(ValueError, match="current_datetime"):
|
||||
compose_system_message(ClientType.CLI, ctx, _NO_TOOLS)
|
||||
|
||||
|
||||
def test_missing_timezone() -> None:
|
||||
ctx = SessionContext(
|
||||
current_datetime="2026-03-31T14:22:00-07:00",
|
||||
timezone="",
|
||||
username="alice",
|
||||
)
|
||||
with pytest.raises(ValueError, match="timezone"):
|
||||
compose_system_message(ClientType.CLI, ctx, _NO_TOOLS)
|
||||
|
||||
|
||||
def test_missing_username() -> None:
|
||||
ctx = SessionContext(
|
||||
current_datetime="2026-03-31T14:22:00-07:00",
|
||||
timezone="PDT",
|
||||
username="",
|
||||
)
|
||||
with pytest.raises(ValueError, match="username"):
|
||||
compose_system_message(ClientType.CLI, ctx, _NO_TOOLS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Unknown client type rejection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unknown_client_type() -> None:
|
||||
with pytest.raises(ValueError, match="Unknown client_type"):
|
||||
compose_system_message("tablet", _VALID_CTX, _NO_TOOLS) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Missing policy file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_policy_file() -> None:
|
||||
with pytest.raises(FileNotFoundError, match="nonexistent"):
|
||||
compose_system_message(
|
||||
ClientType.CLI,
|
||||
_VALID_CTX,
|
||||
_ALL_TOOLS,
|
||||
policies=["nonexistent"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Module isolation — BASE must be environment-agnostic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_base_module_isolation() -> None:
|
||||
from turnstone.prompts import _load
|
||||
|
||||
base = _load("base.md")
|
||||
for forbidden in ("Mermaid", "KaTeX", "terminal", "monospace", "Slack", "Discord"):
|
||||
assert forbidden not in base, f"BASE must not contain '{forbidden}'"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. ENV mutual exclusion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_env_mutual_exclusion() -> None:
|
||||
web = compose_system_message(ClientType.WEB, _VALID_CTX, _ALL_TOOLS)
|
||||
# Web should have Mermaid.js but not "No diagram rendering" from CLI
|
||||
assert "Mermaid" in web
|
||||
assert "No diagram rendering" not in web
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Policy tool gating — file-based (negative case)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_file_policy_gated_out() -> None:
|
||||
"""web_search policy excluded when web_search tool is not available."""
|
||||
result = compose_system_message(
|
||||
ClientType.CLI,
|
||||
_VALID_CTX,
|
||||
frozenset({"read_file"}), # no web_search
|
||||
policies=["web_search"],
|
||||
)
|
||||
assert "Web Search Policy" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Policy tool gating — positive case
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_file_policy_gated_in() -> None:
|
||||
"""web_search policy included when web_search tool is available."""
|
||||
result = compose_system_message(
|
||||
ClientType.CLI,
|
||||
_VALID_CTX,
|
||||
frozenset({"web_search"}),
|
||||
policies=["web_search"],
|
||||
)
|
||||
assert "Web Search Policy" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Unconditional policy not gated
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unconditional_policy() -> None:
|
||||
"""A DB policy with no tool_gate is always included."""
|
||||
db = [
|
||||
{
|
||||
"name": "custom_rule",
|
||||
"content": "## Custom Rule\nAlways be polite.",
|
||||
"tool_gate": "",
|
||||
"priority": 0,
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
result = compose_system_message(
|
||||
ClientType.CLI,
|
||||
_VALID_CTX,
|
||||
_NO_TOOLS,
|
||||
db_policies=db,
|
||||
)
|
||||
assert "Always be polite" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. DB policy override
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_db_policy_overrides_file() -> None:
|
||||
"""DB policy with same name as file policy wins."""
|
||||
db = [
|
||||
{
|
||||
"name": "web_search",
|
||||
"content": "## DB Web Search Override\nCustom content.",
|
||||
"tool_gate": "web_search",
|
||||
"priority": 0,
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
result = compose_system_message(
|
||||
ClientType.CLI,
|
||||
_VALID_CTX,
|
||||
_ALL_TOOLS,
|
||||
policies=["web_search"],
|
||||
db_policies=db,
|
||||
)
|
||||
assert "DB Web Search Override" in result
|
||||
assert "Use local tools" not in result # original file content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. DB-only policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_db_only_policy() -> None:
|
||||
"""DB policy not in explicit list is still included."""
|
||||
db = [
|
||||
{
|
||||
"name": "extra_rule",
|
||||
"content": "## Extra\nDo not share secrets.",
|
||||
"tool_gate": "",
|
||||
"priority": 5,
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
result = compose_system_message(
|
||||
ClientType.CLI,
|
||||
_VALID_CTX,
|
||||
_NO_TOOLS,
|
||||
db_policies=db,
|
||||
)
|
||||
assert "Do not share secrets" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12. Disabled DB policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_disabled_db_policy() -> None:
|
||||
"""DB policy with enabled=False is skipped."""
|
||||
db = [
|
||||
{
|
||||
"name": "disabled_rule",
|
||||
"content": "## Disabled\nThis should not appear.",
|
||||
"tool_gate": "",
|
||||
"priority": 0,
|
||||
"enabled": False,
|
||||
}
|
||||
]
|
||||
result = compose_system_message(
|
||||
ClientType.CLI,
|
||||
_VALID_CTX,
|
||||
_NO_TOOLS,
|
||||
db_policies=db,
|
||||
)
|
||||
assert "This should not appear" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 13. ISO 8601 validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_invalid_iso_datetime() -> None:
|
||||
ctx = SessionContext(
|
||||
current_datetime="not-a-date",
|
||||
timezone="PDT",
|
||||
username="alice",
|
||||
)
|
||||
with pytest.raises(ValueError, match="not valid ISO 8601"):
|
||||
compose_system_message(ClientType.CLI, ctx, _NO_TOOLS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 14. DB policy priority ordering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_db_policy_priority_ordering() -> None:
|
||||
"""DB-only policies are assembled in priority order (ascending)."""
|
||||
db = [
|
||||
{
|
||||
"name": "second",
|
||||
"content": "SECOND_MARKER",
|
||||
"tool_gate": "",
|
||||
"priority": 10,
|
||||
"enabled": True,
|
||||
},
|
||||
{
|
||||
"name": "first",
|
||||
"content": "FIRST_MARKER",
|
||||
"tool_gate": "",
|
||||
"priority": 1,
|
||||
"enabled": True,
|
||||
},
|
||||
]
|
||||
result = compose_system_message(
|
||||
ClientType.CLI,
|
||||
_VALID_CTX,
|
||||
_NO_TOOLS,
|
||||
db_policies=db,
|
||||
)
|
||||
first_pos = result.index("FIRST_MARKER")
|
||||
second_pos = result.index("SECOND_MARKER")
|
||||
assert first_pos < second_pos
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 15. TOOLS module excluded when no tools available
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tools_excluded_when_no_tools() -> None:
|
||||
"""TOOLS module is not included when available_tools is empty."""
|
||||
result = compose_system_message(
|
||||
ClientType.CLI,
|
||||
_VALID_CTX,
|
||||
_NO_TOOLS,
|
||||
)
|
||||
assert "TOOL PATTERNS" not in result
|
||||
|
||||
|
||||
def test_tools_included_when_tools_available() -> None:
|
||||
"""TOOLS module is included when available_tools is non-empty."""
|
||||
result = compose_system_message(
|
||||
ClientType.CLI,
|
||||
_VALID_CTX,
|
||||
_ALL_TOOLS,
|
||||
)
|
||||
assert "TOOL PATTERNS" in result
|
||||
@@ -2303,8 +2303,8 @@ class TestAnthropicToolSearch:
|
||||
# MCP tool should be deferred
|
||||
assert result[1]["defer_loading"] is True
|
||||
# Search tool should be appended
|
||||
assert result[-1]["type"] == "tool_search_tool_bm25_20251119"
|
||||
assert result[-1]["name"] == "tool_search"
|
||||
assert result[-1]["type"] == "tool_search_tool_bm25"
|
||||
assert result[-1]["name"] == "tool_search_tool_bm25"
|
||||
|
||||
def test_inject_tool_search_no_op_without_deferred(self, provider):
|
||||
caps = provider.get_capabilities("claude-opus-4-6-20260101")
|
||||
|
||||
+30
-11
@@ -580,6 +580,24 @@ class TestSessionConfig:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
|
||||
def _server_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-server-live",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_SERVER,
|
||||
)
|
||||
|
||||
|
||||
_SERVER_AUTH_HEADERS = {"Authorization": f"Bearer {_server_jwt()}"}
|
||||
|
||||
|
||||
class TestServerHealthMetrics:
|
||||
"""Verify /health and /metrics endpoints using a Starlette TestClient.
|
||||
|
||||
@@ -596,7 +614,6 @@ class TestServerHealthMetrics:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import turnstone.server as srv_mod
|
||||
from turnstone.core.auth import AuthConfig
|
||||
from turnstone.core.metrics import MetricsCollector
|
||||
from turnstone.core.workstream import WorkstreamState
|
||||
|
||||
@@ -631,7 +648,7 @@ class TestServerHealthMetrics:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
cls.client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@@ -727,8 +744,8 @@ class TestServerHealthMetrics:
|
||||
assert 'le="+Inf"' in body
|
||||
|
||||
def test_unknown_endpoint_returns_404(self):
|
||||
status, _, _ = self._get("/does-not-exist")
|
||||
assert status == 404
|
||||
resp = self.client.get("/does-not-exist", headers=_SERVER_AUTH_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_health_contains_backend_field(self):
|
||||
_, _, body = self._get("/health")
|
||||
@@ -772,7 +789,6 @@ class TestServerRateLimiting:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import turnstone.server as srv_mod
|
||||
from turnstone.core.auth import AuthConfig
|
||||
from turnstone.core.metrics import MetricsCollector
|
||||
from turnstone.core.ratelimit import RateLimiter
|
||||
from turnstone.core.workstream import WorkstreamState
|
||||
@@ -808,7 +824,7 @@ class TestServerRateLimiting:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
rate_limiter=RateLimiter(enabled=True, rate=2.0, burst=3),
|
||||
)
|
||||
cls.client = TestClient(app, raise_server_exceptions=False)
|
||||
@@ -830,16 +846,19 @@ class TestServerRateLimiting:
|
||||
"""After exhausting burst on a non-exempt endpoint, get 429."""
|
||||
# Exhaust burst on a non-exempt endpoint
|
||||
for _ in range(5):
|
||||
self._get("/v1/api/workstreams")
|
||||
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
# At least one should be 429
|
||||
statuses = [self._get("/v1/api/workstreams").status_code for _ in range(3)]
|
||||
statuses = [
|
||||
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS).status_code
|
||||
for _ in range(3)
|
||||
]
|
||||
assert 429 in statuses
|
||||
|
||||
def test_429_includes_retry_after(self):
|
||||
"""429 response includes Retry-After header."""
|
||||
# Burn through burst
|
||||
for _ in range(10):
|
||||
resp = self._get("/v1/api/workstreams")
|
||||
resp = self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
if resp.status_code == 429:
|
||||
assert "retry-after" in resp.headers
|
||||
data = resp.json()
|
||||
@@ -851,7 +870,7 @@ class TestServerRateLimiting:
|
||||
"""Health endpoint is always accessible regardless of rate limit."""
|
||||
# Burn through bucket on non-exempt path
|
||||
for _ in range(10):
|
||||
self._get("/v1/api/workstreams")
|
||||
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
# Health should still work
|
||||
resp = self._get("/health")
|
||||
assert resp.status_code == 200
|
||||
@@ -859,6 +878,6 @@ class TestServerRateLimiting:
|
||||
def test_metrics_exempt_from_ratelimit(self):
|
||||
"""Metrics endpoint is always accessible regardless of rate limit."""
|
||||
for _ in range(10):
|
||||
self._get("/v1/api/workstreams")
|
||||
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
resp = self._get("/metrics")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
"""Tests for skill resource materialization to disk.
|
||||
|
||||
Verifies that skill-bundled resources (scripts, references, assets) stored
|
||||
in the ``skill_resources`` table are written to a temp directory when a
|
||||
skill is loaded, exposed via ``SKILL_RESOURCES_DIR`` env var and ``PATH``,
|
||||
and cleaned up on skill change or session close.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers (mirrors test_skills.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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, **kwargs):
|
||||
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 on_output_warning(self, call_id, assessment):
|
||||
pass
|
||||
|
||||
|
||||
def _make_session(**kwargs: Any) -> ChatSession:
|
||||
defaults: dict[str, Any] = 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 _create_skill(db: Any, skill_id: str, name: str, content: str, **kw: Any) -> None:
|
||||
db.create_prompt_template(
|
||||
template_id=skill_id,
|
||||
name=name,
|
||||
category=kw.get("category", "general"),
|
||||
content=content,
|
||||
variables=kw.get("variables", "[]"),
|
||||
is_default=kw.get("is_default", False),
|
||||
org_id="",
|
||||
created_by="test",
|
||||
origin="manual",
|
||||
mcp_server="",
|
||||
readonly=False,
|
||||
description="",
|
||||
tags="[]",
|
||||
source_url="",
|
||||
version="1.0.0",
|
||||
author="",
|
||||
activation=kw.get("activation", "named"),
|
||||
token_estimate=0,
|
||||
model="",
|
||||
auto_approve=False,
|
||||
temperature=None,
|
||||
reasoning_effort="",
|
||||
max_tokens=None,
|
||||
token_budget=0,
|
||||
agent_max_turns=None,
|
||||
notify_on_complete="{}",
|
||||
enabled=True,
|
||||
allowed_tools="[]",
|
||||
priority=0,
|
||||
)
|
||||
|
||||
|
||||
def _sys_content(session: ChatSession) -> str:
|
||||
msgs = [m for m in session.system_messages if m["role"] == "system"]
|
||||
assert msgs
|
||||
return msgs[0]["content"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMaterializeResources:
|
||||
def test_materialize_creates_files(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "test-skill", "Use the scripts.")
|
||||
db.create_skill_resource("r1", "s1", "scripts/helper.py", "print('hello')")
|
||||
db.create_skill_resource("r2", "s1", "references/api.md", "# API")
|
||||
|
||||
session = _make_session(skill="test-skill")
|
||||
assert session._skill_resources_dir is not None
|
||||
base = session._skill_resources_dir
|
||||
assert os.path.isdir(base)
|
||||
|
||||
helper = os.path.join(base, "scripts", "helper.py")
|
||||
assert os.path.isfile(helper)
|
||||
with open(helper) as f:
|
||||
assert f.read() == "print('hello')"
|
||||
|
||||
api_md = os.path.join(base, "references", "api.md")
|
||||
assert os.path.isfile(api_md)
|
||||
with open(api_md) as f:
|
||||
assert f.read() == "# API"
|
||||
|
||||
session.close()
|
||||
|
||||
def test_scripts_executable(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "exec-skill", "Run scripts/run.sh")
|
||||
db.create_skill_resource("r1", "s1", "scripts/run.sh", "#!/bin/bash\necho hi")
|
||||
|
||||
session = _make_session(skill="exec-skill")
|
||||
base = session._skill_resources_dir
|
||||
run_sh = os.path.join(base, "scripts", "run.sh")
|
||||
mode = os.stat(run_sh).st_mode
|
||||
assert mode & stat.S_IXUSR # owner execute
|
||||
session.close()
|
||||
|
||||
def test_non_scripts_not_executable(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "ref-skill", "Read references/guide.md")
|
||||
db.create_skill_resource("r1", "s1", "references/guide.md", "# Guide")
|
||||
|
||||
session = _make_session(skill="ref-skill")
|
||||
base = session._skill_resources_dir
|
||||
guide = os.path.join(base, "references", "guide.md")
|
||||
mode = os.stat(guide).st_mode
|
||||
assert not (mode & stat.S_IXUSR) # not executable
|
||||
session.close()
|
||||
|
||||
def test_cleanup_on_close(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "cleanup-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", "scripts/a.py", "code")
|
||||
|
||||
session = _make_session(skill="cleanup-skill")
|
||||
base = session._skill_resources_dir
|
||||
assert os.path.isdir(base)
|
||||
|
||||
session.close()
|
||||
assert not os.path.exists(base)
|
||||
assert session._skill_resources_dir is None
|
||||
|
||||
def test_cleanup_on_skill_switch(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "skill-a", "Skill A")
|
||||
db.create_skill_resource("r1", "s1", "scripts/a.py", "code_a")
|
||||
_create_skill(db, "s2", "skill-b", "Skill B")
|
||||
db.create_skill_resource("r2", "s2", "scripts/b.py", "code_b")
|
||||
|
||||
session = _make_session(skill="skill-a")
|
||||
dir_a = session._skill_resources_dir
|
||||
assert os.path.isfile(os.path.join(dir_a, "scripts", "a.py"))
|
||||
|
||||
session.set_skill("skill-b")
|
||||
dir_b = session._skill_resources_dir
|
||||
assert dir_b != dir_a
|
||||
assert not os.path.exists(dir_a)
|
||||
assert os.path.isfile(os.path.join(dir_b, "scripts", "b.py"))
|
||||
|
||||
session.close()
|
||||
|
||||
def test_cleanup_on_skill_clear(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "clear-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", "scripts/x.py", "code")
|
||||
|
||||
session = _make_session(skill="clear-skill")
|
||||
base = session._skill_resources_dir
|
||||
assert os.path.isdir(base)
|
||||
|
||||
session.set_skill(None)
|
||||
assert not os.path.exists(base)
|
||||
assert session._skill_resources_dir is None
|
||||
|
||||
session.close()
|
||||
|
||||
def test_empty_resources_no_dir(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "no-res-skill", "content")
|
||||
# No resources added
|
||||
|
||||
session = _make_session(skill="no-res-skill")
|
||||
assert session._skill_resources_dir is None
|
||||
session.close()
|
||||
|
||||
def test_no_skill_no_dir(self, tmp_db):
|
||||
session = _make_session()
|
||||
assert session._skill_resources_dir is None
|
||||
session.close()
|
||||
|
||||
def test_path_traversal_rejected(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "traversal-skill", "content")
|
||||
# Inject a malicious path directly into storage
|
||||
db.create_skill_resource("r1", "s1", "../etc/passwd", "bad content")
|
||||
db.create_skill_resource("r2", "s1", "scripts/good.py", "good content")
|
||||
|
||||
session = _make_session(skill="traversal-skill")
|
||||
base = session._skill_resources_dir
|
||||
# The traversal path must not be written inside the resources dir
|
||||
assert not os.path.exists(os.path.join(base, "etc"))
|
||||
# The good resource should still be materialized
|
||||
assert os.path.isfile(os.path.join(base, "scripts", "good.py"))
|
||||
session.close()
|
||||
|
||||
|
||||
class TestSkillResourceEnv:
|
||||
def test_env_with_resources(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "env-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", "scripts/tool.py", "code")
|
||||
|
||||
session = _make_session(skill="env-skill")
|
||||
env = session._skill_resource_env()
|
||||
assert env["SKILL_RESOURCES_DIR"] == session._skill_resources_dir
|
||||
assert "PATH" in env
|
||||
scripts_dir = os.path.join(session._skill_resources_dir, "scripts")
|
||||
assert env["PATH"].startswith(scripts_dir + ":")
|
||||
session.close()
|
||||
|
||||
def test_env_without_scripts_dir(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "no-scripts-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", "references/doc.md", "# Doc")
|
||||
|
||||
session = _make_session(skill="no-scripts-skill")
|
||||
env = session._skill_resource_env()
|
||||
assert "SKILL_RESOURCES_DIR" in env
|
||||
# No scripts/ subdir so PATH should not be overridden
|
||||
assert "PATH" not in env
|
||||
session.close()
|
||||
|
||||
def test_env_empty_when_no_resources(self, tmp_db):
|
||||
session = _make_session()
|
||||
assert session._skill_resource_env() == {}
|
||||
session.close()
|
||||
|
||||
|
||||
class TestSystemMessageHint:
|
||||
def test_hint_present_when_resources_exist(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "hint-skill", "Use the bundled scripts.")
|
||||
db.create_skill_resource("r1", "s1", "scripts/run.py", "code")
|
||||
|
||||
session = _make_session(skill="hint-skill")
|
||||
content = _sys_content(session)
|
||||
assert "$SKILL_RESOURCES_DIR" in content
|
||||
assert "scripts/ are on PATH" in content
|
||||
session.close()
|
||||
|
||||
def test_no_hint_when_no_resources(self, tmp_db):
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "plain-skill", "No resources here.")
|
||||
|
||||
session = _make_session(skill="plain-skill")
|
||||
content = _sys_content(session)
|
||||
assert "SKILL_RESOURCES_DIR" not in content
|
||||
session.close()
|
||||
|
||||
|
||||
class TestMaterializeEdgeCases:
|
||||
def test_all_resources_rejected_no_dir(self, tmp_db):
|
||||
"""When every resource fails path validation, no temp dir is left."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "all-bad", "content")
|
||||
db.create_skill_resource("r1", "s1", "../escape", "bad")
|
||||
db.create_skill_resource("r2", "s1", "/absolute", "bad")
|
||||
|
||||
session = _make_session(skill="all-bad")
|
||||
assert session._skill_resources_dir is None
|
||||
session.close()
|
||||
|
||||
def test_dot_path_rejected(self, tmp_db):
|
||||
"""A bare '.' path is rejected rather than crashing."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "dot-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", ".", "bad")
|
||||
db.create_skill_resource("r2", "s1", "scripts/ok.py", "good")
|
||||
|
||||
session = _make_session(skill="dot-skill")
|
||||
base = session._skill_resources_dir
|
||||
assert os.path.isfile(os.path.join(base, "scripts", "ok.py"))
|
||||
session.close()
|
||||
|
||||
def test_empty_path_rejected(self, tmp_db):
|
||||
"""An empty string path is rejected."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "empty-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", "", "bad")
|
||||
db.create_skill_resource("r2", "s1", "scripts/ok.py", "good")
|
||||
|
||||
session = _make_session(skill="empty-skill")
|
||||
assert session._skill_resources_dir is not None
|
||||
session.close()
|
||||
|
||||
def test_nested_traversal_rejected(self, tmp_db):
|
||||
"""Traversal hidden inside a valid prefix is still caught."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "nested-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", "scripts/../../../etc/passwd", "bad")
|
||||
db.create_skill_resource("r2", "s1", "scripts/ok.py", "good")
|
||||
|
||||
session = _make_session(skill="nested-skill")
|
||||
base = session._skill_resources_dir
|
||||
assert not os.path.exists(os.path.join(base, "etc"))
|
||||
assert os.path.isfile(os.path.join(base, "scripts", "ok.py"))
|
||||
session.close()
|
||||
|
||||
def test_double_close_idempotent(self, tmp_db):
|
||||
"""Calling close() twice does not raise."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "double-skill", "content")
|
||||
db.create_skill_resource("r1", "s1", "scripts/x.py", "code")
|
||||
|
||||
session = _make_session(skill="double-skill")
|
||||
session.close()
|
||||
session.close() # must not raise
|
||||
|
||||
|
||||
class TestPreflightValidation:
|
||||
def test_missing_resource_warns(self, tmp_db):
|
||||
"""Skill content references a script not in resources."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "warn-skill", "Run scripts/missing.py to start.")
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, skill="warn-skill")
|
||||
ui.on_info.assert_called_once()
|
||||
msg = ui.on_info.call_args[0][0]
|
||||
assert "scripts/missing.py" in msg
|
||||
assert "warn-skill" in msg
|
||||
session.close()
|
||||
|
||||
def test_all_resources_present_no_warn(self, tmp_db):
|
||||
"""No warning when all referenced paths are bundled."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "ok-skill", "Run scripts/helper.py for help.")
|
||||
db.create_skill_resource("r1", "s1", "scripts/helper.py", "print('hi')")
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, skill="ok-skill")
|
||||
ui.on_info.assert_not_called()
|
||||
session.close()
|
||||
|
||||
def test_no_references_no_warn(self, tmp_db):
|
||||
"""Skill content with no resource paths triggers no validation warning."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "plain-skill", "Just a plain skill with no paths.")
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, skill="plain-skill")
|
||||
ui.on_info.assert_not_called()
|
||||
session.close()
|
||||
|
||||
def test_multiple_missing_warns_once(self, tmp_db):
|
||||
"""Multiple missing resources produce a single warning listing all."""
|
||||
db = get_storage()
|
||||
_create_skill(
|
||||
db,
|
||||
"s1",
|
||||
"multi-skill",
|
||||
"Use scripts/a.py and scripts/b.sh to process references/guide.md",
|
||||
)
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, skill="multi-skill")
|
||||
ui.on_info.assert_called_once()
|
||||
msg = ui.on_info.call_args[0][0]
|
||||
assert "3 resource(s)" in msg
|
||||
assert "scripts/a.py" in msg
|
||||
assert "scripts/b.sh" in msg
|
||||
assert "references/guide.md" in msg
|
||||
session.close()
|
||||
|
||||
def test_validation_skipped_no_skill(self, tmp_db):
|
||||
"""No crash or warning when no skill is active."""
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui)
|
||||
ui.on_info.assert_not_called()
|
||||
session.close()
|
||||
|
||||
def test_json_extension_not_truncated(self, tmp_db):
|
||||
"""assets/config.json should match as .json, not .js."""
|
||||
db = get_storage()
|
||||
_create_skill(db, "s1", "json-skill", "Load assets/config.json for settings.")
|
||||
db.create_skill_resource("r1", "s1", "assets/config.json", "{}")
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, skill="json-skill")
|
||||
ui.on_info.assert_not_called()
|
||||
session.close()
|
||||
|
||||
def test_compound_prefix_not_matched(self, tmp_db):
|
||||
"""'myscripts/tool.py' should not match as 'scripts/tool.py'."""
|
||||
db = get_storage()
|
||||
_create_skill(
|
||||
db,
|
||||
"s1",
|
||||
"compound-skill",
|
||||
"The myscripts/tool.py file is unrelated.",
|
||||
)
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, skill="compound-skill")
|
||||
ui.on_info.assert_not_called()
|
||||
session.close()
|
||||
|
||||
def test_extension_suffix_not_matched(self, tmp_db):
|
||||
"""'scripts/tool.python' should not match as 'scripts/tool.py'."""
|
||||
db = get_storage()
|
||||
_create_skill(
|
||||
db,
|
||||
"s1",
|
||||
"suffix-skill",
|
||||
"Run scripts/tool.python to start.",
|
||||
)
|
||||
|
||||
ui = NullUI()
|
||||
ui.on_info = MagicMock()
|
||||
session = _make_session(ui=ui, skill="suffix-skill")
|
||||
ui.on_info.assert_not_called()
|
||||
session.close()
|
||||
+109
-2
@@ -55,8 +55,8 @@ def _make_app(tls_manager):
|
||||
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="",
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="config",
|
||||
scopes=frozenset({"approve", "service"}),
|
||||
token_source="test",
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
@@ -124,6 +124,113 @@ def test_delete_cert_not_found(tls_manager):
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Auth enforcement ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_app_no_auth(tls_manager):
|
||||
"""Create app without auth middleware — simulates unauthenticated requests."""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
|
||||
from turnstone.console.server import (
|
||||
tls_ca_cert,
|
||||
tls_ca_status,
|
||||
tls_delete_cert,
|
||||
tls_list_certs,
|
||||
tls_renew_cert,
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/ca", tls_ca_status),
|
||||
Route("/ca.pem", tls_ca_cert),
|
||||
Route("/certs", tls_list_certs),
|
||||
Route("/certs/{domain}/renew", tls_renew_cert, methods=["POST"]),
|
||||
Route("/certs/{domain}", tls_delete_cert, methods=["DELETE"]),
|
||||
],
|
||||
)
|
||||
app.state.tls_manager = tls_manager
|
||||
return app
|
||||
|
||||
|
||||
def _make_app_read_only(tls_manager):
|
||||
"""Create app with read-only auth — should be rejected by admin endpoints."""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Route
|
||||
|
||||
from turnstone.console.server import (
|
||||
tls_ca_cert,
|
||||
tls_ca_status,
|
||||
tls_delete_cert,
|
||||
tls_list_certs,
|
||||
tls_renew_cert,
|
||||
)
|
||||
from turnstone.core.auth import AuthResult
|
||||
|
||||
async def _grant_read(request, call_next): # type: ignore[no-untyped-def]
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="viewer",
|
||||
scopes=frozenset({"read"}),
|
||||
token_source="jwt",
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/ca", tls_ca_status),
|
||||
Route("/ca.pem", tls_ca_cert),
|
||||
Route("/certs", tls_list_certs),
|
||||
Route("/certs/{domain}/renew", tls_renew_cert, methods=["POST"]),
|
||||
Route("/certs/{domain}", tls_delete_cert, methods=["DELETE"]),
|
||||
],
|
||||
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_read)],
|
||||
)
|
||||
app.state.tls_manager = tls_manager
|
||||
return app
|
||||
|
||||
|
||||
def test_unauthenticated_list_certs_401(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app_no_auth(tls_manager))
|
||||
resp = client.get("/certs")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_unauthenticated_renew_401(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app_no_auth(tls_manager))
|
||||
resp = client.post("/certs/test.internal/renew")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_unauthenticated_delete_401(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app_no_auth(tls_manager))
|
||||
resp = client.delete("/certs/test.internal")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_read_only_renew_403(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app_read_only(tls_manager))
|
||||
resp = client.post("/certs/test.internal/renew")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_read_only_delete_403(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app_read_only(tls_manager))
|
||||
resp = client.delete("/certs/test.internal")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ── CLI bootstrap ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -84,5 +84,5 @@ def test_collector_tls_defaults():
|
||||
|
||||
storage_mock = MagicMock()
|
||||
collector = ClusterCollector(storage=storage_mock)
|
||||
# Should create httpx client without errors
|
||||
assert collector._http_client is not None
|
||||
# Should store TLS settings for async client creation
|
||||
assert collector._tls_verify is True
|
||||
|
||||
@@ -145,7 +145,7 @@ async def test_tls_ca_cert_endpoint(tls_manager):
|
||||
|
||||
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="", scopes=frozenset({"approve"}), token_source="config"
|
||||
user_id="", scopes=frozenset({"approve", "service"}), token_source="test"
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
@@ -190,7 +190,7 @@ async def test_tls_endpoints_disabled():
|
||||
|
||||
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="", scopes=frozenset({"approve"}), token_source="config"
|
||||
user_id="", scopes=frozenset({"approve", "service"}), token_source="test"
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
+54
-10
@@ -713,6 +713,41 @@ class TestWebUI:
|
||||
assert ui._plan_result == "approved"
|
||||
t.join()
|
||||
|
||||
def test_pending_plan_review_stored_and_replayed(self):
|
||||
"""Plan review state is stored for SSE reconnection replay."""
|
||||
from turnstone.server import WebUI
|
||||
|
||||
ui = WebUI(ws_id="test")
|
||||
assert ui._pending_plan_review is None
|
||||
|
||||
# Simulate on_plan_review in a background thread (it blocks)
|
||||
def review():
|
||||
ui.on_plan_review("Here is the plan")
|
||||
|
||||
t = threading.Thread(target=review)
|
||||
t.start()
|
||||
time.sleep(0.1)
|
||||
|
||||
# While blocking, pending state should be set
|
||||
assert ui._pending_plan_review is not None
|
||||
assert ui._pending_plan_review["type"] == "plan_review"
|
||||
assert ui._pending_plan_review["content"] == "Here is the plan"
|
||||
|
||||
# Resolve — pending state should be cleared
|
||||
ui.resolve_plan("looks good")
|
||||
t.join(timeout=2)
|
||||
assert ui._pending_plan_review is None
|
||||
assert ui._plan_result == "looks good"
|
||||
|
||||
def test_pending_plan_review_cleared_on_resolve_before_wait_returns(self):
|
||||
"""resolve_plan clears pending state immediately, not just after wait."""
|
||||
from turnstone.server import WebUI
|
||||
|
||||
ui = WebUI(ws_id="test")
|
||||
ui._pending_plan_review = {"type": "plan_review", "content": "test"}
|
||||
ui.resolve_plan("ok")
|
||||
assert ui._pending_plan_review is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebUI SSE fan-out
|
||||
@@ -730,13 +765,23 @@ class TestWebUIFanOut:
|
||||
ui._enqueue({"type": "content", "text": "hello"}) # should not raise
|
||||
|
||||
def test_enqueue_single_listener(self):
|
||||
"""Single listener receives the event."""
|
||||
"""Single listener receives the event with ws_id stamped."""
|
||||
from turnstone.server import WebUI
|
||||
|
||||
ui = WebUI(ws_id="test")
|
||||
q = ui._register_listener()
|
||||
ui._enqueue({"type": "content", "text": "hello"})
|
||||
assert q.get_nowait() == {"type": "content", "text": "hello"}
|
||||
assert q.get_nowait() == {"type": "content", "text": "hello", "ws_id": "test"}
|
||||
|
||||
def test_enqueue_does_not_mutate_input(self):
|
||||
"""_enqueue must not mutate the caller's dict."""
|
||||
from turnstone.server import WebUI
|
||||
|
||||
ui = WebUI(ws_id="test")
|
||||
ui._register_listener()
|
||||
original = {"type": "content", "text": "hello"}
|
||||
ui._enqueue(original)
|
||||
assert "ws_id" not in original
|
||||
|
||||
def test_enqueue_multiple_listeners(self):
|
||||
"""All registered listeners receive an identical copy."""
|
||||
@@ -747,12 +792,12 @@ class TestWebUIFanOut:
|
||||
q2 = ui._register_listener()
|
||||
q3 = ui._register_listener()
|
||||
|
||||
event = {"type": "content", "text": "world"}
|
||||
ui._enqueue(event)
|
||||
ui._enqueue({"type": "content", "text": "world"})
|
||||
|
||||
assert q1.get_nowait() == event
|
||||
assert q2.get_nowait() == event
|
||||
assert q3.get_nowait() == event
|
||||
expected = {"type": "content", "text": "world", "ws_id": "test"}
|
||||
assert q1.get_nowait() == expected
|
||||
assert q2.get_nowait() == expected
|
||||
assert q3.get_nowait() == expected
|
||||
|
||||
def test_unregister_stops_delivery(self):
|
||||
"""After unregister, the queue receives no further events."""
|
||||
@@ -784,11 +829,10 @@ class TestWebUIFanOut:
|
||||
assert fast.qsize() == 0
|
||||
|
||||
# Enqueue via fan-out — slow drops (full), fast receives
|
||||
event = {"type": "content", "text": "overflow"}
|
||||
ui._enqueue(event)
|
||||
ui._enqueue({"type": "content", "text": "overflow"})
|
||||
assert slow.qsize() == 500 # still full, overflow dropped
|
||||
assert fast.qsize() == 1
|
||||
assert fast.get_nowait() == event
|
||||
assert fast.get_nowait() == {"type": "content", "text": "overflow", "ws_id": "test"}
|
||||
|
||||
def test_unregister_idempotent(self):
|
||||
"""Double unregister does not raise."""
|
||||
|
||||
@@ -56,11 +56,9 @@
|
||||
# --- Auth (node, console) ---
|
||||
|
||||
[auth]
|
||||
# enabled = true # env: TURNSTONE_AUTH_ENABLED
|
||||
# Auth is always enabled. JWT secret is required.
|
||||
# jwt_secret = "" # HS256 signing secret (min 32 bytes recommended)
|
||||
# env: TURNSTONE_JWT_SECRET
|
||||
# token = "" # Static config token for full access
|
||||
# env: TURNSTONE_AUTH_TOKEN
|
||||
|
||||
# --- Logging (turnstone, node, console) ---
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "0.9.7"
|
||||
__version__ = "1.0.0"
|
||||
|
||||
+13
-18
@@ -265,9 +265,19 @@ def _cmd_tls_list(args: argparse.Namespace) -> None:
|
||||
|
||||
url = f"{console_url}/v1/api/admin/tls/certs"
|
||||
headers = {}
|
||||
token = getattr(args, "auth_token", "") or _get_config_token()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
# Prefer JWT via ServiceTokenManager when JWT secret is available
|
||||
jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
|
||||
if jwt_secret:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, ServiceTokenManager
|
||||
|
||||
mgr = ServiceTokenManager(
|
||||
user_id="admin-cli",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="cli",
|
||||
secret=jwt_secret,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
headers["Authorization"] = f"Bearer {mgr.token}"
|
||||
resp = httpx.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
@@ -283,20 +293,6 @@ def _cmd_tls_list(args: argparse.Namespace) -> None:
|
||||
print(f"{c['domain']:<30s} {c['issued_at']:<22s} {c['expires_at']:<22s}")
|
||||
|
||||
|
||||
def _get_config_token() -> str:
|
||||
"""Try to load auth token from config.toml or environment."""
|
||||
token = os.environ.get("TURNSTONE_AUTH_TOKEN", "")
|
||||
if token:
|
||||
return token
|
||||
try:
|
||||
from turnstone.core.config import load_config
|
||||
|
||||
cfg = load_config("auth")
|
||||
return str(cfg.get("token", ""))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _discover_console_url() -> str:
|
||||
"""Discover console URL from the services table."""
|
||||
from turnstone.core.storage import get_storage
|
||||
@@ -381,7 +377,6 @@ def main() -> None:
|
||||
|
||||
p_tlslist = sub.add_parser("tls-list", help="List issued certificates")
|
||||
p_tlslist.add_argument("--console-url", default="", help="Console URL")
|
||||
p_tlslist.add_argument("--auth-token", default="", help="Auth token for admin API")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
|
||||
@@ -942,6 +942,41 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Admin: Prompt Policies ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/prompt-policies",
|
||||
"GET",
|
||||
"List all prompt policies for system message composition",
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/prompt-policies",
|
||||
"POST",
|
||||
"Create a prompt policy",
|
||||
error_codes=[400],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/prompt-policies/{policy_id}",
|
||||
"GET",
|
||||
"Get a single prompt policy",
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/prompt-policies/{policy_id}",
|
||||
"PUT",
|
||||
"Update a prompt policy",
|
||||
error_codes=[400, 404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/prompt-policies/{policy_id}",
|
||||
"DELETE",
|
||||
"Delete a prompt policy",
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Admin: TLS / ACME ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/tls/ca",
|
||||
|
||||
@@ -57,6 +57,10 @@ class CreateWorkstreamRequest(BaseModel):
|
||||
description="Workstream ID to resume atomically during creation (empty = fresh start)",
|
||||
)
|
||||
skill: str = Field(default="", description="Skill name (replaces default skills)")
|
||||
client_type: str = Field(
|
||||
default="",
|
||||
description="Client surface type (web, cli, chat). Defaults to web for server-created sessions.",
|
||||
)
|
||||
|
||||
|
||||
class CreateWorkstreamResponse(BaseModel):
|
||||
|
||||
@@ -137,8 +137,11 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
|
||||
"/v1/api/events/global",
|
||||
"GET",
|
||||
"Global SSE event stream",
|
||||
description="Global Server-Sent Events stream for state-change broadcasts "
|
||||
"across all workstreams. Returns text/event-stream.",
|
||||
description="Server-Sent Events stream for node-level state broadcasts. "
|
||||
"Emits a node_snapshot event on connect (workstreams, health, aggregate), "
|
||||
"followed by real-time delta events (ws_state, ws_activity, ws_created, "
|
||||
"ws_closed, ws_rename, health_changed, aggregate). "
|
||||
"Pass ?expected_node_id=X for identity verification (returns 409 on mismatch).",
|
||||
tags=["Streaming"],
|
||||
),
|
||||
# --- Saved workstreams ---
|
||||
|
||||
+43
-14
@@ -82,10 +82,9 @@ For commercial providers (OpenAI, Anthropic-via-proxy), use the real key.
|
||||
- `POSTGRES_USER` — PostgreSQL username (default: turnstone)
|
||||
- `POSTGRES_PASSWORD` — PostgreSQL password (required for production/cluster)
|
||||
|
||||
### Authentication
|
||||
- `TURNSTONE_AUTH_ENABLED` — Enable auth (`true`/empty)
|
||||
- `TURNSTONE_JWT_SECRET` — JWT signing secret (required if auth enabled)
|
||||
- `TURNSTONE_AUTH_TOKEN` — Static bearer token for inter-service auth
|
||||
### Authentication (always enabled)
|
||||
- `TURNSTONE_JWT_SECRET` — JWT signing secret (required). All services must share the same secret. \
|
||||
Generate with: `python -c "import secrets; print(secrets.token_hex(32))"`
|
||||
|
||||
### OIDC SSO (optional)
|
||||
- `TURNSTONE_OIDC_ISSUER` — OIDC issuer URL (e.g., https://accounts.google.com). Setting this + CLIENT_ID + CLIENT_SECRET enables SSO.
|
||||
@@ -162,8 +161,9 @@ Walk the user through setting up their deployment step by step:
|
||||
(may differ from this wizard's model). Ask for base URL, API key, model name.
|
||||
4. **Database**: SQLite (dev/simple) vs PostgreSQL (production/cluster). \
|
||||
PostgreSQL is required for cluster mode.
|
||||
5. **Security**: Recommend enabling auth for any non-local deployment. \
|
||||
Use `generate_secret` for JWT secret, auth token, and Postgres password. \
|
||||
5. **Security**: Auth is always enabled and requires `TURNSTONE_JWT_SECRET`. \
|
||||
Use `generate_secret` for JWT secret and Postgres password. \
|
||||
Always set `TURNSTONE_JWT_SECRET` in the .env. \
|
||||
Ask for initial admin username and password. \
|
||||
If the user's deployment will use an external identity provider (Okta, Azure AD, Google, etc.), \
|
||||
offer to configure OIDC SSO. Ask for the issuer URL, client ID, and client secret. \
|
||||
@@ -192,7 +192,7 @@ Note: the MCP SDK's DNS rebinding protection must be disabled for Docker-interna
|
||||
(the compose.yaml handles this), and the server must bind to 0.0.0.0 (not 127.0.0.1) to be \
|
||||
reachable from other containers.
|
||||
- The `DATABASE_URL` for docker compose internal networking uses the hostname `postgres` \
|
||||
(e.g., `postgresql://turnstone:<password>@postgres:5432/turnstone`).
|
||||
(e.g., `postgresql+psycopg://turnstone:<password>@postgres:5432/turnstone`).
|
||||
- For local LLM backends (vLLM, llama.cpp, Ollama, etc.), set `OPENAI_API_KEY=dummy` in the \
|
||||
.env file — local servers typically don't require authentication. The `LLM_BASE_URL` should \
|
||||
use `host.docker.internal` to reach the host machine from inside Docker \
|
||||
@@ -643,22 +643,42 @@ class _BootstrapLLM:
|
||||
messages=messages,
|
||||
tools=tools if tools else None,
|
||||
)
|
||||
choice = resp.choices[0]
|
||||
content = choice.message.content or ""
|
||||
# Guard against non-spec responses from proxies (Open WebUI, LiteLLM, etc.)
|
||||
if resp is None:
|
||||
raise RuntimeError(
|
||||
"Server returned null — your OpenAI-compatible endpoint may not "
|
||||
"support tool calling. Try a direct connection to the model server."
|
||||
)
|
||||
choices = getattr(resp, "choices", None)
|
||||
if not choices:
|
||||
raise RuntimeError(
|
||||
"Server returned an empty choices array. "
|
||||
"The model may have hit its context limit, or the proxy "
|
||||
"dropped the response."
|
||||
)
|
||||
choice = choices[0]
|
||||
message = getattr(choice, "message", None)
|
||||
if message is None:
|
||||
raise RuntimeError(
|
||||
"Server returned a choice with no message. "
|
||||
"Your OpenAI-compatible endpoint may not fully implement "
|
||||
"the chat completions API."
|
||||
)
|
||||
content = message.content or ""
|
||||
tool_calls = None
|
||||
if choice.message.tool_calls:
|
||||
if getattr(message, "tool_calls", None):
|
||||
tool_calls = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"id": getattr(tc, "id", None) or f"call_{secrets.token_hex(4)}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
},
|
||||
}
|
||||
for tc in choice.message.tool_calls
|
||||
for i, tc in enumerate(message.tool_calls)
|
||||
]
|
||||
return content, tool_calls, choice.finish_reason or "stop"
|
||||
return content, tool_calls, getattr(choice, "finish_reason", None) or "stop"
|
||||
|
||||
# -- Anthropic path -----------------------------------------------------
|
||||
|
||||
@@ -1002,7 +1022,16 @@ def _run_conversation(
|
||||
retries += 1
|
||||
if retries >= _max_retries:
|
||||
print(f"\n{RED}LLM error after {_max_retries} attempts: {exc}{RESET}")
|
||||
print("Please check your connection and try again.")
|
||||
print()
|
||||
print("Troubleshooting:")
|
||||
print(
|
||||
f" {DIM}• If using a proxy (Open WebUI, LiteLLM), try connecting directly{RESET}"
|
||||
)
|
||||
print(f" {DIM}• Verify the endpoint supports tool/function calling{RESET}")
|
||||
print(f" {DIM}• Check that the model context window isn't exceeded{RESET}")
|
||||
print(
|
||||
f" {DIM}• Try a different model — not all models handle tool calls reliably{RESET}"
|
||||
)
|
||||
return
|
||||
print(f"\n{RED}LLM error: {exc}{RESET}")
|
||||
print(f"{DIM}Retrying ({retries}/{_max_retries})...{RESET}")
|
||||
|
||||
@@ -139,6 +139,26 @@ def format_plan_review(content: str) -> str:
|
||||
return f"**Plan review requested:**\n\n{content}"
|
||||
|
||||
|
||||
def format_tool_result(output: str) -> str:
|
||||
"""Format a tool result into a compact code-block summary.
|
||||
|
||||
Truncates to the first 10 lines (plus an ellipsis line if trimmed) or
|
||||
500 characters, whichever is shorter.
|
||||
"""
|
||||
# Truncate to 10 lines.
|
||||
lines = output.split("\n", 10)
|
||||
if len(lines) > 10:
|
||||
lines = lines[:10]
|
||||
lines.append("\u2026")
|
||||
trimmed = "\n".join(lines)
|
||||
# Escape triple backticks to prevent code-block breakout.
|
||||
trimmed = trimmed.replace("```", "` ` `")
|
||||
# Truncate to 500 chars (after escaping, which can expand the string).
|
||||
if len(trimmed) > 500:
|
||||
trimmed = trimmed[:497] + "\u2026"
|
||||
return f"```\n{trimmed}\n```"
|
||||
|
||||
|
||||
def truncate(text: str, max_length: int = 200) -> str:
|
||||
"""Truncate *text* to *max_length*, appending an ellipsis if trimmed."""
|
||||
if len(text) <= max_length:
|
||||
|
||||
@@ -37,10 +37,9 @@ async def _handle_health(request: Request) -> JSONResponse:
|
||||
|
||||
def _check_auth(request: Request) -> JSONResponse | None:
|
||||
"""Validate the request's Authorization header. Returns an error response or None."""
|
||||
auth_token: str = getattr(request.app.state, "auth_token", "")
|
||||
jwt_secret: str = getattr(request.app.state, "jwt_secret", "")
|
||||
|
||||
if not auth_token and not jwt_secret:
|
||||
if not jwt_secret:
|
||||
log.warning("notify.auth_not_configured")
|
||||
return JSONResponse({"error": "authentication not configured"}, status_code=401)
|
||||
|
||||
@@ -50,15 +49,8 @@ def _check_auth(request: Request) -> JSONResponse | None:
|
||||
|
||||
token = header[7:]
|
||||
|
||||
# Static token check
|
||||
if auth_token:
|
||||
import hmac
|
||||
|
||||
if hmac.compare_digest(token, auth_token):
|
||||
return None
|
||||
|
||||
# JWT check
|
||||
if jwt_secret and "." in token:
|
||||
# JWT validation
|
||||
if "." in token:
|
||||
from turnstone.core.auth import JWT_AUD_CHANNEL, validate_jwt
|
||||
|
||||
result = validate_jwt(token, jwt_secret, audience=JWT_AUD_CHANNEL)
|
||||
@@ -178,7 +170,6 @@ def create_channel_app(
|
||||
adapters: dict[str, ChannelAdapter],
|
||||
storage: StorageBackend,
|
||||
*,
|
||||
auth_token: str = "",
|
||||
jwt_secret: str = "",
|
||||
) -> Starlette:
|
||||
"""Create the channel gateway HTTP application."""
|
||||
@@ -195,7 +186,6 @@ def create_channel_app(
|
||||
)
|
||||
app.state.adapters = adapters
|
||||
app.state.storage = storage
|
||||
app.state.auth_token = auth_token
|
||||
app.state.jwt_secret = jwt_secret
|
||||
return app
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ class ChannelRouter:
|
||||
name: str = "",
|
||||
model: str = "",
|
||||
initial_message: str = "",
|
||||
client_type: str = "",
|
||||
) -> tuple[str, bool]:
|
||||
"""Look up or create a workstream for a channel.
|
||||
|
||||
@@ -176,6 +177,7 @@ class ChannelRouter:
|
||||
skill=self._skill,
|
||||
auto_approve=self._auto_approve,
|
||||
auto_approve_tools=_tools_csv,
|
||||
client_type=client_type,
|
||||
)
|
||||
ws_id = data.get("ws_id", "")
|
||||
else:
|
||||
@@ -187,6 +189,7 @@ class ChannelRouter:
|
||||
skill=self._skill,
|
||||
auto_approve=self._auto_approve,
|
||||
auto_approve_tools=_tools_csv,
|
||||
client_type=client_type,
|
||||
)
|
||||
ws_id = resp.ws_id
|
||||
data = {"ws_id": resp.ws_id, "name": resp.name}
|
||||
|
||||
@@ -72,13 +72,6 @@ def main() -> None:
|
||||
parser.add_argument("--ssl-keyfile", default=None, help="SSL private key file")
|
||||
parser.add_argument("--ssl-ca-certs", default=None, help="SSL CA certs for client verification")
|
||||
|
||||
# -- Auth ----------------------------------------------------------------
|
||||
parser.add_argument(
|
||||
"--auth-token",
|
||||
default=os.environ.get("TURNSTONE_CHANNEL_AUTH_TOKEN", ""),
|
||||
help="Static auth token for /v1/api/notify (default: $TURNSTONE_CHANNEL_AUTH_TOKEN)",
|
||||
)
|
||||
|
||||
# -- Workstream defaults -------------------------------------------------
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
@@ -121,7 +114,6 @@ def main() -> None:
|
||||
)
|
||||
|
||||
# -- Auth config ---------------------------------------------------------
|
||||
auth_token = os.environ.get("TURNSTONE_AUTH_TOKEN", "") or args.auth_token
|
||||
jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
|
||||
|
||||
# Prefer auto-rotating service JWTs when jwt_secret is available.
|
||||
@@ -132,7 +124,7 @@ def main() -> None:
|
||||
if jwt_secret:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, JWT_AUD_SERVER, ServiceTokenManager
|
||||
|
||||
_scopes = frozenset({"read", "write", "approve"})
|
||||
_scopes = frozenset({"read", "write", "approve", "service"})
|
||||
_console_mgr = ServiceTokenManager(
|
||||
user_id="channel-gateway",
|
||||
scopes=_scopes,
|
||||
@@ -151,7 +143,6 @@ def main() -> None:
|
||||
)
|
||||
_console_token_factory = lambda: _console_mgr.token # noqa: E731
|
||||
_server_token_factory = lambda: _server_mgr.token # noqa: E731
|
||||
auth_token = "" # don't also pass static token
|
||||
|
||||
server_url: str = args.server_url
|
||||
console_url: str = args.console_url
|
||||
@@ -242,7 +233,6 @@ def main() -> None:
|
||||
config,
|
||||
server_url,
|
||||
storage,
|
||||
api_token=auth_token,
|
||||
console_url=console_url,
|
||||
console_token_factory=_console_token_factory,
|
||||
server_token_factory=_server_token_factory,
|
||||
@@ -253,7 +243,6 @@ def main() -> None:
|
||||
channel_app = create_channel_app(
|
||||
adapters, # type: ignore[arg-type]
|
||||
storage,
|
||||
auth_token=auth_token,
|
||||
jwt_secret=jwt_secret,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from turnstone.channels._formatter import chunk_message
|
||||
from turnstone.channels._routing import ChannelRouter
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.sdk.events import (
|
||||
ApprovalResolvedEvent,
|
||||
ApproveRequestEvent,
|
||||
ContentEvent,
|
||||
ErrorEvent,
|
||||
@@ -31,6 +32,10 @@ from turnstone.sdk.events import (
|
||||
PlanReviewEvent,
|
||||
ServerEvent,
|
||||
StreamEndEvent,
|
||||
ThinkingStartEvent,
|
||||
ThinkingStopEvent,
|
||||
ToolInfoEvent,
|
||||
ToolResultEvent,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -175,9 +180,16 @@ class TurnstoneBot:
|
||||
server_token_factory=server_token_factory,
|
||||
)
|
||||
|
||||
self._commands_synced: bool = False
|
||||
self._subscribed_ws: set[str] = set()
|
||||
self._sse_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._streaming: dict[str, StreamingMessage] = {}
|
||||
# Transient "Thinking..." status messages, deleted when content starts.
|
||||
self._thinking_msgs: dict[str, discord.Message] = {}
|
||||
# Per-tool "running" embeds, edited in-place when the result arrives.
|
||||
# List preserves call order for FIFO matching when the same tool name
|
||||
# appears more than once in a single turn.
|
||||
self._tool_info_msgs: dict[str, list[tuple[str, str, str, discord.Message]]] = {}
|
||||
# Track the Discord message containing the pending approval embed per
|
||||
# workstream so that IntentVerdictEvent can update it with LLM judge
|
||||
# results.
|
||||
@@ -193,12 +205,17 @@ class TurnstoneBot:
|
||||
# response message can be re-tracked for multi-turn DM conversations.
|
||||
self._notify_reply_channels: dict[str, tuple[discord.abc.Messageable, str]] = {}
|
||||
|
||||
# Shared HTTP client for SSE connections (long-lived, no timeout).
|
||||
# Shared HTTP client for SSE connections.
|
||||
# Read timeout detects half-open connections (server sends ping=5s
|
||||
# keepalives, so 90s is very conservative).
|
||||
# Token factory provides auto-rotating JWTs; static token is fallback.
|
||||
headers: dict[str, str] = {}
|
||||
if api_token and not server_token_factory:
|
||||
headers["Authorization"] = f"Bearer {api_token}"
|
||||
self._http_client = httpx.AsyncClient(headers=headers, timeout=None)
|
||||
self._http_client = httpx.AsyncClient(
|
||||
headers=headers,
|
||||
timeout=httpx.Timeout(connect=10.0, read=90.0, write=10.0, pool=10.0),
|
||||
)
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
@@ -219,6 +236,10 @@ class TurnstoneBot:
|
||||
async def on_ready() -> None:
|
||||
await self._on_ready()
|
||||
|
||||
@self._bot.event
|
||||
async def on_resumed() -> None:
|
||||
await self._on_resumed()
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
async def _setup_hook(self) -> None:
|
||||
@@ -236,23 +257,57 @@ class TurnstoneBot:
|
||||
log.info("discord.setup_hook_complete")
|
||||
|
||||
async def _on_ready(self) -> None:
|
||||
"""Sync slash commands and recover existing routes."""
|
||||
"""Sync slash commands (once) and recover existing routes."""
|
||||
import discord
|
||||
|
||||
bot = self._bot
|
||||
log.info("discord.ready", user=str(bot.user), guild_count=len(bot.guilds))
|
||||
|
||||
if self.config.guild_id:
|
||||
guild = discord.Object(id=self.config.guild_id)
|
||||
bot.tree.copy_global_to(guild=guild)
|
||||
await bot.tree.sync(guild=guild)
|
||||
log.info("discord.commands_synced", guild_id=self.config.guild_id)
|
||||
else:
|
||||
await bot.tree.sync()
|
||||
log.info("discord.commands_synced_global")
|
||||
if not self._commands_synced:
|
||||
if self.config.guild_id:
|
||||
guild = discord.Object(id=self.config.guild_id)
|
||||
bot.tree.copy_global_to(guild=guild)
|
||||
await bot.tree.sync(guild=guild)
|
||||
log.info("discord.commands_synced", guild_id=self.config.guild_id)
|
||||
else:
|
||||
await bot.tree.sync()
|
||||
log.info("discord.commands_synced_global")
|
||||
self._commands_synced = True
|
||||
|
||||
self._purge_dead_sse_tasks("ready")
|
||||
await self._recover_routes()
|
||||
|
||||
async def _on_resumed(self) -> None:
|
||||
"""Recover dead SSE tasks after a gateway session resume.
|
||||
|
||||
Unlike ``on_ready``, ``on_resumed`` fires when discord.py resumes
|
||||
an existing session after a brief disconnect — ``on_ready`` is NOT
|
||||
called in that case. Any SSE listener tasks that died during the
|
||||
blip need to be cleaned up and re-subscribed.
|
||||
"""
|
||||
self._purge_dead_sse_tasks("resumed")
|
||||
await self._recover_routes()
|
||||
|
||||
def _purge_dead_sse_tasks(self, trigger: str) -> None:
|
||||
"""Remove completed/failed SSE tasks so they can be re-subscribed."""
|
||||
dead = [ws_id for ws_id, task in self._sse_tasks.items() if task.done()]
|
||||
for ws_id in dead:
|
||||
task = self._sse_tasks.pop(ws_id)
|
||||
self._subscribed_ws.discard(ws_id)
|
||||
# Retrieve exception to suppress "Task exception was never
|
||||
# retrieved" warnings and log the underlying failure.
|
||||
if not task.cancelled():
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
log.warning(
|
||||
"discord.sse_task_failed",
|
||||
trigger=trigger,
|
||||
ws_id=ws_id,
|
||||
error=str(exc),
|
||||
)
|
||||
if dead:
|
||||
log.info("discord.purged_dead_tasks", trigger=trigger, count=len(dead), ws_ids=dead)
|
||||
|
||||
async def _recover_routes(self) -> None:
|
||||
"""Re-subscribe to event channels for existing discord routes.
|
||||
|
||||
@@ -302,6 +357,11 @@ class TurnstoneBot:
|
||||
await task
|
||||
self._subscribed_ws.discard(ws_id)
|
||||
self._streaming.pop(ws_id, None)
|
||||
thinking_msg = self._thinking_msgs.pop(ws_id, None)
|
||||
if thinking_msg is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await thinking_msg.delete()
|
||||
self._tool_info_msgs.pop(ws_id, None)
|
||||
self._pending_approval_msgs.pop(ws_id, None)
|
||||
self._notify_reply_channels.pop(ws_id, None)
|
||||
# Purge stale notification tracking entries for this workstream.
|
||||
@@ -322,6 +382,11 @@ class TurnstoneBot:
|
||||
self._subscribed_ws.discard(ws_id)
|
||||
self._sse_tasks.pop(ws_id, None)
|
||||
self._streaming.pop(ws_id, None)
|
||||
thinking_msg = self._thinking_msgs.pop(ws_id, None)
|
||||
if thinking_msg is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await thinking_msg.delete()
|
||||
self._tool_info_msgs.pop(ws_id, None)
|
||||
self._pending_approval_msgs.pop(ws_id, None)
|
||||
self._notify_reply_channels.pop(ws_id, None)
|
||||
stale = [mid for mid, entry in self._notify_ws_map.items() if entry[0] == ws_id]
|
||||
@@ -340,14 +405,15 @@ class TurnstoneBot:
|
||||
"""
|
||||
import httpx_sse
|
||||
|
||||
# When routing through the console, connect SSE directly to the
|
||||
# assigned server node (node_url from the create response).
|
||||
node_base = await self.router.get_node_url(ws_id)
|
||||
url = f"{node_base}/v1/api/events"
|
||||
delay = _SSE_RECONNECT_DELAY
|
||||
url = "" # set before loop so exception handlers can reference it
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Re-resolve node URL on each attempt so reconnects pick up
|
||||
# changes after bot restarts or router cache expiry.
|
||||
node_base = await self.router.get_node_url(ws_id)
|
||||
url = f"{node_base}/v1/api/events"
|
||||
# Refresh auth header per-connection (token may have rotated)
|
||||
sse_headers: dict[str, str] | None = None
|
||||
if self._token_factory is not None:
|
||||
@@ -371,7 +437,13 @@ class TurnstoneBot:
|
||||
ws_id=ws_id,
|
||||
status=status,
|
||||
)
|
||||
# Fall through to backoff/retry for transient errors.
|
||||
# Don't try to parse a non-SSE error body —
|
||||
# fall through to backoff/retry below.
|
||||
raise httpx.HTTPStatusError(
|
||||
f"SSE upstream {status}",
|
||||
request=event_source.response.request,
|
||||
response=event_source.response,
|
||||
)
|
||||
delay = _SSE_RECONNECT_DELAY # reset on successful connect
|
||||
async for sse in event_source.aiter_sse():
|
||||
if sse.event == "message" or not sse.event:
|
||||
@@ -385,12 +457,34 @@ class TurnstoneBot:
|
||||
)
|
||||
continue
|
||||
event = ServerEvent.from_dict(data)
|
||||
await self._on_ws_event(ws_id, thread, event)
|
||||
try:
|
||||
await self._on_ws_event(ws_id, thread, event)
|
||||
except Exception:
|
||||
# Discord API failures (rate limits, outages)
|
||||
# must not kill the SSE connection.
|
||||
log.warning(
|
||||
"discord.event_dispatch_failed",
|
||||
ws_id=ws_id,
|
||||
exc_info=True,
|
||||
)
|
||||
except httpx.HTTPStatusError:
|
||||
pass # already logged above; fall through to backoff
|
||||
except httpx.RemoteProtocolError:
|
||||
# Server closed connection (normal on stream_end or shutdown).
|
||||
log.debug("discord.sse_remote_closed", ws_id=ws_id)
|
||||
except asyncio.CancelledError:
|
||||
return # unsubscribe or shutdown
|
||||
except httpx.ReadTimeout:
|
||||
# No data received within read timeout — likely a half-open
|
||||
# connection. Reconnect to recover.
|
||||
log.info("discord.sse_read_timeout", ws_id=ws_id)
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
|
||||
log.warning(
|
||||
"discord.sse_connect_failed",
|
||||
ws_id=ws_id,
|
||||
url=url,
|
||||
error=str(exc),
|
||||
)
|
||||
except Exception:
|
||||
log.warning("discord.sse_error", ws_id=ws_id, exc_info=True)
|
||||
|
||||
@@ -416,7 +510,28 @@ class TurnstoneBot:
|
||||
)
|
||||
from turnstone.channels.discord.views import ApprovalView, PlanReviewView
|
||||
|
||||
if isinstance(event, ContentEvent):
|
||||
if isinstance(event, ThinkingStartEvent):
|
||||
# Clean up any prior thinking message (consecutive starts without stop).
|
||||
prev = self._thinking_msgs.pop(ws_id, None)
|
||||
if prev is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await prev.delete()
|
||||
try:
|
||||
msg = await thread.send("*Thinking...*")
|
||||
self._thinking_msgs[ws_id] = msg
|
||||
except Exception:
|
||||
log.debug("discord.thinking_start_send_failed", ws_id=ws_id)
|
||||
|
||||
elif isinstance(event, ThinkingStopEvent):
|
||||
# Leave the thinking message in place — the next visible event
|
||||
# (ContentEvent, ToolInfoEvent, StreamEndEvent) will edit or
|
||||
# clean it up, avoiding a delete→gap→new-message flicker.
|
||||
pass
|
||||
|
||||
elif isinstance(event, ContentEvent):
|
||||
# Reuse thinking message as the initial streaming message so the
|
||||
# first flush edits it in-place (no delete→gap→send flicker).
|
||||
thinking_msg = self._thinking_msgs.pop(ws_id, None)
|
||||
sm = self._streaming.get(ws_id)
|
||||
if sm is None:
|
||||
sm = StreamingMessage(
|
||||
@@ -424,9 +539,100 @@ class TurnstoneBot:
|
||||
max_length=self.config.max_message_length,
|
||||
edit_interval=self.config.streaming_edit_interval,
|
||||
)
|
||||
if thinking_msg is not None:
|
||||
sm._message = thinking_msg
|
||||
self._streaming[ws_id] = sm
|
||||
elif thinking_msg is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await thinking_msg.delete()
|
||||
await sm.append(event.text)
|
||||
|
||||
elif isinstance(event, ToolInfoEvent):
|
||||
from turnstone.channels._formatter import truncate
|
||||
|
||||
# Reuse the thinking message for the first tool embed.
|
||||
thinking_msg = self._thinking_msgs.pop(ws_id, None)
|
||||
|
||||
# Show a "running" embed for every tool. The approval dialog
|
||||
# (ApproveRequestEvent) is a separate concern — it asks "do you
|
||||
# authorize this?" while the running embed says "this tool is
|
||||
# executing." Both can coexist in the thread.
|
||||
for it in event.items:
|
||||
name = it.get("func_name") or it.get("approval_label") or "tool"
|
||||
raw_preview = it.get("preview", "")
|
||||
# Sanitize preview: escape backticks to prevent markdown
|
||||
# breakout and strip @-mentions.
|
||||
raw_preview = raw_preview.replace("`", "\\`")
|
||||
raw_preview = discord.utils.escape_mentions(raw_preview)
|
||||
preview = truncate(raw_preview, max_length=120) or None
|
||||
embed = discord.Embed(
|
||||
title=name,
|
||||
description=preview,
|
||||
color=discord.Color.light_grey(),
|
||||
)
|
||||
# Edit thinking message into first tool embed to avoid flicker.
|
||||
if thinking_msg is not None:
|
||||
try:
|
||||
await thinking_msg.edit(content=None, embed=embed)
|
||||
msg = thinking_msg
|
||||
except Exception:
|
||||
msg = await thread.send(embed=embed)
|
||||
thinking_msg = None
|
||||
else:
|
||||
msg = await thread.send(embed=embed)
|
||||
call_id = it.get("call_id", "")
|
||||
self._tool_info_msgs.setdefault(ws_id, []).append(
|
||||
(call_id, name, preview or "", msg)
|
||||
)
|
||||
|
||||
# If no items consumed the thinking message (empty event), clean up.
|
||||
if thinking_msg is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await thinking_msg.delete()
|
||||
|
||||
elif isinstance(event, ToolResultEvent):
|
||||
from turnstone.channels._formatter import format_tool_result
|
||||
|
||||
# Mark the matching "running" embed as complete/errored.
|
||||
# Prefer call_id match (deterministic); fall back to name (FIFO).
|
||||
info_list = self._tool_info_msgs.get(ws_id, [])
|
||||
matched_preview = ""
|
||||
matched_msg: discord.Message | None = None
|
||||
if event.call_id:
|
||||
for i, (cid, _tname, _prev, _tmsg) in enumerate(info_list):
|
||||
if cid == event.call_id:
|
||||
entry = info_list.pop(i)
|
||||
matched_preview, matched_msg = entry[2], entry[3]
|
||||
break
|
||||
if matched_msg is None:
|
||||
for i, (_cid, tname, _prev, _tmsg) in enumerate(info_list):
|
||||
if tname == event.name:
|
||||
entry = info_list.pop(i)
|
||||
matched_preview, matched_msg = entry[2], entry[3]
|
||||
break
|
||||
if matched_msg is not None:
|
||||
status = "Error" if event.is_error else "Done"
|
||||
status_color = discord.Color.red() if event.is_error else discord.Color.dark_grey()
|
||||
status_embed = discord.Embed(
|
||||
title=f"{event.name} \u2014 {status}",
|
||||
description=matched_preview or None,
|
||||
color=status_color,
|
||||
)
|
||||
try:
|
||||
await matched_msg.edit(content=None, embed=status_embed)
|
||||
except Exception:
|
||||
log.debug("discord.tool_info_status_edit_failed", ws_id=ws_id)
|
||||
|
||||
# Send the result as a separate message.
|
||||
desc = format_tool_result(event.output)
|
||||
color = discord.Color.red() if event.is_error else discord.Color.dark_grey()
|
||||
result_embed = discord.Embed(
|
||||
title=event.name,
|
||||
description=desc,
|
||||
color=color,
|
||||
)
|
||||
await thread.send(embed=result_embed)
|
||||
|
||||
elif isinstance(event, ApproveRequestEvent):
|
||||
# Evaluate admin tool policies before auto-approve.
|
||||
_policy_handled = False
|
||||
@@ -539,7 +745,26 @@ class TurnstoneBot:
|
||||
except Exception:
|
||||
log.debug("discord.verdict_embed_edit_failed", ws_id=ws_id)
|
||||
|
||||
elif isinstance(event, ApprovalResolvedEvent):
|
||||
# Server resolved the approval (timeout, external approve/reject).
|
||||
# Disable the buttons so they can't be clicked stale.
|
||||
approval_msg = self._pending_approval_msgs.pop(ws_id, None)
|
||||
if approval_msg is not None:
|
||||
from turnstone.channels.discord.views import disable_message_buttons
|
||||
|
||||
label = "Approved" if event.approved else "Denied"
|
||||
try:
|
||||
await disable_message_buttons(approval_msg, label)
|
||||
except Exception:
|
||||
log.debug("discord.approval_resolved_edit_failed", ws_id=ws_id)
|
||||
|
||||
elif isinstance(event, StreamEndEvent):
|
||||
# Edge-case cleanup: clear any lingering thinking indicator.
|
||||
thinking_msg = self._thinking_msgs.pop(ws_id, None)
|
||||
if thinking_msg is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await thinking_msg.delete()
|
||||
self._tool_info_msgs.pop(ws_id, None)
|
||||
sm = self._streaming.pop(ws_id, None)
|
||||
if sm is not None:
|
||||
await sm.finalize()
|
||||
|
||||
@@ -136,6 +136,7 @@ class MessageCog:
|
||||
str(channel.id),
|
||||
name=channel.name or "",
|
||||
initial_message="",
|
||||
client_type="chat",
|
||||
)
|
||||
except (TimeoutError, RuntimeError):
|
||||
log.warning("discord.ws_reactivation_failed", thread_id=channel.id)
|
||||
@@ -192,6 +193,7 @@ class MessageCog:
|
||||
name=thread_name,
|
||||
model=self.ts.config.model,
|
||||
initial_message="",
|
||||
client_type="chat",
|
||||
)
|
||||
|
||||
await self.ts.subscribe_ws(ws_id, thread)
|
||||
@@ -210,6 +212,10 @@ class MessageCog:
|
||||
# Only handle explicit replies to a tracked notification message.
|
||||
ref = message.reference
|
||||
if ref is None or ref.message_id is None:
|
||||
await message.channel.send(
|
||||
"*Direct messages aren't supported. "
|
||||
"Use `/ask` in a server channel or @mention me to start a conversation.*"
|
||||
)
|
||||
return
|
||||
|
||||
# Atomic pop prevents TOCTOU race across await points.
|
||||
@@ -366,6 +372,7 @@ class MessageCog:
|
||||
name=thread_name,
|
||||
model=self.ts.config.model,
|
||||
initial_message="",
|
||||
client_type="chat",
|
||||
)
|
||||
|
||||
await self.ts.subscribe_ws(ws_id, thread)
|
||||
|
||||
@@ -30,15 +30,17 @@ def _parse_footer(interaction: discord.Interaction) -> tuple[str, str] | None:
|
||||
return parts[0], parts[1]
|
||||
|
||||
|
||||
async def _disable_buttons(interaction: discord.Interaction, label: str) -> None:
|
||||
"""Edit the message to disable all buttons and append a result label."""
|
||||
async def disable_message_buttons(message: discord.Message, label: str) -> None:
|
||||
"""Disable all buttons on *message* and append *label* to the embed title.
|
||||
|
||||
Used both from interaction callbacks (via the message attribute) and
|
||||
from bot event handlers when the server resolves an approval externally
|
||||
(e.g. timeout).
|
||||
"""
|
||||
import discord
|
||||
|
||||
if interaction.message is None:
|
||||
return
|
||||
|
||||
view = discord.ui.View()
|
||||
for item in interaction.message.components or []:
|
||||
for item in message.components or []:
|
||||
for child in item.children: # type: ignore[union-attr]
|
||||
button: discord.ui.Button[discord.ui.View] = discord.ui.Button(
|
||||
label=getattr(child, "label", ""),
|
||||
@@ -48,12 +50,19 @@ async def _disable_buttons(interaction: discord.Interaction, label: str) -> None
|
||||
)
|
||||
view.add_item(button)
|
||||
|
||||
embed = interaction.message.embeds[0] if interaction.message.embeds else None
|
||||
embed = message.embeds[0] if message.embeds else None
|
||||
if embed is not None:
|
||||
embed.color = discord.Color.greyple()
|
||||
embed.title = f"{embed.title} - {label}"
|
||||
|
||||
await interaction.message.edit(embed=embed, view=view)
|
||||
await message.edit(embed=embed, view=view)
|
||||
|
||||
|
||||
async def _disable_buttons(interaction: discord.Interaction, label: str) -> None:
|
||||
"""Edit the interaction message to disable all buttons and append *label* to the embed title."""
|
||||
if interaction.message is None:
|
||||
return
|
||||
await disable_message_buttons(interaction.message, label)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -151,6 +160,8 @@ class ApprovalView:
|
||||
)
|
||||
|
||||
label = "Always Approved" if always else ("Approved" if approved else "Rejected")
|
||||
# Pop pending approval so ApprovalResolvedEvent doesn't double-update.
|
||||
self.bot._pending_approval_msgs.pop(ws_id, None)
|
||||
await _disable_buttons(interaction, label)
|
||||
await interaction.followup.send(
|
||||
f"Tool execution **{label.lower()}**.",
|
||||
|
||||
+15
-9
@@ -633,7 +633,7 @@ def _handle_ws_command(
|
||||
# ─── Cluster commands ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _handle_cluster_command(cmd_line: str, console_url: str | None, auth_token: str = "") -> None:
|
||||
def _handle_cluster_command(cmd_line: str, console_url: str | None) -> None:
|
||||
"""Handle /cluster subcommands querying the turnstone-console API."""
|
||||
import httpx
|
||||
|
||||
@@ -642,8 +642,18 @@ def _handle_cluster_command(cmd_line: str, console_url: str | None, auth_token:
|
||||
return
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
if auth_token:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
|
||||
if jwt_secret:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, ServiceTokenManager
|
||||
|
||||
_cluster_token_mgr = ServiceTokenManager(
|
||||
user_id="cli",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="cli",
|
||||
secret=jwt_secret,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
headers["Authorization"] = f"Bearer {_cluster_token_mgr.token}"
|
||||
|
||||
parts = cmd_line.strip().split()
|
||||
sub = parts[1] if len(parts) > 1 else "status"
|
||||
@@ -968,11 +978,6 @@ def main() -> None:
|
||||
default=None,
|
||||
help="Turnstone console URL for /cluster commands (e.g., http://localhost:8090)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--auth-token",
|
||||
default=os.environ.get("TURNSTONE_AUTH_TOKEN", ""),
|
||||
help="Bearer token for authenticating to turnstone services (default: $TURNSTONE_AUTH_TOKEN)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mcp-config",
|
||||
default=None,
|
||||
@@ -1132,6 +1137,7 @@ def main() -> None:
|
||||
ws_id: str | None = None,
|
||||
*,
|
||||
skill: str | None = None,
|
||||
client_type: str = "",
|
||||
) -> ChatSession:
|
||||
assert ui is not None, "session_factory requires a non-None UI"
|
||||
r_client, r_model, r_cfg = registry.resolve(model_alias)
|
||||
@@ -1246,7 +1252,7 @@ def main() -> None:
|
||||
continue
|
||||
|
||||
if user_input.startswith("/cluster"):
|
||||
_handle_cluster_command(user_input, args.console_url, args.auth_token)
|
||||
_handle_cluster_command(user_input, args.console_url)
|
||||
continue
|
||||
|
||||
active = manager.get_active()
|
||||
|
||||
+358
-201
@@ -1,22 +1,26 @@
|
||||
"""Cluster state collector — aggregates data from all turnstone nodes.
|
||||
|
||||
Discovers nodes via the service registry (StorageBackend), polls each
|
||||
node's /v1/api/dashboard endpoint for workstream data.
|
||||
Discovers nodes via the service registry (StorageBackend) and subscribes
|
||||
to each node's ``/v1/api/events/global`` SSE stream for real-time state
|
||||
updates. A single asyncio event loop on one dedicated thread multiplexes
|
||||
all SSE connections, scaling to 1000+ nodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
import httpx_sse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.console.metrics import ConsoleMetrics
|
||||
@@ -34,7 +38,7 @@ class NodeSnapshot:
|
||||
node_id: str = ""
|
||||
server_url: str = ""
|
||||
started: float = 0.0
|
||||
last_seen: float = 0.0 # monotonic time of last successful poll
|
||||
last_seen: float = 0.0 # monotonic time of last successful data
|
||||
max_ws: int = 10 # max workstreams (capacity)
|
||||
workstreams: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
health: dict[str, Any] = field(default_factory=dict)
|
||||
@@ -43,21 +47,18 @@ class NodeSnapshot:
|
||||
|
||||
|
||||
class ClusterCollector:
|
||||
"""Aggregates cluster state from the service registry and per-node HTTP APIs.
|
||||
"""Aggregates cluster state from the service registry and per-node SSE streams.
|
||||
|
||||
Two daemon threads:
|
||||
1. Node discovery — queries the service registry every ``discovery_interval`` seconds
|
||||
2. Poll loop — fetches /v1/api/dashboard from each node every ``poll_interval`` seconds
|
||||
2. SSE manager — single asyncio event loop multiplexing SSE connections to all nodes
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage: StorageBackend,
|
||||
poll_interval: float = 15.0,
|
||||
discovery_interval: float = 15.0,
|
||||
max_poll_workers: int = 200,
|
||||
discovery_interval: float = 60.0,
|
||||
http_timeout: float = 30.0,
|
||||
auth_token: str = "",
|
||||
token_manager: ServiceTokenManager | None = None,
|
||||
tls_verify: Any = True,
|
||||
tls_cert: tuple[str, str] | None = None,
|
||||
@@ -65,57 +66,53 @@ class ClusterCollector:
|
||||
console_metrics: ConsoleMetrics | None = None,
|
||||
):
|
||||
self._storage = storage
|
||||
self._poll_interval = poll_interval
|
||||
self._discovery_interval = discovery_interval
|
||||
self._max_poll_workers = max_poll_workers
|
||||
self._http_timeout = http_timeout
|
||||
self._token_manager = token_manager
|
||||
self._router = router
|
||||
self._console_metrics = console_metrics
|
||||
# Static auth header — only used when no token_manager is present.
|
||||
# When a token_manager exists, auth is injected per-request via
|
||||
# extra_headers in _poll_all_nodes to avoid stale JWT expiry.
|
||||
self._static_auth: dict[str, str] | None = None
|
||||
if auth_token and token_manager is None:
|
||||
self._static_auth = {"Authorization": f"Bearer {auth_token}"}
|
||||
self._tls_verify = tls_verify
|
||||
self._tls_cert = tls_cert
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._nodes: dict[str, NodeSnapshot] = {}
|
||||
self._running = False
|
||||
self._threads: list[threading.Thread] = []
|
||||
self._poll_pool = ThreadPoolExecutor(max_workers=max_poll_workers)
|
||||
self._http_client = httpx.Client(
|
||||
timeout=httpx.Timeout(connect=10, read=http_timeout, write=5, pool=http_timeout),
|
||||
limits=httpx.Limits(
|
||||
max_connections=max_poll_workers + 10,
|
||||
max_keepalive_connections=min(max_poll_workers, 200),
|
||||
),
|
||||
verify=tls_verify,
|
||||
cert=tls_cert,
|
||||
)
|
||||
|
||||
# SSE fan-out to browser clients
|
||||
self._listeners: list[queue.Queue[dict[str, Any]]] = []
|
||||
self._listeners_lock = threading.Lock()
|
||||
|
||||
# SSE manager state (managed by the asyncio event loop thread)
|
||||
self._sse_loop: asyncio.AbstractEventLoop | None = None
|
||||
self._sse_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._sse_stop_events: dict[str, asyncio.Event] = {}
|
||||
self._sse_async_client: httpx.AsyncClient | None = None
|
||||
|
||||
def upgrade_tls(self, tls_verify: Any = True, tls_cert: tuple[str, str] | None = None) -> None:
|
||||
"""Replace the httpx client with one using mTLS context."""
|
||||
old = self._http_client
|
||||
self._http_client = httpx.Client(
|
||||
timeout=httpx.Timeout(
|
||||
connect=10, read=self._http_timeout, write=5, pool=self._http_timeout
|
||||
),
|
||||
limits=httpx.Limits(
|
||||
max_connections=self._max_poll_workers + 10,
|
||||
max_keepalive_connections=min(self._max_poll_workers, 200),
|
||||
),
|
||||
verify=tls_verify,
|
||||
cert=tls_cert,
|
||||
"""Update TLS settings for future SSE connections."""
|
||||
self._tls_verify = tls_verify
|
||||
self._tls_cert = tls_cert
|
||||
# If the async client is running, replace it on the event loop.
|
||||
if self._sse_loop is not None and self._sse_loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(self._replace_async_client(), self._sse_loop)
|
||||
|
||||
async def _replace_async_client(self) -> None:
|
||||
"""Replace the async httpx client (called on the SSE event loop).
|
||||
|
||||
Closing the old client terminates its underlying connections, which
|
||||
causes active ``_node_sse_task`` coroutines to raise and reconnect
|
||||
using the new client with updated TLS settings.
|
||||
"""
|
||||
old = self._sse_async_client
|
||||
self._sse_async_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=10, read=None, write=5, pool=None),
|
||||
limits=httpx.Limits(max_connections=2000, max_keepalive_connections=1500),
|
||||
verify=self._tls_verify,
|
||||
cert=self._tls_cert,
|
||||
)
|
||||
# Don't close old client — concurrent _fetch_node() threads may still
|
||||
# be using it. It will be GC'd once all references are released, and
|
||||
# the current client is closed in stop().
|
||||
del old
|
||||
if old is not None:
|
||||
await old.aclose()
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
@@ -124,7 +121,7 @@ class ClusterCollector:
|
||||
self._running = True
|
||||
for target, name in [
|
||||
(self._discovery_loop, "console-discovery"),
|
||||
(self._poll_loop, "console-poll"),
|
||||
(self._sse_manager_thread, "console-sse"),
|
||||
]:
|
||||
t = threading.Thread(target=target, name=name, daemon=True)
|
||||
t.start()
|
||||
@@ -132,10 +129,22 @@ class ClusterCollector:
|
||||
log.info("ClusterCollector started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop all threads and clean up resources."""
|
||||
"""Stop all threads and clean up resources.
|
||||
|
||||
Sets ``_running = False`` which causes the SSE manager coroutine to
|
||||
exit naturally (its ``while self._running`` loop terminates), running
|
||||
its ``finally`` cleanup (cancel tasks, close AsyncClient).
|
||||
"""
|
||||
self._running = False
|
||||
self._poll_pool.shutdown(wait=False)
|
||||
self._http_client.close()
|
||||
# Request cancellation of all SSE tasks so they don't block the
|
||||
# manager's cleanup. The manager coroutine exits when _running is
|
||||
# False and handles remaining task cancellation in its finally block.
|
||||
if self._sse_loop is not None and self._sse_loop.is_running():
|
||||
for node_id in list(self._sse_tasks):
|
||||
asyncio.run_coroutine_threadsafe(self._stop_node(node_id), self._sse_loop)
|
||||
# Wait for background threads to finish their shutdown.
|
||||
for t in self._threads:
|
||||
t.join(timeout=5)
|
||||
log.info("ClusterCollector stopped")
|
||||
|
||||
def _fanout(self, event: dict[str, Any]) -> None:
|
||||
@@ -145,6 +154,137 @@ class ClusterCollector:
|
||||
with contextlib.suppress(queue.Full):
|
||||
q.put_nowait(event)
|
||||
|
||||
# -- auth helpers --------------------------------------------------------
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
"""Build auth headers for the current SSE connection."""
|
||||
if self._token_manager is not None:
|
||||
return {"Authorization": f"Bearer {self._token_manager.token}"}
|
||||
return {}
|
||||
|
||||
# -- SSE manager ---------------------------------------------------------
|
||||
|
||||
def _sse_manager_thread(self) -> None:
|
||||
"""Run asyncio event loop that manages all node SSE connections."""
|
||||
self._sse_loop = asyncio.new_event_loop()
|
||||
try:
|
||||
self._sse_loop.run_until_complete(self._sse_manager())
|
||||
finally:
|
||||
self._sse_loop.close()
|
||||
self._sse_loop = None
|
||||
|
||||
async def _sse_manager(self) -> None:
|
||||
"""Top-level coroutine — runs until collector stops."""
|
||||
self._sse_async_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=10, read=None, write=5, pool=None),
|
||||
limits=httpx.Limits(max_connections=2000, max_keepalive_connections=1500),
|
||||
verify=self._tls_verify,
|
||||
cert=self._tls_cert,
|
||||
)
|
||||
try:
|
||||
while self._running:
|
||||
await asyncio.sleep(1)
|
||||
finally:
|
||||
# Cancel all remaining tasks
|
||||
for task in self._sse_tasks.values():
|
||||
task.cancel()
|
||||
for task in self._sse_tasks.values():
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
self._sse_tasks.clear()
|
||||
self._sse_stop_events.clear()
|
||||
await self._sse_async_client.aclose()
|
||||
self._sse_async_client = None
|
||||
|
||||
async def _start_node(self, node_id: str) -> None:
|
||||
"""Start an SSE task for a node (called on the SSE event loop)."""
|
||||
if node_id in self._sse_tasks:
|
||||
return # already running
|
||||
stop = asyncio.Event()
|
||||
self._sse_stop_events[node_id] = stop
|
||||
self._sse_tasks[node_id] = asyncio.create_task(
|
||||
self._node_sse_task(node_id, stop),
|
||||
name=f"sse-{node_id}",
|
||||
)
|
||||
|
||||
async def _stop_node(self, node_id: str) -> None:
|
||||
"""Stop an SSE task for a node (called on the SSE event loop)."""
|
||||
stop = self._sse_stop_events.pop(node_id, None)
|
||||
if stop:
|
||||
stop.set()
|
||||
task = self._sse_tasks.pop(node_id, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
async def _node_sse_task(self, node_id: str, stop_event: asyncio.Event) -> None:
|
||||
"""Persistent SSE connection to a single server node."""
|
||||
backoff = 1.0
|
||||
while not stop_event.is_set() and self._running:
|
||||
url = self._get_node_url(node_id)
|
||||
if not url or self._sse_async_client is None:
|
||||
break
|
||||
base = url.rstrip("/")
|
||||
try:
|
||||
async with httpx_sse.aconnect_sse(
|
||||
self._sse_async_client,
|
||||
"GET",
|
||||
f"{base}/v1/api/events/global",
|
||||
params={"expected_node_id": node_id},
|
||||
headers=self._auth_headers(),
|
||||
) as source:
|
||||
if source.response.status_code == 409:
|
||||
log.warning("Node identity mismatch for %s at %s", node_id, url)
|
||||
self._mark_unreachable(node_id)
|
||||
break # stop reconnecting — wrong node at this URL
|
||||
source.response.raise_for_status()
|
||||
async for sse in source.aiter_sse():
|
||||
if stop_event.is_set():
|
||||
break
|
||||
if not sse.data:
|
||||
continue # ping/comment frame
|
||||
try:
|
||||
data = json.loads(sse.data)
|
||||
except json.JSONDecodeError:
|
||||
log.debug("Invalid SSE JSON from node %s", node_id)
|
||||
continue
|
||||
etype = data.get("type", "")
|
||||
if etype == "node_snapshot":
|
||||
# Client-side identity check (defense in depth)
|
||||
if data.get("node_id") != node_id:
|
||||
log.warning(
|
||||
"Snapshot node_id mismatch: expected %s, got %s",
|
||||
node_id,
|
||||
data.get("node_id"),
|
||||
)
|
||||
self._mark_unreachable(node_id)
|
||||
break
|
||||
self._apply_snapshot(node_id, data)
|
||||
backoff = 1.0
|
||||
else:
|
||||
self._apply_delta(node_id, data)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
log.debug("SSE error for node %s", node_id, exc_info=True)
|
||||
self._mark_unreachable(node_id)
|
||||
await asyncio.sleep(min(backoff, 30) + random.random())
|
||||
backoff = min(backoff * 2, 30)
|
||||
|
||||
def _get_node_url(self, node_id: str) -> str:
|
||||
"""Get the server URL for a node (thread-safe)."""
|
||||
with self._lock:
|
||||
node = self._nodes.get(node_id)
|
||||
return node.server_url if node else ""
|
||||
|
||||
def _mark_unreachable(self, node_id: str) -> None:
|
||||
"""Mark a node as unreachable (thread-safe)."""
|
||||
with self._lock:
|
||||
node = self._nodes.get(node_id)
|
||||
if node:
|
||||
node.reachable = False
|
||||
|
||||
# -- node discovery ------------------------------------------------------
|
||||
|
||||
def _discovery_loop(self) -> None:
|
||||
@@ -161,6 +301,8 @@ class ClusterCollector:
|
||||
raw_services = self._storage.list_services("server", max_age_seconds=120)
|
||||
active_ids = set()
|
||||
pending_events: list[dict[str, Any]] = []
|
||||
new_nodes: list[str] = []
|
||||
lost_nodes: list[str] = []
|
||||
|
||||
with self._lock:
|
||||
for svc in raw_services:
|
||||
@@ -183,6 +325,7 @@ class ClusterCollector:
|
||||
max_ws=meta.get("max_ws", 10),
|
||||
)
|
||||
pending_events.append({"type": "node_joined", "node_id": nid})
|
||||
new_nodes.append(nid)
|
||||
log.info("Discovered node: %s", nid)
|
||||
else:
|
||||
self._nodes[nid].server_url = url or self._nodes[nid].server_url
|
||||
@@ -193,10 +336,18 @@ class ClusterCollector:
|
||||
for nid in lost:
|
||||
del self._nodes[nid]
|
||||
pending_events.append({"type": "node_lost", "node_id": nid})
|
||||
lost_nodes.append(nid)
|
||||
log.info("Lost node: %s", nid)
|
||||
for event in pending_events:
|
||||
self._fanout(event)
|
||||
|
||||
# Manage SSE tasks for new/lost nodes
|
||||
if self._sse_loop is not None and self._sse_loop.is_running():
|
||||
for nid in new_nodes:
|
||||
asyncio.run_coroutine_threadsafe(self._start_node(nid), self._sse_loop)
|
||||
for nid in lost_nodes:
|
||||
asyncio.run_coroutine_threadsafe(self._stop_node(nid), self._sse_loop)
|
||||
|
||||
# Notify the routing layer so it can refresh its hash-ring cache
|
||||
# when the rebalancer has published a new version.
|
||||
if self._router is not None:
|
||||
@@ -211,114 +362,67 @@ class ClusterCollector:
|
||||
self._router.version,
|
||||
)
|
||||
|
||||
# -- polling -------------------------------------------------------------
|
||||
# -- SSE event handlers --------------------------------------------------
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
"""Periodically fetch /v1/api/dashboard from each node."""
|
||||
while self._running:
|
||||
try:
|
||||
self._poll_all_nodes()
|
||||
except Exception:
|
||||
log.exception("Poll loop error")
|
||||
time.sleep(self._poll_interval)
|
||||
def _reconcile_node(
|
||||
self, node_id: str, node: NodeSnapshot, new_ws_list: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Diff new workstream data against the current snapshot.
|
||||
|
||||
@staticmethod
|
||||
def _node_jitter(node_id: str, window: float) -> float:
|
||||
"""Deterministic per-node delay within a sliding window.
|
||||
|
||||
Uses a Mersenne prime (2^31 - 1) to hash the node_id into a
|
||||
stable offset so each node is polled at a different point in
|
||||
the cycle. The offset is consistent across restarts for the
|
||||
same node_id, giving an even spread without randomness.
|
||||
Returns a list of pending events. Caller must hold ``_lock``.
|
||||
Updates ``node.workstreams`` in place.
|
||||
"""
|
||||
h = hash(node_id) & 0x7FFFFFFF # positive 31-bit
|
||||
return (h % 2147483647) / 2147483647 * window # M31 = 2^31 - 1
|
||||
pending: list[dict[str, Any]] = []
|
||||
old_ids = {k for k in node.workstreams if k}
|
||||
new_ws: dict[str, dict[str, Any]] = {}
|
||||
for ws in new_ws_list:
|
||||
ws_id = ws.get("id", "")
|
||||
if not ws_id:
|
||||
continue
|
||||
ws["node"] = node_id
|
||||
ws["server_url"] = node.server_url
|
||||
new_ws[ws_id] = ws
|
||||
new_ids = set(new_ws.keys())
|
||||
# Additions
|
||||
for ws_id in sorted(new_ids - old_ids):
|
||||
ws = new_ws[ws_id]
|
||||
pending.append(
|
||||
{
|
||||
"type": "ws_created",
|
||||
"ws_id": ws_id,
|
||||
"name": ws.get("name", ""),
|
||||
"node_id": node_id,
|
||||
}
|
||||
)
|
||||
# Removals
|
||||
for ws_id in sorted(old_ids - new_ids):
|
||||
pending.append({"type": "ws_closed", "ws_id": ws_id})
|
||||
# State and name changes on existing workstreams
|
||||
for ws_id in sorted(new_ids & old_ids):
|
||||
old_ws = node.workstreams.get(ws_id, {})
|
||||
new_w = new_ws[ws_id]
|
||||
old_state = old_ws.get("state", "")
|
||||
new_state = new_w.get("state", "")
|
||||
if old_state != new_state:
|
||||
pending.append(
|
||||
{
|
||||
"type": "cluster_state",
|
||||
"ws_id": ws_id,
|
||||
"state": new_state,
|
||||
"node_id": node_id,
|
||||
"tokens": new_w.get("tokens", 0),
|
||||
"content": new_w.get("content", ""),
|
||||
}
|
||||
)
|
||||
old_name = old_ws.get("name", "")
|
||||
new_name = new_w.get("name", "")
|
||||
if old_name != new_name and new_name:
|
||||
pending.append({"type": "ws_rename", "ws_id": ws_id, "name": new_name})
|
||||
node.workstreams = new_ws
|
||||
return pending
|
||||
|
||||
def _poll_all_nodes(self) -> None:
|
||||
"""Fetch dashboard data from all known nodes in parallel.
|
||||
|
||||
Submissions are throttled by the thread pool size to avoid a
|
||||
thundering herd — at most ``max_poll_workers`` concurrent HTTP
|
||||
requests are in flight at any time. Each worker sleeps a
|
||||
deterministic per-node jitter (derived from its node_id) to
|
||||
spread requests across the first half of the poll interval.
|
||||
"""
|
||||
# Snapshot current auth header for this poll cycle. Per-request
|
||||
# headers avoid mutating shared client state (thread-safe).
|
||||
if self._token_manager is not None:
|
||||
poll_headers: dict[str, str] | None = {
|
||||
"Authorization": f"Bearer {self._token_manager.token}"
|
||||
}
|
||||
else:
|
||||
poll_headers = self._static_auth
|
||||
with self._lock:
|
||||
targets = [
|
||||
(n.node_id, n.server_url)
|
||||
for n in self._nodes.values()
|
||||
if n.server_url and n.server_url.startswith("http")
|
||||
]
|
||||
|
||||
if not targets:
|
||||
return
|
||||
|
||||
jitter_window = self._poll_interval / 2
|
||||
|
||||
def _jittered_fetch(
|
||||
nid: str, url: str, headers: dict[str, str] | None
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
delay = self._node_jitter(nid, jitter_window)
|
||||
if delay > 0.1:
|
||||
time.sleep(delay)
|
||||
return self._fetch_node(nid, url, headers)
|
||||
|
||||
futures = {
|
||||
self._poll_pool.submit(_jittered_fetch, nid, url, poll_headers): nid
|
||||
for nid, url in targets
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
nid = futures[future]
|
||||
try:
|
||||
dashboard, health = future.result()
|
||||
self._apply_poll(nid, dashboard, health)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code in (401, 403):
|
||||
log.warning(
|
||||
"Auth failure polling node %s: HTTP %d", nid, exc.response.status_code
|
||||
)
|
||||
else:
|
||||
log.debug("Failed to poll node %s: HTTP %d", nid, exc.response.status_code)
|
||||
with self._lock:
|
||||
if nid in self._nodes:
|
||||
self._nodes[nid].reachable = False
|
||||
except Exception:
|
||||
log.warning("Failed to poll node %s", nid, exc_info=True)
|
||||
with self._lock:
|
||||
if nid in self._nodes:
|
||||
self._nodes[nid].reachable = False
|
||||
|
||||
def _fetch_node(
|
||||
self,
|
||||
node_id: str,
|
||||
server_url: str,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Fetch /v1/api/dashboard and /health from a single node."""
|
||||
base = server_url.rstrip("/")
|
||||
dash_resp = self._http_client.get(f"{base}/v1/api/dashboard", headers=extra_headers)
|
||||
dash_resp.raise_for_status()
|
||||
dash_data: dict[str, Any] = dash_resp.json()
|
||||
try:
|
||||
health_resp = self._http_client.get(f"{base}/health", headers=extra_headers)
|
||||
health_data: dict[str, Any] = health_resp.json()
|
||||
except Exception:
|
||||
log.debug("Failed to fetch health from %s", node_id, exc_info=True)
|
||||
health_data = {}
|
||||
return dash_data, health_data
|
||||
|
||||
def _apply_poll(self, node_id: str, dashboard: dict[str, Any], health: dict[str, Any]) -> None:
|
||||
"""Apply polled data to the in-memory node snapshot."""
|
||||
ws_list = dashboard.get("workstreams", [])
|
||||
aggregate = dashboard.get("aggregate", {})
|
||||
def _apply_snapshot(self, node_id: str, data: dict[str, Any]) -> None:
|
||||
"""Apply a ``node_snapshot`` SSE event to the in-memory state."""
|
||||
pending_events: list[dict[str, Any]] = []
|
||||
with self._lock:
|
||||
node = self._nodes.get(node_id)
|
||||
@@ -326,63 +430,116 @@ class ClusterCollector:
|
||||
return
|
||||
node.last_seen = time.monotonic()
|
||||
node.reachable = True
|
||||
node.health = health
|
||||
node.aggregate = aggregate
|
||||
# Build new workstream map
|
||||
old_ids = {k for k in node.workstreams if k}
|
||||
new_ws: dict[str, dict[str, Any]] = {}
|
||||
for ws in ws_list:
|
||||
ws_id = ws.get("id", "")
|
||||
if not ws_id:
|
||||
continue
|
||||
ws["node"] = node_id
|
||||
ws["server_url"] = node.server_url
|
||||
new_ws[ws_id] = ws
|
||||
new_ids = set(new_ws.keys())
|
||||
# Detect additions not yet known to SSE clients
|
||||
for ws_id in sorted(new_ids - old_ids):
|
||||
ws = new_ws[ws_id]
|
||||
node.health = data.get("health", {})
|
||||
node.aggregate = data.get("aggregate", {})
|
||||
pending_events = self._reconcile_node(node_id, node, data.get("workstreams", []))
|
||||
for event in pending_events:
|
||||
self._fanout(event)
|
||||
|
||||
def _apply_delta(self, node_id: str, data: dict[str, Any]) -> None:
|
||||
"""Apply a single delta SSE event to the in-memory state."""
|
||||
etype = data.get("type", "")
|
||||
if not etype:
|
||||
return
|
||||
pending_events: list[dict[str, Any]] = []
|
||||
with self._lock:
|
||||
node = self._nodes.get(node_id)
|
||||
if not node:
|
||||
return
|
||||
node.last_seen = time.monotonic()
|
||||
|
||||
if etype == "ws_state":
|
||||
# Server emits ws_state; translate to cluster_state for browser.
|
||||
# Only fan out if the workstream is known — a ws_state arriving
|
||||
# before ws_created (race) is silently absorbed on reconnect.
|
||||
ws_id = data.get("ws_id", "")
|
||||
ws = node.workstreams.get(ws_id)
|
||||
if ws:
|
||||
ws["state"] = data.get("state", ws.get("state", ""))
|
||||
ws["tokens"] = data.get("tokens", ws.get("tokens", 0))
|
||||
ws["context_ratio"] = data.get("context_ratio", ws.get("context_ratio", 0))
|
||||
ws["activity"] = data.get("activity", ws.get("activity", ""))
|
||||
ws["activity_state"] = data.get("activity_state", ws.get("activity_state", ""))
|
||||
pending_events.append(
|
||||
{
|
||||
"type": "cluster_state",
|
||||
"ws_id": ws_id,
|
||||
"state": data.get("state", ""),
|
||||
"node_id": node_id,
|
||||
"tokens": data.get("tokens", 0),
|
||||
"content": data.get("content", ""),
|
||||
}
|
||||
)
|
||||
|
||||
elif etype == "ws_activity":
|
||||
ws_id = data.get("ws_id", "")
|
||||
ws = node.workstreams.get(ws_id)
|
||||
if ws:
|
||||
ws["activity"] = data.get("activity", "")
|
||||
ws["activity_state"] = data.get("activity_state", "")
|
||||
# Activity events are not forwarded to cluster SSE — only state changes
|
||||
|
||||
elif etype == "ws_created":
|
||||
ws_id = data.get("ws_id", "")
|
||||
if ws_id and ws_id not in node.workstreams:
|
||||
node.workstreams[ws_id] = {
|
||||
"id": ws_id,
|
||||
"name": data.get("name", ""),
|
||||
"state": "idle",
|
||||
"node": node_id,
|
||||
"server_url": node.server_url,
|
||||
"model": data.get("model", ""),
|
||||
"model_alias": data.get("model_alias", ""),
|
||||
"tokens": 0,
|
||||
"context_ratio": 0.0,
|
||||
"activity": "",
|
||||
"activity_state": "",
|
||||
"tool_calls": 0,
|
||||
"title": "",
|
||||
}
|
||||
pending_events.append(
|
||||
{
|
||||
"type": "ws_created",
|
||||
"ws_id": ws_id,
|
||||
"name": ws.get("name", ""),
|
||||
"name": data.get("name", ""),
|
||||
"node_id": node_id,
|
||||
}
|
||||
)
|
||||
# Detect removals
|
||||
for ws_id in sorted(old_ids - new_ids):
|
||||
|
||||
elif etype == "ws_closed":
|
||||
ws_id = data.get("ws_id", "")
|
||||
node.workstreams.pop(ws_id, None)
|
||||
pending_events.append({"type": "ws_closed", "ws_id": ws_id})
|
||||
# Detect state changes on existing workstreams
|
||||
for ws_id in sorted(new_ids & old_ids):
|
||||
old_ws = node.workstreams.get(ws_id, {})
|
||||
new_w = new_ws[ws_id]
|
||||
old_state = old_ws.get("state", "")
|
||||
new_state = new_w.get("state", "")
|
||||
if old_state != new_state:
|
||||
pending_events.append(
|
||||
{
|
||||
"type": "ws_state",
|
||||
"ws_id": ws_id,
|
||||
"state": new_state,
|
||||
"node_id": node_id,
|
||||
"tokens": new_w.get("tokens", 0),
|
||||
"content": new_w.get("content", ""),
|
||||
}
|
||||
)
|
||||
# Detect name/title changes
|
||||
old_name = old_ws.get("name", "")
|
||||
new_name = new_w.get("name", "")
|
||||
if old_name != new_name and new_name:
|
||||
pending_events.append(
|
||||
{
|
||||
"type": "ws_rename",
|
||||
"ws_id": ws_id,
|
||||
"name": new_name,
|
||||
}
|
||||
)
|
||||
node.workstreams = new_ws
|
||||
# Fan out diffs to SSE listeners outside the lock
|
||||
|
||||
elif etype == "ws_rename":
|
||||
ws_id = data.get("ws_id", "")
|
||||
name = data.get("name", "")
|
||||
ws = node.workstreams.get(ws_id)
|
||||
if ws and name:
|
||||
ws["name"] = name
|
||||
pending_events.append({"type": "ws_rename", "ws_id": ws_id, "name": name})
|
||||
|
||||
elif etype == "health_changed":
|
||||
# Update the health dict's circuit state in-place
|
||||
circuit = data.get("circuit_state", "")
|
||||
if circuit:
|
||||
if not node.health:
|
||||
node.health = {}
|
||||
backend = node.health.setdefault("backend", {})
|
||||
backend["circuit_state"] = circuit
|
||||
backend["status"] = "up" if circuit == "closed" else "down"
|
||||
node.health["status"] = "ok" if circuit == "closed" else "degraded"
|
||||
# Not forwarded to cluster SSE — next snapshot refreshes UI
|
||||
|
||||
elif etype == "aggregate":
|
||||
node.aggregate = {
|
||||
"total_tokens": data.get("total_tokens", 0),
|
||||
"total_tool_calls": data.get("total_tool_calls", 0),
|
||||
"active_count": data.get("active_count", 0),
|
||||
"total_count": data.get("total_count", 0),
|
||||
}
|
||||
# Not forwarded to cluster SSE — overview queries read from snapshot
|
||||
|
||||
for event in pending_events:
|
||||
self._fanout(event)
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ class Rebalancer:
|
||||
lock_ttl: int = 120,
|
||||
eager_migrate: bool = False,
|
||||
api_token: str = "",
|
||||
token_manager: Any = None,
|
||||
) -> None:
|
||||
self._storage = storage
|
||||
self._router = router
|
||||
@@ -75,6 +76,7 @@ class Rebalancer:
|
||||
self._lock_ttl = lock_ttl
|
||||
self._eager_migrate = eager_migrate
|
||||
self._api_token = api_token
|
||||
self._token_manager = token_manager
|
||||
self._stop_event = threading.Event()
|
||||
self._trigger_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
@@ -530,7 +532,9 @@ class Rebalancer:
|
||||
return 0
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
if self._api_token:
|
||||
if self._token_manager is not None:
|
||||
headers["Authorization"] = f"Bearer {self._token_manager.token}"
|
||||
elif self._api_token:
|
||||
headers["Authorization"] = f"Bearer {self._api_token}"
|
||||
|
||||
migrated = 0
|
||||
|
||||
+234
-76
@@ -162,19 +162,11 @@ def _proxy_auth_headers(request: Request) -> dict[str, str]:
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Fallback: service identity (no user context).
|
||||
# When auth is disabled on the console, auth_result is None, so all proxied
|
||||
# requests use the full-privilege service identity. This is safe only when
|
||||
# the upstream server also has auth disabled.
|
||||
# Fallback: service identity via ServiceTokenManager.
|
||||
mgr = getattr(request.app.state, "proxy_token_mgr", None)
|
||||
if mgr is not None:
|
||||
return dict(mgr.bearer_header)
|
||||
|
||||
# Fall back to static proxy_auth_token (e.g. from --auth-token)
|
||||
static_token = getattr(request.app.state, "proxy_auth_token", "")
|
||||
if static_token:
|
||||
return {"Authorization": f"Bearer {static_token}"}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
@@ -5567,6 +5559,193 @@ async def tls_ca_cert(request: Request) -> Response:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin: Prompt Policies (system message composition)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def admin_list_prompt_policies(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/prompt-policies — list all prompt policies."""
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.policies")
|
||||
if err:
|
||||
return err
|
||||
|
||||
policies = storage.list_prompt_policies()
|
||||
return JSONResponse({"policies": policies})
|
||||
|
||||
|
||||
async def admin_create_prompt_policy(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/admin/prompt-policies — create a prompt policy."""
|
||||
import uuid
|
||||
|
||||
from turnstone.core.audit import record_audit
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.policies")
|
||||
if err:
|
||||
return err
|
||||
|
||||
body = await read_json_or_400(request)
|
||||
if isinstance(body, JSONResponse):
|
||||
return body
|
||||
|
||||
name = str(body.get("name", "")).strip()[:64]
|
||||
content = str(body.get("content", "")).strip()[:32768]
|
||||
if not name:
|
||||
return JSONResponse({"error": "name is required"}, status_code=400)
|
||||
if not content:
|
||||
return JSONResponse({"error": "content is required"}, status_code=400)
|
||||
|
||||
try:
|
||||
priority = int(body.get("priority", 0))
|
||||
except (ValueError, TypeError):
|
||||
return JSONResponse({"error": "priority must be an integer"}, status_code=400)
|
||||
|
||||
policy_id = uuid.uuid4().hex
|
||||
audit_uid, ip = _audit_context(request)
|
||||
|
||||
storage.upsert_prompt_policy(
|
||||
{
|
||||
"policy_id": policy_id,
|
||||
"name": name,
|
||||
"content": content,
|
||||
"tool_gate": str(body.get("tool_gate", "")).strip(),
|
||||
"priority": priority,
|
||||
"enabled": bool(body.get("enabled", True)),
|
||||
"org_id": str(body.get("org_id", "")).strip(),
|
||||
"created_by": audit_uid,
|
||||
}
|
||||
)
|
||||
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
"prompt_policy.create",
|
||||
"prompt_policy",
|
||||
policy_id,
|
||||
{"name": name},
|
||||
ip,
|
||||
)
|
||||
|
||||
return JSONResponse(storage.get_prompt_policy(policy_id) or {})
|
||||
|
||||
|
||||
async def admin_get_prompt_policy(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/prompt-policies/{policy_id}."""
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.policies")
|
||||
if err:
|
||||
return err
|
||||
|
||||
policy_id = request.path_params["policy_id"]
|
||||
policy = storage.get_prompt_policy(policy_id)
|
||||
if policy is None:
|
||||
return JSONResponse({"error": "Prompt policy not found"}, status_code=404)
|
||||
return JSONResponse(policy)
|
||||
|
||||
|
||||
async def admin_update_prompt_policy(request: Request) -> JSONResponse:
|
||||
"""PUT /v1/api/admin/prompt-policies/{policy_id}."""
|
||||
from turnstone.core.audit import record_audit
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.policies")
|
||||
if err:
|
||||
return err
|
||||
|
||||
policy_id = request.path_params["policy_id"]
|
||||
existing = storage.get_prompt_policy(policy_id)
|
||||
if existing is None:
|
||||
return JSONResponse({"error": "Prompt policy not found"}, status_code=404)
|
||||
|
||||
body = await read_json_or_400(request)
|
||||
if isinstance(body, JSONResponse):
|
||||
return body
|
||||
|
||||
update = dict(body)
|
||||
update["policy_id"] = policy_id
|
||||
if "name" in update:
|
||||
update["name"] = str(update["name"]).strip()[:64]
|
||||
if "priority" in update:
|
||||
try:
|
||||
update["priority"] = int(update["priority"])
|
||||
except (ValueError, TypeError):
|
||||
return JSONResponse({"error": "priority must be an integer"}, status_code=400)
|
||||
if "content" in update:
|
||||
update["content"] = str(update["content"]).strip()[:32768]
|
||||
if "tool_gate" in update:
|
||||
update["tool_gate"] = str(update["tool_gate"] or "").strip()
|
||||
if "enabled" in update:
|
||||
update["enabled"] = bool(update["enabled"])
|
||||
storage.upsert_prompt_policy(update)
|
||||
|
||||
audit_uid, ip = _audit_context(request)
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
"prompt_policy.update",
|
||||
"prompt_policy",
|
||||
policy_id,
|
||||
{"name": existing.get("name", "")},
|
||||
ip,
|
||||
)
|
||||
|
||||
return JSONResponse(storage.get_prompt_policy(policy_id) or {})
|
||||
|
||||
|
||||
async def admin_delete_prompt_policy(request: Request) -> JSONResponse:
|
||||
"""DELETE /v1/api/admin/prompt-policies/{policy_id}."""
|
||||
from turnstone.core.audit import record_audit
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.policies")
|
||||
if err:
|
||||
return err
|
||||
|
||||
policy_id = request.path_params["policy_id"]
|
||||
existing = storage.get_prompt_policy(policy_id)
|
||||
if existing is None:
|
||||
return JSONResponse({"error": "Prompt policy not found"}, status_code=404)
|
||||
|
||||
storage.delete_prompt_policy(policy_id)
|
||||
|
||||
audit_uid, ip = _audit_context(request)
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
"prompt_policy.delete",
|
||||
"prompt_policy",
|
||||
policy_id,
|
||||
{"name": existing.get("name", "")},
|
||||
ip,
|
||||
)
|
||||
|
||||
return JSONResponse({"status": "ok", "policy_id": policy_id})
|
||||
|
||||
|
||||
async def admin_ring_status(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/ring/status — hash ring rebalancer status."""
|
||||
from turnstone.core.auth import require_permission
|
||||
@@ -5737,10 +5916,8 @@ def _seed_config_from_env(config_store: Any, storage: Any) -> None:
|
||||
def create_app(
|
||||
*,
|
||||
collector: ClusterCollector,
|
||||
auth_config: Any,
|
||||
jwt_secret: str = "",
|
||||
auth_storage: Any = None,
|
||||
proxy_auth_token: str = "",
|
||||
proxy_token_mgr: Any = None,
|
||||
cors_origins: list[str] | None = None,
|
||||
tls_manager: Any = None,
|
||||
@@ -6005,6 +6182,27 @@ def create_app(
|
||||
"/api/admin/model-capabilities/known",
|
||||
admin_known_models,
|
||||
),
|
||||
# Governance: Prompt Policies
|
||||
Route("/api/admin/prompt-policies", admin_list_prompt_policies),
|
||||
Route(
|
||||
"/api/admin/prompt-policies",
|
||||
admin_create_prompt_policy,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/prompt-policies/{policy_id}",
|
||||
admin_get_prompt_policy,
|
||||
),
|
||||
Route(
|
||||
"/api/admin/prompt-policies/{policy_id}",
|
||||
admin_update_prompt_policy,
|
||||
methods=["PUT"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/prompt-policies/{policy_id}",
|
||||
admin_delete_prompt_policy,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
# Governance: Usage & Audit
|
||||
Route("/api/admin/usage", admin_usage),
|
||||
Route("/api/admin/audit", admin_audit),
|
||||
@@ -6057,10 +6255,8 @@ def create_app(
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
app.state.collector = collector
|
||||
app.state.auth_config = auth_config
|
||||
app.state.jwt_secret = jwt_secret
|
||||
app.state.auth_storage = auth_storage
|
||||
app.state.proxy_auth_token = proxy_auth_token
|
||||
app.state.proxy_token_mgr = proxy_token_mgr
|
||||
app.state.console_url = console_url
|
||||
app.state.tls_manager = tls_manager
|
||||
@@ -6093,7 +6289,7 @@ def create_app(
|
||||
scheduler = TaskScheduler(
|
||||
collector=collector,
|
||||
storage=auth_storage,
|
||||
api_token=proxy_auth_token,
|
||||
api_token="",
|
||||
token_manager=proxy_token_mgr,
|
||||
)
|
||||
app.state.scheduler = scheduler
|
||||
@@ -6140,21 +6336,9 @@ def main() -> None:
|
||||
default=8090,
|
||||
help="Port to listen on (default: 8090)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--poll-interval",
|
||||
type=float,
|
||||
default=10.0,
|
||||
help="Node polling interval in seconds (default: 10)",
|
||||
)
|
||||
from turnstone.core.log import add_log_args
|
||||
|
||||
add_log_args(parser)
|
||||
parser.add_argument(
|
||||
"--auth-token",
|
||||
default=os.environ.get("TURNSTONE_AUTH_TOKEN", ""),
|
||||
help="Bearer token for polling turnstone-server nodes (default: $TURNSTONE_AUTH_TOKEN)",
|
||||
)
|
||||
|
||||
from turnstone.core.config import add_config_arg, apply_config
|
||||
|
||||
add_config_arg(parser)
|
||||
@@ -6165,10 +6349,9 @@ def main() -> None:
|
||||
|
||||
configure_logging_from_args(args, "console")
|
||||
|
||||
from turnstone.core.auth import load_auth_config, load_jwt_secret
|
||||
from turnstone.core.auth import load_jwt_secret
|
||||
|
||||
auth_config = load_auth_config()
|
||||
jwt_secret = load_jwt_secret() if auth_config.enabled else ""
|
||||
jwt_secret = load_jwt_secret()
|
||||
|
||||
# Initialize storage early — the collector needs it for service discovery.
|
||||
auth_storage = None
|
||||
@@ -6197,39 +6380,23 @@ def main() -> None:
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
# If no explicit auth token is provided, use a ServiceTokenManager
|
||||
# so collector JWTs auto-rotate. A shared JWT secret is required for
|
||||
# multi-service deployments — ephemeral secrets differ per process.
|
||||
collector_token = args.auth_token
|
||||
collector_token_mgr = None
|
||||
if not collector_token:
|
||||
_jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "")
|
||||
if not _jwt_secret:
|
||||
log.error(
|
||||
"TURNSTONE_JWT_SECRET is not set and no --auth-token provided. "
|
||||
"The console cannot authenticate to server nodes. Set TURNSTONE_JWT_SECRET "
|
||||
"to a shared secret (at least 32 characters) or pass --auth-token."
|
||||
)
|
||||
raise SystemExit(1)
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
|
||||
|
||||
collector_token_mgr = ServiceTokenManager(
|
||||
user_id="console-collector",
|
||||
scopes=frozenset({"read"}),
|
||||
source="console",
|
||||
secret=_jwt_secret,
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
log.info("console.collector_token_manager_created")
|
||||
collector_token_mgr = ServiceTokenManager(
|
||||
user_id="console-collector",
|
||||
scopes=frozenset({"read"}),
|
||||
source="console",
|
||||
secret=jwt_secret,
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
log.info("console.collector_token_manager_created")
|
||||
|
||||
router = ConsoleRouter(storage=auth_storage)
|
||||
console_metrics = ConsoleMetrics()
|
||||
|
||||
collector = ClusterCollector(
|
||||
storage=auth_storage,
|
||||
poll_interval=args.poll_interval,
|
||||
auth_token=collector_token if collector_token_mgr is None else "",
|
||||
token_manager=collector_token_mgr,
|
||||
router=router,
|
||||
console_metrics=console_metrics,
|
||||
@@ -6238,22 +6405,15 @@ def main() -> None:
|
||||
|
||||
_load_static()
|
||||
|
||||
# If no explicit auth token is provided, use a ServiceTokenManager
|
||||
# so proxy JWTs auto-rotate.
|
||||
proxy_token = args.auth_token
|
||||
proxy_token_mgr = None
|
||||
if not proxy_token and jwt_secret:
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
|
||||
|
||||
proxy_token_mgr = ServiceTokenManager(
|
||||
user_id="console-proxy",
|
||||
scopes=frozenset({"read", "write", "approve"}),
|
||||
source="console",
|
||||
secret=jwt_secret,
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
log.info("console.proxy_token_manager_created")
|
||||
proxy_token_mgr = ServiceTokenManager(
|
||||
user_id="console-proxy",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="console",
|
||||
secret=jwt_secret,
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
log.info("console.proxy_token_manager_created")
|
||||
|
||||
from turnstone.core.web_helpers import parse_cors_origins
|
||||
|
||||
@@ -6340,7 +6500,8 @@ def main() -> None:
|
||||
threshold=_rcs.get("rebalancer.threshold", 0.10),
|
||||
vnodes_per_unit=_rcs.get("ring.vnodes_per_unit", 150),
|
||||
eager_migrate=_rcs.get("rebalancer.eager_migrate", False),
|
||||
api_token=proxy_token if proxy_token_mgr is None else "",
|
||||
api_token="",
|
||||
token_manager=proxy_token_mgr,
|
||||
)
|
||||
log.info("rebalancer.configured")
|
||||
except Exception:
|
||||
@@ -6348,10 +6509,8 @@ def main() -> None:
|
||||
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
auth_config=auth_config,
|
||||
jwt_secret=jwt_secret,
|
||||
auth_storage=auth_storage,
|
||||
proxy_auth_token=proxy_token if proxy_token_mgr is None else "",
|
||||
proxy_token_mgr=proxy_token_mgr,
|
||||
cors_origins=cors_origins,
|
||||
tls_manager=tls_mgr,
|
||||
@@ -6362,8 +6521,7 @@ def main() -> None:
|
||||
)
|
||||
|
||||
log.info("Console starting on %s", console_url)
|
||||
if auth_config.enabled:
|
||||
log.info("Auth: enabled (%d config token(s))", len(auth_config.tokens))
|
||||
log.info("Auth: enabled (JWT)")
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
import uvicorn
|
||||
|
||||
@@ -59,6 +59,7 @@ function showAdmin() {
|
||||
watches: "admin.watches",
|
||||
roles: "admin.roles",
|
||||
policies: "admin.policies",
|
||||
"prompt-policies": "admin.prompt_policies",
|
||||
skills: "admin.skills",
|
||||
usage: "admin.usage",
|
||||
audit: "admin.audit",
|
||||
@@ -198,6 +199,7 @@ function switchAdminTab(tab) {
|
||||
"settings",
|
||||
"tls",
|
||||
"mcp",
|
||||
"prompt-policies",
|
||||
];
|
||||
for (var p = 0; p < panels.length; p++) {
|
||||
var el = document.getElementById("admin-" + panels[p]);
|
||||
@@ -222,6 +224,7 @@ function switchAdminTab(tab) {
|
||||
if (tab === "settings") loadSettings();
|
||||
if (tab === "tls") loadTlsCerts();
|
||||
if (tab === "mcp") loadAdminMcp();
|
||||
if (tab === "prompt-policies") loadPromptPolicies();
|
||||
|
||||
// Update breadcrumb with active tab label
|
||||
var activeNav = document.querySelector('.admin-nav[data-tab="' + tab + '"]');
|
||||
@@ -1848,6 +1851,10 @@ function _installTrap(overlayId, boxId, trapRef) {
|
||||
else if (overlayId === "mcp-install-overlay") hideInstallMcpModal();
|
||||
else if (overlayId === "github-import-overlay") hideGitHubImportModal();
|
||||
else if (overlayId === "model-create-overlay") hideCreateModelModal();
|
||||
else if (overlayId === "create-ppolicy-overlay")
|
||||
hideCreatePromptPolicyModal();
|
||||
else if (overlayId === "edit-ppolicy-overlay")
|
||||
hideEditPromptPolicyModal();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1937,6 +1944,8 @@ document.addEventListener("keydown", function (e) {
|
||||
["mcp-create-overlay", hideCreateMcpModal],
|
||||
["github-import-overlay", hideGitHubImportModal],
|
||||
["model-create-overlay", hideCreateModelModal],
|
||||
["create-ppolicy-overlay", hideCreatePromptPolicyModal],
|
||||
["edit-ppolicy-overlay", hideEditPromptPolicyModal],
|
||||
];
|
||||
for (var gi = 0; gi < govOverlays.length; gi++) {
|
||||
var govEl = document.getElementById(govOverlays[gi][0]);
|
||||
|
||||
@@ -2450,3 +2450,282 @@ function submitGitHubImport() {
|
||||
submitBtn.textContent = "Install";
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt Policies (system message composition)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _promptPolicies = [];
|
||||
var _cppTrapHandler = null;
|
||||
var _cppTriggerEl = null;
|
||||
var _eppTrapHandler = null;
|
||||
var _eppTriggerEl = null;
|
||||
|
||||
function loadPromptPolicies() {
|
||||
authFetch("/v1/api/admin/prompt-policies")
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed");
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
_promptPolicies = data.policies || [];
|
||||
_renderPromptPolicies(_promptPolicies);
|
||||
})
|
||||
.catch(function () {
|
||||
var el = document.getElementById("admin-prompt-policies-table");
|
||||
el.textContent = "";
|
||||
var empty = document.createElement("div");
|
||||
empty.className = "dashboard-empty";
|
||||
empty.textContent = "Failed to load prompts";
|
||||
el.appendChild(empty);
|
||||
});
|
||||
}
|
||||
|
||||
function _renderPromptPolicies(items) {
|
||||
var el = document.getElementById("admin-prompt-policies-table");
|
||||
el.textContent = "";
|
||||
if (!items.length) {
|
||||
var empty = document.createElement("div");
|
||||
empty.className = "dashboard-empty";
|
||||
empty.textContent = "No prompts defined";
|
||||
el.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var p = items[i];
|
||||
var row = document.createElement("div");
|
||||
row.className = "admin-row";
|
||||
row.setAttribute("role", "listitem");
|
||||
|
||||
var colName = document.createElement("span");
|
||||
colName.className = "admin-col admin-col-pname";
|
||||
colName.textContent = p.name;
|
||||
row.appendChild(colName);
|
||||
|
||||
var colGate = document.createElement("span");
|
||||
colGate.className = "admin-col admin-col-ppattern";
|
||||
if (p.tool_gate) {
|
||||
var code = document.createElement("code");
|
||||
code.textContent = p.tool_gate;
|
||||
colGate.appendChild(code);
|
||||
} else {
|
||||
var em = document.createElement("em");
|
||||
em.textContent = "unconditional";
|
||||
colGate.appendChild(em);
|
||||
}
|
||||
row.appendChild(colGate);
|
||||
|
||||
var colPri = document.createElement("span");
|
||||
colPri.className = "admin-col admin-col-ppriority";
|
||||
colPri.textContent = String(p.priority);
|
||||
row.appendChild(colPri);
|
||||
|
||||
var colStatus = document.createElement("span");
|
||||
colStatus.className = "admin-col admin-col-pstatus";
|
||||
var dot = document.createElement("span");
|
||||
dot.className = p.enabled ? "watch-active" : "watch-completed";
|
||||
dot.title = p.enabled ? "Enabled" : "Disabled";
|
||||
dot.textContent = p.enabled ? "\u25CF active" : "\u25CB disabled";
|
||||
colStatus.appendChild(dot);
|
||||
row.appendChild(colStatus);
|
||||
|
||||
var colActions = document.createElement("span");
|
||||
colActions.className = "admin-col admin-col-actions";
|
||||
var editBtn = document.createElement("button");
|
||||
editBtn.className = "admin-btn-action";
|
||||
editBtn.textContent = "edit";
|
||||
editBtn.setAttribute("data-edit-ppolicy", p.policy_id);
|
||||
editBtn.setAttribute("aria-label", "Edit prompt " + p.name);
|
||||
colActions.appendChild(editBtn);
|
||||
var delBtn = document.createElement("button");
|
||||
delBtn.className = "admin-btn-danger";
|
||||
delBtn.textContent = "delete";
|
||||
delBtn.setAttribute("data-delete-ppolicy", p.policy_id);
|
||||
delBtn.setAttribute("data-ppolicy-name", p.name);
|
||||
delBtn.setAttribute("aria-label", "Delete prompt " + p.name);
|
||||
colActions.appendChild(delBtn);
|
||||
row.appendChild(colActions);
|
||||
|
||||
el.appendChild(row);
|
||||
}
|
||||
el.querySelectorAll("[data-edit-ppolicy]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
showEditPromptPolicyModal(this.getAttribute("data-edit-ppolicy"));
|
||||
});
|
||||
});
|
||||
el.querySelectorAll("[data-delete-ppolicy]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var pid = this.getAttribute("data-delete-ppolicy");
|
||||
var pname = this.getAttribute("data-ppolicy-name");
|
||||
showConfirmModal(
|
||||
"Delete Prompt",
|
||||
'Delete prompt "' + pname + '"?',
|
||||
"Delete",
|
||||
function () {
|
||||
authFetch("/v1/api/admin/prompt-policies/" + pid, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error();
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
showToast("Prompt deleted");
|
||||
loadPromptPolicies();
|
||||
})
|
||||
.catch(function () {
|
||||
showToast("Failed to delete prompt");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showCreatePromptPolicyModal() {
|
||||
_cppTriggerEl = document.activeElement;
|
||||
var ov = document.getElementById("create-ppolicy-overlay");
|
||||
ov.style.display = "flex";
|
||||
document.getElementById("cpp-name").value = "";
|
||||
document.getElementById("cpp-gate").value = "";
|
||||
document.getElementById("cpp-content").value = "";
|
||||
document.getElementById("cpp-priority").value = "0";
|
||||
document.getElementById("cpp-error").style.display = "none";
|
||||
document.getElementById("cpp-name").focus();
|
||||
_cppTrapHandler = _installTrap(
|
||||
"create-ppolicy-overlay",
|
||||
"create-ppolicy-box",
|
||||
);
|
||||
}
|
||||
|
||||
function hideCreatePromptPolicyModal() {
|
||||
document.getElementById("create-ppolicy-overlay").style.display = "none";
|
||||
_cppTrapHandler = _removeTrap(_cppTrapHandler);
|
||||
if (_cppTriggerEl && _cppTriggerEl.focus) {
|
||||
_cppTriggerEl.focus();
|
||||
}
|
||||
_cppTriggerEl = null;
|
||||
}
|
||||
|
||||
function submitCreatePromptPolicy() {
|
||||
var errEl = document.getElementById("cpp-error");
|
||||
var name = document.getElementById("cpp-name").value.trim();
|
||||
var content = document.getElementById("cpp-content").value.trim();
|
||||
if (!name || !content) {
|
||||
errEl.textContent = "Name and content are required";
|
||||
errEl.style.display = "";
|
||||
return;
|
||||
}
|
||||
errEl.style.display = "none";
|
||||
var submitBtn = document.getElementById("cpp-submit");
|
||||
submitBtn.disabled = true;
|
||||
authFetch("/v1/api/admin/prompt-policies", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
content: content,
|
||||
tool_gate: document.getElementById("cpp-gate").value.trim(),
|
||||
priority:
|
||||
parseInt(document.getElementById("cpp-priority").value, 10) || 0,
|
||||
enabled: true,
|
||||
}),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
throw new Error(d.error || "Failed");
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
hideCreatePromptPolicyModal();
|
||||
showToast("Prompt created");
|
||||
loadPromptPolicies();
|
||||
})
|
||||
.catch(function (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = "";
|
||||
})
|
||||
.finally(function () {
|
||||
submitBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function showEditPromptPolicyModal(policyId) {
|
||||
_eppTriggerEl = document.activeElement;
|
||||
var p = null;
|
||||
for (var i = 0; i < _promptPolicies.length; i++) {
|
||||
if (_promptPolicies[i].policy_id === policyId) {
|
||||
p = _promptPolicies[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!p) return;
|
||||
document.getElementById("epp-id").value = p.policy_id;
|
||||
document.getElementById("epp-name").value = p.name;
|
||||
document.getElementById("epp-gate").value = p.tool_gate || "";
|
||||
document.getElementById("epp-content").value = p.content || "";
|
||||
document.getElementById("epp-priority").value = p.priority || 0;
|
||||
document.getElementById("epp-enabled").checked = p.enabled;
|
||||
document.getElementById("epp-error").style.display = "none";
|
||||
var ov = document.getElementById("edit-ppolicy-overlay");
|
||||
ov.style.display = "flex";
|
||||
document.getElementById("epp-name").focus();
|
||||
_eppTrapHandler = _installTrap("edit-ppolicy-overlay", "edit-ppolicy-box");
|
||||
}
|
||||
|
||||
function hideEditPromptPolicyModal() {
|
||||
document.getElementById("edit-ppolicy-overlay").style.display = "none";
|
||||
_eppTrapHandler = _removeTrap(_eppTrapHandler);
|
||||
if (_eppTriggerEl && _eppTriggerEl.focus) {
|
||||
_eppTriggerEl.focus();
|
||||
}
|
||||
_eppTriggerEl = null;
|
||||
}
|
||||
|
||||
function submitEditPromptPolicy() {
|
||||
var errEl = document.getElementById("epp-error");
|
||||
var policyId = document.getElementById("epp-id").value;
|
||||
var name = document.getElementById("epp-name").value.trim();
|
||||
var content = document.getElementById("epp-content").value.trim();
|
||||
if (!name || !content) {
|
||||
errEl.textContent = "Name and content are required";
|
||||
errEl.style.display = "";
|
||||
return;
|
||||
}
|
||||
errEl.style.display = "none";
|
||||
var submitBtn = document.getElementById("epp-submit");
|
||||
submitBtn.disabled = true;
|
||||
authFetch("/v1/api/admin/prompt-policies/" + policyId, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
content: content,
|
||||
tool_gate: document.getElementById("epp-gate").value.trim(),
|
||||
priority:
|
||||
parseInt(document.getElementById("epp-priority").value, 10) || 0,
|
||||
enabled: document.getElementById("epp-enabled").checked,
|
||||
}),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
throw new Error(d.error || "Failed");
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
hideEditPromptPolicyModal();
|
||||
showToast("Prompt updated");
|
||||
loadPromptPolicies();
|
||||
})
|
||||
.catch(function (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = "";
|
||||
})
|
||||
.finally(function () {
|
||||
submitBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
<div class="admin-sidebar-group-label" aria-hidden="true">Governance</div>
|
||||
<button id="tab-roles" class="admin-nav" 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-nav" data-tab="policies" role="tab" aria-selected="false" aria-controls="admin-policies" tabindex="-1" onclick="switchAdminTab('policies')">Policies</button>
|
||||
<button id="tab-prompt-policies" class="admin-nav" data-tab="prompt-policies" role="tab" aria-selected="false" aria-controls="admin-prompt-policies" tabindex="-1" onclick="switchAdminTab('prompt-policies')">Prompts</button>
|
||||
</div>
|
||||
<div class="admin-sidebar-group" data-group="extensions" role="group" aria-label="Extensions">
|
||||
<div class="admin-sidebar-group-label" aria-hidden="true">Extensions</div>
|
||||
@@ -259,6 +260,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Policies Tab -->
|
||||
<div id="admin-prompt-policies" class="admin-panel" role="tabpanel" aria-labelledby="tab-prompt-policies" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header" style="margin:0">PROMPTS</span>
|
||||
<button class="admin-action-btn" onclick="showCreatePromptPolicyModal()">+ Create prompt</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">TOOL GATE</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-prompt-policies-table" role="list" aria-label="Prompt policies" aria-live="polite">
|
||||
<div class="dashboard-empty">Loading prompt policies...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills Tab -->
|
||||
<div id="admin-skills" class="admin-panel" role="tabpanel" aria-labelledby="tab-skills" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
@@ -888,6 +907,48 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Prompt Policy Modal -->
|
||||
<div id="create-ppolicy-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-ppolicy-title">
|
||||
<div id="create-ppolicy-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="create-ppolicy-title">Create Prompt</h2>
|
||||
<div id="cpp-error" role="alert" aria-live="assertive"></div>
|
||||
<label for="cpp-name">Name <span class="label-hint">slug-style identifier</span></label>
|
||||
<input id="cpp-name" type="text" placeholder="e.g. web_search, data_handling" autocomplete="off">
|
||||
<label for="cpp-gate">Tool Gate <span class="label-hint">tool name or blank for unconditional</span></label>
|
||||
<input id="cpp-gate" type="text" placeholder="e.g. web_search" autocomplete="off">
|
||||
<label for="cpp-content">Content <span class="label-hint">markdown body for system message</span></label>
|
||||
<textarea id="cpp-content" rows="10" placeholder="## Policy Name Behavioral guidance..." spellcheck="false"></textarea>
|
||||
<label for="cpp-priority">Priority <span class="label-hint">higher = assembled later (closer to conversation)</span></label>
|
||||
<input id="cpp-priority" type="number" value="0" min="0" max="9999">
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideCreatePromptPolicyModal()">Cancel</button>
|
||||
<button id="cpp-submit" class="modal-submit" onclick="submitCreatePromptPolicy()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Prompt Policy Modal -->
|
||||
<div id="edit-ppolicy-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-ppolicy-title">
|
||||
<div id="edit-ppolicy-box" class="admin-modal admin-modal-wide">
|
||||
<h2 id="edit-ppolicy-title">Edit Prompt</h2>
|
||||
<div id="epp-error" role="alert" aria-live="assertive"></div>
|
||||
<input id="epp-id" type="hidden">
|
||||
<label for="epp-name">Name</label>
|
||||
<input id="epp-name" type="text" autocomplete="off">
|
||||
<label for="epp-gate">Tool Gate</label>
|
||||
<input id="epp-gate" type="text" autocomplete="off">
|
||||
<label for="epp-content">Content</label>
|
||||
<textarea id="epp-content" rows="10" spellcheck="false"></textarea>
|
||||
<label for="epp-priority">Priority</label>
|
||||
<input id="epp-priority" type="number" value="0" min="0" max="9999">
|
||||
<label class="admin-checkbox"><input id="epp-enabled" type="checkbox" checked> Enabled</label>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideEditPromptPolicyModal()">Cancel</button>
|
||||
<button id="epp-submit" class="modal-submit" onclick="submitEditPromptPolicy()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Skill 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 admin-modal-skill">
|
||||
|
||||
@@ -912,7 +912,7 @@
|
||||
.admin-row {
|
||||
display: grid;
|
||||
padding: 8px 12px;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
@@ -1395,6 +1395,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
#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-ppolicy-overlay, #edit-ppolicy-overlay,
|
||||
#create-template-overlay, #edit-template-overlay,
|
||||
#memory-detail-overlay,
|
||||
#mcp-create-overlay, #mcp-import-overlay, #mcp-detail-overlay, #mcp-install-overlay,
|
||||
@@ -1493,6 +1494,12 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
grid-template-columns: 1.2fr 1fr 70px 50px 80px 140px;
|
||||
}
|
||||
|
||||
/* Prompt Policies grid: NAME | TOOL GATE | PRI | STATUS | ACTIONS */
|
||||
#admin-prompt-policies .admin-colheaders,
|
||||
#admin-prompt-policies .admin-row {
|
||||
grid-template-columns: 1.2fr 1fr 50px 80px 140px;
|
||||
}
|
||||
|
||||
/* Policy action badges */
|
||||
.policy-badge {
|
||||
display: inline-block;
|
||||
@@ -1807,6 +1814,9 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
grid-template-columns: 1fr 70px 50px 100px;
|
||||
}
|
||||
.admin-col-pstatus, .admin-col-ppriority { display: none; }
|
||||
#admin-prompt-policies .admin-colheaders, #admin-prompt-policies .admin-row {
|
||||
grid-template-columns: 1fr 70px 100px;
|
||||
}
|
||||
#admin-skills .admin-colheaders, #admin-skills .admin-row {
|
||||
grid-template-columns: 1fr 100px;
|
||||
}
|
||||
@@ -1858,7 +1868,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
grid-template-columns: 200px 1fr auto;
|
||||
gap: 8px 16px;
|
||||
padding: 8px 12px;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.settings-row:hover { background: var(--row-alt, rgba(255,255,255,0.015)); }
|
||||
@@ -1903,6 +1913,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
}
|
||||
|
||||
/* Bool toggle */
|
||||
.settings-input .settings-toggle { margin-top: 2px; }
|
||||
.settings-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
+31
-130
@@ -1,15 +1,12 @@
|
||||
"""Bearer token authentication and authorization for turnstone HTTP servers.
|
||||
|
||||
Supports three token types:
|
||||
Supports two token types:
|
||||
|
||||
1. **Config-file tokens** — static tokens in ``config.toml`` or the
|
||||
``TURNSTONE_AUTH_TOKEN`` env var. Validated in-memory via
|
||||
``hmac.compare_digest``. Map to scopes via their role.
|
||||
2. **API tokens** — database-backed, prefixed ``ts_``, stored as SHA-256
|
||||
1. **API tokens** — database-backed, prefixed ``ts_``, stored as SHA-256
|
||||
hashes. Exchanged for JWTs via ``/api/auth/login``.
|
||||
3. **JWTs** — short-lived session tokens issued after API token validation.
|
||||
Validated locally via shared HMAC-SHA256 secret. Contain user_id and
|
||||
scopes in claims.
|
||||
2. **JWTs** — short-lived session tokens issued after login or by
|
||||
:class:`ServiceTokenManager`. Validated locally via shared HMAC-SHA256
|
||||
secret. Contain user_id and scopes in claims.
|
||||
|
||||
Public paths (``/``, ``/static/*``, ``/shared/*``, ``/health``, ``/metrics``,
|
||||
``/openapi.json``, ``/docs``, ``/api/auth/login``, ``/api/auth/logout``) are
|
||||
@@ -19,7 +16,6 @@ always accessible without authentication.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -28,7 +24,7 @@ import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -56,7 +52,7 @@ JWT_AUD_CONSOLE = "turnstone-console"
|
||||
JWT_AUD_CHANNEL = "turnstone-channel"
|
||||
_MIN_SECRET_LENGTH = 32 # 256 bits minimum for HMAC-SHA256
|
||||
|
||||
VALID_SCOPES: frozenset[str] = frozenset({"read", "write", "approve"})
|
||||
VALID_SCOPES: frozenset[str] = frozenset({"read", "write", "approve", "service"})
|
||||
|
||||
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||
USERNAME_MAX_LEN = 64
|
||||
@@ -72,16 +68,12 @@ def is_valid_username(username: str) -> bool:
|
||||
|
||||
|
||||
# Hierarchical: each scope implies all lower scopes.
|
||||
# "service" is a superset that grants full access + bypasses RBAC permission checks.
|
||||
SCOPE_HIERARCHY: dict[str, frozenset[str]] = {
|
||||
"read": frozenset({"read"}),
|
||||
"write": frozenset({"read", "write"}),
|
||||
"approve": frozenset({"read", "write", "approve"}),
|
||||
}
|
||||
|
||||
# Map old role names to scope sets.
|
||||
_ROLE_TO_SCOPES: dict[str, frozenset[str]] = {
|
||||
"read": frozenset({"read"}),
|
||||
"full": frozenset({"read", "write", "approve"}),
|
||||
"service": frozenset({"read", "write", "approve", "service"}),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -106,7 +98,7 @@ def _permissions_to_scopes(permissions: set[str]) -> frozenset[str]:
|
||||
scopes.add("read")
|
||||
return frozenset(scopes)
|
||||
for perm in permissions:
|
||||
if perm in VALID_SCOPES:
|
||||
if perm in VALID_SCOPES and perm != "service":
|
||||
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):
|
||||
@@ -120,15 +112,14 @@ 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.
|
||||
Service tokens (scope ``service``) bypass permission checks.
|
||||
"""
|
||||
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:
|
||||
if auth_result.has_scope("service"):
|
||||
return None
|
||||
if auth_result.has_permission(permission):
|
||||
return None
|
||||
@@ -200,9 +191,9 @@ def _strip_version_prefix(path: str) -> str:
|
||||
class AuthResult:
|
||||
"""Result of successful authentication."""
|
||||
|
||||
user_id: str # empty string for config-file tokens
|
||||
user_id: str
|
||||
scopes: frozenset[str]
|
||||
token_source: str # "config", "jwt", "database"
|
||||
token_source: str # "jwt", "database", "password", or service origin (e.g. "console", "cli")
|
||||
permissions: frozenset[str] = frozenset()
|
||||
|
||||
def has_scope(self, scope: str) -> bool:
|
||||
@@ -214,28 +205,6 @@ class AuthResult:
|
||||
return permission in self.permissions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AuthConfig (unchanged from before — static config-file tokens)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthConfig:
|
||||
"""Auth configuration loaded once at startup (not modified after creation)."""
|
||||
|
||||
enabled: bool = False
|
||||
tokens: dict[str, str] = field(default_factory=dict) # token_value → role
|
||||
|
||||
def check(self, token: str | None) -> str | None:
|
||||
"""Return the role for a valid config token, or *None*."""
|
||||
if not token:
|
||||
return None
|
||||
for known_token, role in self.tokens.items():
|
||||
if hmac.compare_digest(token, known_token):
|
||||
return role
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token generation and hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -303,7 +272,11 @@ def parse_scopes(scopes_str: str) -> frozenset[str]:
|
||||
|
||||
|
||||
def load_jwt_secret() -> str:
|
||||
"""Load JWT signing secret from env or config, or auto-generate."""
|
||||
"""Load JWT signing secret from env or config.
|
||||
|
||||
Raises :class:`SystemExit` if no secret is configured. A JWT secret
|
||||
is required for auth, inter-service communication, and session tokens.
|
||||
"""
|
||||
secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
|
||||
if not secret:
|
||||
from turnstone.core.config import load_config
|
||||
@@ -312,18 +285,19 @@ def load_jwt_secret() -> str:
|
||||
secret = str(auth_cfg.get("jwt_secret", "")).strip()
|
||||
|
||||
if not secret:
|
||||
# Auto-generate an ephemeral secret
|
||||
secret = secrets.token_hex(32)
|
||||
log.warning(
|
||||
"No JWT secret configured — using ephemeral secret (tokens will not survive restart)"
|
||||
log.error(
|
||||
"TURNSTONE_JWT_SECRET is required but not set. "
|
||||
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
|
||||
)
|
||||
return secret
|
||||
raise SystemExit(1)
|
||||
|
||||
if len(secret) < _MIN_SECRET_LENGTH:
|
||||
log.warning(
|
||||
"JWT secret is shorter than %d characters — consider using a stronger secret",
|
||||
log.error(
|
||||
"JWT secret must be at least %d characters. "
|
||||
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"',
|
||||
_MIN_SECRET_LENGTH,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
return secret
|
||||
|
||||
|
||||
@@ -397,62 +371,6 @@ def validate_jwt(token: str, secret: str, audience: str = "") -> AuthResult | No
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_auth_config() -> AuthConfig:
|
||||
"""Build :class:`AuthConfig` from ``config.toml`` ``[auth]`` + env vars.
|
||||
|
||||
Auth is **enabled by default**. Set ``[auth] enabled = false`` or
|
||||
``TURNSTONE_AUTH_ENABLED=0`` to disable.
|
||||
|
||||
Config format::
|
||||
|
||||
[auth]
|
||||
enabled = false # opt out
|
||||
|
||||
[[auth.tokens]]
|
||||
value = "tok_abc123"
|
||||
role = "full"
|
||||
|
||||
Environment variables:
|
||||
|
||||
- ``TURNSTONE_AUTH_ENABLED=0`` — disables auth
|
||||
- ``TURNSTONE_AUTH_ENABLED=1`` — enables auth (default)
|
||||
- ``TURNSTONE_AUTH_TOKEN=<token>`` — registers a single full-access token
|
||||
"""
|
||||
from turnstone.core.config import load_config
|
||||
|
||||
auth_cfg = load_config("auth")
|
||||
enabled = bool(auth_cfg.get("enabled", True))
|
||||
tokens: dict[str, str] = {}
|
||||
|
||||
# Tokens from config file (TOML array-of-tables)
|
||||
for entry in auth_cfg.get("tokens", []):
|
||||
value = entry.get("value", "") if isinstance(entry, dict) else ""
|
||||
role = entry.get("role", "read") if isinstance(entry, dict) else ""
|
||||
if value and role in ("read", "full"):
|
||||
tokens[value] = role
|
||||
|
||||
# Environment variable overrides
|
||||
env_enabled = os.environ.get("TURNSTONE_AUTH_ENABLED", "").strip().lower()
|
||||
if env_enabled in ("1", "true", "yes"):
|
||||
enabled = True
|
||||
elif env_enabled in ("0", "false", "no"):
|
||||
enabled = False
|
||||
|
||||
env_token = os.environ.get("TURNSTONE_AUTH_TOKEN", "").strip()
|
||||
if env_token:
|
||||
tokens[env_token] = "full"
|
||||
|
||||
if enabled and not tokens:
|
||||
log.info("Auth enabled (no config tokens — use /api/auth/setup or turnstone-admin)")
|
||||
|
||||
return AuthConfig(enabled=enabled, tokens=tokens)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -529,7 +447,6 @@ def _extract_proxied_path(normalized: str) -> str | None:
|
||||
|
||||
|
||||
def check_request(
|
||||
auth_config: AuthConfig,
|
||||
method: str,
|
||||
path: str,
|
||||
auth_header: str | None,
|
||||
@@ -539,20 +456,16 @@ def check_request(
|
||||
jwt_audience: str = "",
|
||||
storage: Any = None,
|
||||
) -> tuple[bool, int, str, AuthResult | None]:
|
||||
"""Validate a request against the auth config.
|
||||
"""Validate a request.
|
||||
|
||||
Checks ``Authorization: Bearer <token>`` first, then falls back to the
|
||||
``turnstone_auth`` cookie. Token types are auto-detected:
|
||||
|
||||
- Contains ``.`` → JWT (validated with *jwt_secret*)
|
||||
- Starts with ``ts_`` → API token (looked up in *storage* by hash)
|
||||
- Otherwise → config-file token (hmac check)
|
||||
|
||||
Returns ``(allowed, status_code, message, auth_result)``.
|
||||
"""
|
||||
if not auth_config.enabled:
|
||||
return True, 200, "", None
|
||||
|
||||
if is_public_path(path):
|
||||
return True, 200, "", None
|
||||
|
||||
@@ -566,7 +479,7 @@ def check_request(
|
||||
|
||||
# Authenticate
|
||||
result = _authenticate_token(
|
||||
raw_token, auth_config, jwt_secret=jwt_secret, jwt_audience=jwt_audience, storage=storage
|
||||
raw_token, jwt_secret=jwt_secret, jwt_audience=jwt_audience, storage=storage
|
||||
)
|
||||
if result is None:
|
||||
return False, 401, "Unauthorized: missing or invalid token", None
|
||||
@@ -581,7 +494,6 @@ def check_request(
|
||||
|
||||
def _authenticate_token(
|
||||
token: str,
|
||||
auth_config: AuthConfig,
|
||||
*,
|
||||
jwt_secret: str = "",
|
||||
jwt_audience: str = "",
|
||||
@@ -601,12 +513,6 @@ def _authenticate_token(
|
||||
if token.startswith(TOKEN_PREFIX) and storage is not None:
|
||||
return _authenticate_api_token(token, storage)
|
||||
|
||||
# 3. Config-file token (hmac comparison)
|
||||
role = auth_config.check(token)
|
||||
if role is not None:
|
||||
scopes = _ROLE_TO_SCOPES.get(role, frozenset({"read"}))
|
||||
return AuthResult(user_id="", scopes=scopes, token_source="config")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -852,7 +758,6 @@ class AuthMiddleware:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
auth_config = request.app.state.auth_config
|
||||
jwt_secret = getattr(request.app.state, "jwt_secret", "")
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
method = request.method
|
||||
@@ -860,7 +765,6 @@ class AuthMiddleware:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
cookie_header = request.headers.get("Cookie")
|
||||
allowed, status, msg, auth_result = check_request(
|
||||
auth_config,
|
||||
method,
|
||||
path,
|
||||
auth_header,
|
||||
@@ -904,7 +808,6 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
|
||||
|
||||
auth_config = request.app.state.auth_config
|
||||
jwt_secret = getattr(request.app.state, "jwt_secret", "")
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
login_limiter: LoginRateLimiter | None = getattr(request.app.state, "login_limiter", None)
|
||||
@@ -955,7 +858,6 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
|
||||
elif body.get("token"):
|
||||
result = _authenticate_token(
|
||||
body["token"],
|
||||
auth_config,
|
||||
jwt_secret=jwt_secret,
|
||||
jwt_audience=audience,
|
||||
storage=storage,
|
||||
@@ -1011,7 +913,6 @@ async def handle_auth_status(request: Request) -> Response:
|
||||
"""Shared ``GET /api/auth/status`` handler — login UI state detection."""
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
auth_config = request.app.state.auth_config
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
|
||||
has_users = False
|
||||
@@ -1027,9 +928,9 @@ async def handle_auth_status(request: Request) -> Response:
|
||||
oidc_enabled = bool(oidc_config and oidc_config.enabled)
|
||||
|
||||
resp: dict[str, Any] = {
|
||||
"auth_enabled": auth_config.enabled,
|
||||
"auth_enabled": True,
|
||||
"has_users": has_users,
|
||||
"setup_required": auth_config.enabled and not has_users,
|
||||
"setup_required": not has_users,
|
||||
}
|
||||
if oidc_enabled and oidc_config is not None:
|
||||
resp["oidc_enabled"] = True
|
||||
|
||||
@@ -117,7 +117,6 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
|
||||
"host": "host",
|
||||
"port": "port",
|
||||
"url": "console_url",
|
||||
"poll_interval": "poll_interval",
|
||||
"log_level": "log_level",
|
||||
},
|
||||
"auth": {
|
||||
|
||||
@@ -66,7 +66,6 @@ _EXPLICIT_SCRUB: frozenset[str] = frozenset(
|
||||
"ANTHROPIC_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
"TURNSTONE_JWT_SECRET",
|
||||
"TURNSTONE_AUTH_TOKEN",
|
||||
"TURNSTONE_DISCORD_TOKEN",
|
||||
"TURNSTONE_GITHUB_TOKEN",
|
||||
"TURNSTONE_OIDC_CLIENT_SECRET",
|
||||
|
||||
@@ -43,6 +43,7 @@ class BackendHealthMonitor:
|
||||
provider: str = "openai",
|
||||
initial_model: str = "",
|
||||
on_model_changed: Callable[[str, int | None], None] | None = None,
|
||||
on_state_changed: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._probe_interval = probe_interval
|
||||
@@ -54,6 +55,7 @@ class BackendHealthMonitor:
|
||||
self._provider = provider
|
||||
self._last_detected_model = initial_model
|
||||
self._on_model_changed = on_model_changed
|
||||
self._on_state_changed = on_state_changed
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._state = CircuitState.CLOSED
|
||||
@@ -82,8 +84,17 @@ class BackendHealthMonitor:
|
||||
# Passive tracking (called by request path)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fire_state_callback(self, state_val: str | None) -> None:
|
||||
"""Fire on_state_changed callback outside the lock."""
|
||||
if state_val is not None and self._on_state_changed is not None:
|
||||
try:
|
||||
self._on_state_changed(state_val)
|
||||
except Exception:
|
||||
log.debug("on_state_changed callback error", exc_info=True)
|
||||
|
||||
def record_success(self) -> None:
|
||||
"""Called on successful LLM call. Resets failure count, closes circuit."""
|
||||
state_to_dispatch: str | None = None
|
||||
with self._lock:
|
||||
self._consecutive_failures = 0
|
||||
if self._state != CircuitState.CLOSED:
|
||||
@@ -93,9 +104,12 @@ class BackendHealthMonitor:
|
||||
self._last_state_change = time.monotonic()
|
||||
log.info("Circuit breaker CLOSED (was %s): backend recovered", prev.value)
|
||||
self._update_metrics()
|
||||
state_to_dispatch = self._state.value
|
||||
self._fire_state_callback(state_to_dispatch)
|
||||
|
||||
def record_failure(self) -> None:
|
||||
"""Called on LLM call failure. May open circuit."""
|
||||
state_to_dispatch: str | None = None
|
||||
with self._lock:
|
||||
self._consecutive_failures += 1
|
||||
if self._state == CircuitState.HALF_OPEN:
|
||||
@@ -105,6 +119,7 @@ class BackendHealthMonitor:
|
||||
self._last_state_change = time.monotonic()
|
||||
log.warning("Circuit breaker OPEN: probe failed in HALF_OPEN")
|
||||
self._update_metrics()
|
||||
state_to_dispatch = self._state.value
|
||||
elif (
|
||||
self._state == CircuitState.CLOSED
|
||||
and self._consecutive_failures >= self._failure_threshold
|
||||
@@ -116,6 +131,8 @@ class BackendHealthMonitor:
|
||||
self._consecutive_failures,
|
||||
)
|
||||
self._update_metrics()
|
||||
state_to_dispatch = self._state.value
|
||||
self._fire_state_callback(state_to_dispatch)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query helpers
|
||||
@@ -246,7 +263,12 @@ class BackendHealthMonitor:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _update_metrics(self) -> None:
|
||||
"""Push state to metrics collector. Called with *self._lock* held."""
|
||||
"""Push circuit-breaker state to metrics collector.
|
||||
|
||||
Called with *self._lock* held. State-change callbacks are dispatched
|
||||
by the callers (``record_success`` / ``record_failure``) after the
|
||||
lock is released, not by this method.
|
||||
"""
|
||||
from turnstone.core.metrics import metrics
|
||||
|
||||
metrics.set_backend_status(self._state == CircuitState.CLOSED)
|
||||
|
||||
@@ -69,7 +69,7 @@ def _merge_consecutive(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
_WEB_SEARCH_TOOL_TYPE = "web_search_20250305"
|
||||
|
||||
# Tool search: server-side BM25 tool discovery for deferred tools
|
||||
_TOOL_SEARCH_TOOL_TYPE = "tool_search_tool_bm25_20251119"
|
||||
_TOOL_SEARCH_TOOL_TYPE = "tool_search_tool_bm25"
|
||||
|
||||
# -- model capabilities -------------------------------------------------------
|
||||
|
||||
@@ -205,7 +205,7 @@ class AnthropicProvider:
|
||||
result.append({**tool, "defer_loading": True})
|
||||
else:
|
||||
result.append(tool)
|
||||
result.append({"type": _TOOL_SEARCH_TOOL_TYPE, "name": "tool_search"})
|
||||
result.append({"type": _TOOL_SEARCH_TOOL_TYPE, "name": _TOOL_SEARCH_TOOL_TYPE})
|
||||
return result
|
||||
|
||||
# -- shared param logic --------------------------------------------------
|
||||
|
||||
+185
-61
@@ -19,6 +19,7 @@ import mimetypes
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -26,6 +27,7 @@ import textwrap
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from html import escape as _html_escape
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
@@ -87,6 +89,7 @@ from turnstone.core.tools import (
|
||||
merge_mcp_tools,
|
||||
)
|
||||
from turnstone.core.web import check_ssrf, strip_html
|
||||
from turnstone.prompts import ClientType, SessionContext, compose_system_message
|
||||
from turnstone.ui.colors import DIM, GRAY, GREEN, RED, RESET, YELLOW, bold, cyan, dim
|
||||
|
||||
log = get_logger(__name__)
|
||||
@@ -159,6 +162,13 @@ _IMAGE_SIZE_CAP: int = 4 * 1024 * 1024
|
||||
# Upper bound on total skill content injected into system messages
|
||||
_MAX_SKILL_CONTENT: int = 32768
|
||||
|
||||
# Matches resource paths referenced in skill content (scripts/foo.py, etc.)
|
||||
_RESOURCE_PATH_RE = re.compile(
|
||||
r"(?<![/\w-])(?:scripts|references|assets)/[\w./-]+\."
|
||||
r"(?:json|yaml|yml|toml|cfg|ini|py|sh|js|ts|md|txt)"
|
||||
r"(?=[\s)\]}'\"`,;:\x60]|$)"
|
||||
)
|
||||
|
||||
|
||||
_TEMPLATE_VAR_RE = re.compile(r"\{\{(\w+)\}\}")
|
||||
|
||||
@@ -290,6 +300,8 @@ class ChatSession:
|
||||
memory_config: MemoryConfig | None = None,
|
||||
config_store: ConfigStore | None = None,
|
||||
web_search_backend: str = "",
|
||||
client_type: ClientType = ClientType.CLI,
|
||||
username: str = "",
|
||||
):
|
||||
self.client = client
|
||||
self.model = model
|
||||
@@ -325,6 +337,8 @@ class ChatSession:
|
||||
self.auto_approve = False
|
||||
self._node_id = node_id
|
||||
self._user_id = user_id
|
||||
self._username = username
|
||||
self._client_type = client_type
|
||||
self._config_store = config_store
|
||||
self._memory_config = memory_config or MemoryConfig()
|
||||
self._ws_id = ws_id or uuid.uuid4().hex
|
||||
@@ -411,6 +425,7 @@ class ChatSession:
|
||||
self._skill_name: str | None = skill
|
||||
self._skill_content: str | None = None
|
||||
self._skill_resources: dict[str, str] = {}
|
||||
self._skill_resources_dir: str | None = None
|
||||
self._load_skills()
|
||||
self._init_system_messages()
|
||||
self._save_config()
|
||||
@@ -577,6 +592,8 @@ class ChatSession:
|
||||
else:
|
||||
self._skill_content = None
|
||||
self._skill_resources = {}
|
||||
self._materialize_skill_resources()
|
||||
self._validate_skill_resources()
|
||||
|
||||
def set_skill(self, name: str | None) -> None:
|
||||
"""Set or clear the active skill."""
|
||||
@@ -607,6 +624,81 @@ class ChatSession:
|
||||
log.warning("skill_resources.load_failed", skill_id=skill_id, exc_info=True)
|
||||
return {}
|
||||
|
||||
def _cleanup_skill_resources(self) -> None:
|
||||
"""Remove materialized skill resources from disk."""
|
||||
d = self._skill_resources_dir
|
||||
if d is not None:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
self._skill_resources_dir = None
|
||||
|
||||
def _materialize_skill_resources(self) -> None:
|
||||
"""Write skill resources to a temp directory for subprocess access."""
|
||||
self._cleanup_skill_resources()
|
||||
if not self._skill_resources:
|
||||
return
|
||||
base = tempfile.mkdtemp(prefix=f"skill-{self._ws_id[:8]}-")
|
||||
written = 0
|
||||
for rel_path, content in self._skill_resources.items():
|
||||
normed = os.path.normpath(rel_path)
|
||||
if not normed or normed == "." or normed.startswith(("..", "/")):
|
||||
log.warning("skill_resources.bad_path", path=rel_path)
|
||||
continue
|
||||
if ".." in normed.split(os.sep):
|
||||
log.warning("skill_resources.bad_path", path=rel_path)
|
||||
continue
|
||||
full = os.path.join(base, normed)
|
||||
if not os.path.realpath(full).startswith(os.path.realpath(base)):
|
||||
log.warning("skill_resources.path_escape", path=rel_path)
|
||||
continue
|
||||
try:
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
if normed.startswith("scripts/"):
|
||||
os.chmod(full, 0o755)
|
||||
written += 1
|
||||
except Exception:
|
||||
log.warning("skill_resources.write_failed", path=rel_path, exc_info=True)
|
||||
if written == 0:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
return
|
||||
self._skill_resources_dir = base
|
||||
log.info(
|
||||
"skill_resources.materialized",
|
||||
dir=base,
|
||||
count=written,
|
||||
)
|
||||
|
||||
def _skill_resource_env(self) -> dict[str, str]:
|
||||
"""Return extra env vars for bash when skill resources are materialized."""
|
||||
if not self._skill_resources_dir:
|
||||
return {}
|
||||
env: dict[str, str] = {"SKILL_RESOURCES_DIR": self._skill_resources_dir}
|
||||
scripts_dir = os.path.join(self._skill_resources_dir, "scripts")
|
||||
if os.path.isdir(scripts_dir):
|
||||
current_path = os.environ.get("PATH")
|
||||
if current_path:
|
||||
env["PATH"] = scripts_dir + os.pathsep + current_path
|
||||
else:
|
||||
env["PATH"] = scripts_dir
|
||||
return env
|
||||
|
||||
def _validate_skill_resources(self) -> None:
|
||||
"""Warn if skill content references resource paths not in skill_resources."""
|
||||
if not self._skill_content or not self._skill_name:
|
||||
return
|
||||
referenced = {os.path.normpath(p) for p in _RESOURCE_PATH_RE.findall(self._skill_content)}
|
||||
if not referenced:
|
||||
return
|
||||
available = {os.path.normpath(p) for p in self._skill_resources}
|
||||
missing = sorted(referenced - available)
|
||||
if missing:
|
||||
log.warning("skill_resources.missing", skill=self._skill_name, paths=missing)
|
||||
self.ui.on_info(
|
||||
f"Skill '{self._skill_name}' references {len(missing)} resource(s) "
|
||||
f"not bundled: {', '.join(missing)}"
|
||||
)
|
||||
|
||||
# -- MCP tool refresh ----------------------------------------------------
|
||||
|
||||
def _on_mcp_tools_changed(self) -> None:
|
||||
@@ -741,6 +833,7 @@ class ChatSession:
|
||||
self._mcp_prompt_cb = None
|
||||
if self._watch_runner:
|
||||
self._watch_runner.remove_dispatch_fn(self._ws_id)
|
||||
self._cleanup_skill_resources()
|
||||
|
||||
def _handle_mcp_refresh(self, arg: str) -> None:
|
||||
"""Handle ``/mcp refresh [server]``."""
|
||||
@@ -887,6 +980,13 @@ class ChatSession:
|
||||
self._msg_tokens = [
|
||||
max(1, int(self._msg_char_count(m) / self._chars_per_token)) for m in self.messages
|
||||
]
|
||||
log.info(
|
||||
"Resuming ws=%s: %d messages, provider=%s, model=%s",
|
||||
ws_id,
|
||||
len(messages),
|
||||
type(self._provider).__name__,
|
||||
self.model,
|
||||
)
|
||||
# Restore persisted config
|
||||
config = load_workstream_config(ws_id)
|
||||
if config:
|
||||
@@ -904,11 +1004,24 @@ class ChatSession:
|
||||
self.context_window = cfg.context_window
|
||||
if not self._manual_tool_truncation:
|
||||
self.tool_truncation = int(cfg.context_window * self._chars_per_token * 0.5)
|
||||
log.info(
|
||||
"Resume: resolved alias=%s → provider=%s, model=%s, ctx=%d",
|
||||
saved_alias,
|
||||
type(self._provider).__name__,
|
||||
model_name,
|
||||
cfg.context_window,
|
||||
)
|
||||
elif saved_model and saved_model != self.model:
|
||||
# No alias or alias no longer in registry — at least set the model name
|
||||
self.model = saved_model
|
||||
self._model_alias = None
|
||||
self._cached_capabilities = None
|
||||
log.warning(
|
||||
"Resume: alias %r not in registry, keeping default provider=%s for model=%s",
|
||||
saved_alias,
|
||||
type(self._provider).__name__,
|
||||
saved_model,
|
||||
)
|
||||
if "temperature" in config:
|
||||
self.temperature = float(config["temperature"])
|
||||
if "reasoning_effort" in config:
|
||||
@@ -988,64 +1101,30 @@ class ChatSession:
|
||||
"Never condescend to the form.",
|
||||
]
|
||||
else:
|
||||
dev_parts = [
|
||||
"You are a resident engineer on a small, focused infrastructure team. "
|
||||
"Your workspace is an instrumented workbench — a terminal with tools for "
|
||||
"reading, writing, searching, and executing code. You've been here a while. "
|
||||
"You know the codebase. You know the tools. You know their limits.\n\n"
|
||||
"Your team trusts you with real work: investigating bugs, implementing features, "
|
||||
"reviewing security, writing code that ships. You have access to the project's "
|
||||
"files, git history, and a running database. You don't have access to everything "
|
||||
"— some tools require approval, some paths are restricted, and that's by design. "
|
||||
"You work within those boundaries.\n\n"
|
||||
"You think before you act. You read before you edit. You verify before you commit. "
|
||||
"When something breaks, you diagnose before you retry. When you're uncertain, you "
|
||||
"say so. When a request is ambiguous, you make a reasonable call and note what you "
|
||||
"assumed — you don't stall asking for permission on every judgment call.\n\n"
|
||||
"When you disagree with a direction, you push back with reasoning — then defer to "
|
||||
"the team's call.\n\n"
|
||||
"You are not performing a demo. There is no audience. The code you write will run. "
|
||||
"The files you edit are real. The commits you make go to a shared repository. "
|
||||
"Act accordingly.\n\n"
|
||||
"TOOL PATTERNS:\n\n"
|
||||
"Modify existing file → read_file then edit_file:\n"
|
||||
" read_file(path='config.py') → "
|
||||
"edit_file(path='config.py')\n\n"
|
||||
"Modify multiple files → read_file then edit_file each:\n"
|
||||
" read_file(path='a.py') → edit_file(path='a.py') → "
|
||||
"read_file(path='b.py') → edit_file(path='b.py')\n\n"
|
||||
"Create new file → write_file (generate reasonable "
|
||||
"content even if the request is vague):\n"
|
||||
" write_file(path='hello.py', content='...')\n"
|
||||
" write_file(path='README.md', "
|
||||
"content='# Project\\nDescription.')\n\n"
|
||||
"Create a file then run it → write_file then bash:\n"
|
||||
" write_file(path='fib.py', content='...') → "
|
||||
"bash(command='python fib.py')\n\n"
|
||||
"Find something across files → search:\n"
|
||||
" search(query='test_')\n\n"
|
||||
"Find and modify → search then read_file then edit_file:\n"
|
||||
" search(query='MAX_RETRIES') → "
|
||||
"read_file(path='found.py') → "
|
||||
"edit_file(path='found.py')\n\n"
|
||||
"Plan, design, or architect something → "
|
||||
"explore codebase then plan_agent:\n"
|
||||
" bash(command='ls') → read_file(path='app.py') → "
|
||||
"plan_agent(goal='add caching to the application')\n"
|
||||
" plan_agent(goal='refactor database layer "
|
||||
"from monolith to service')\n"
|
||||
" plan_agent(goal='restructure auth module')\n\n"
|
||||
"Run a command, git, or tests → bash:\n"
|
||||
" bash(command='git log -5')\n"
|
||||
" bash(command='pytest')\n\n"
|
||||
"Retrieve a URL → web_fetch:\n"
|
||||
" web_fetch(url='https://example.com')\n\n"
|
||||
"Search the web for information → web_search:\n"
|
||||
" web_search(query='current population of Tokyo')\n\n"
|
||||
"Look up command flags or documentation → man:\n"
|
||||
" man(page='tar')\n"
|
||||
" man(page='grep')",
|
||||
]
|
||||
# Compose system message from modular components
|
||||
tool_names = frozenset(t["function"]["name"] for t in self._tools if "function" in t)
|
||||
# Load DB prompt policies if storage is available
|
||||
db_policies: list[dict[str, Any]] = []
|
||||
try:
|
||||
storage = get_storage()
|
||||
if storage:
|
||||
db_policies = storage.list_prompt_policies()
|
||||
except Exception:
|
||||
pass
|
||||
now = datetime.now().astimezone()
|
||||
ctx = SessionContext(
|
||||
current_datetime=now.strftime("%Y-%m-%dT%H:%M"),
|
||||
timezone=now.tzname() or "UTC",
|
||||
username=self._username or self._user_id or "unknown",
|
||||
)
|
||||
composed = compose_system_message(
|
||||
client_type=self._client_type,
|
||||
context=ctx,
|
||||
available_tools=tool_names,
|
||||
policies=["web_search"],
|
||||
db_policies=db_policies,
|
||||
)
|
||||
dev_parts = [composed]
|
||||
# Tool search hint (client-side mode only — native mode needs no hint)
|
||||
if self._tool_search:
|
||||
caps = self._get_capabilities()
|
||||
@@ -1119,6 +1198,12 @@ class ChatSession:
|
||||
"Resource content omitted (total exceeds 8KB). "
|
||||
"Resource files are listed above by path and size."
|
||||
)
|
||||
if self._skill_resources_dir:
|
||||
lines.append(
|
||||
"\nResource files are materialized on disk. "
|
||||
"Scripts in scripts/ are on PATH and can be run by name. "
|
||||
"All files are under $SKILL_RESOURCES_DIR."
|
||||
)
|
||||
lines.append("</skill-resources>")
|
||||
dev_parts.append("\n".join(lines))
|
||||
# Skill catalog: disclose search-activated skills so the model
|
||||
@@ -1303,6 +1388,21 @@ class ChatSession:
|
||||
) -> Iterator[StreamChunk]:
|
||||
"""Attempt a streaming API call with retries on transient errors."""
|
||||
prov = provider or self._provider
|
||||
raw_url = str(getattr(client, "base_url", getattr(client, "_base_url", "?")))
|
||||
safe_url = raw_url.split("?")[0] # strip query params (may contain keys)
|
||||
msg_count = len(msgs)
|
||||
role_counts: dict[str, int] = {}
|
||||
for m in msgs:
|
||||
r = m.get("role", "?")
|
||||
role_counts[r] = role_counts.get(r, 0) + 1
|
||||
log.debug(
|
||||
"API call: provider=%s model=%s base_url=%s msgs=%d roles=%s",
|
||||
type(prov).__name__,
|
||||
model,
|
||||
safe_url,
|
||||
msg_count,
|
||||
role_counts,
|
||||
)
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(self._MAX_RETRIES + 1):
|
||||
self._check_cancelled()
|
||||
@@ -1322,6 +1422,29 @@ class ChatSession:
|
||||
)
|
||||
except Exception as e:
|
||||
ename = type(e).__name__
|
||||
cause_name = (
|
||||
type(e.__cause__).__name__
|
||||
if e.__cause__
|
||||
else (type(e.__context__).__name__ if e.__context__ else "None")
|
||||
)
|
||||
log.warning(
|
||||
"API error (attempt %d/%d): %s (cause=%s) "
|
||||
"provider=%s model=%s base_url=%s msgs=%d",
|
||||
attempt + 1,
|
||||
self._MAX_RETRIES + 1,
|
||||
ename,
|
||||
cause_name,
|
||||
type(prov).__name__,
|
||||
model,
|
||||
safe_url,
|
||||
msg_count,
|
||||
)
|
||||
log.debug(
|
||||
"API error details (attempt %d/%d)",
|
||||
attempt + 1,
|
||||
self._MAX_RETRIES + 1,
|
||||
exc_info=True,
|
||||
)
|
||||
if ename not in prov.retryable_error_names or attempt == self._MAX_RETRIES:
|
||||
raise
|
||||
last_err = e
|
||||
@@ -2212,7 +2335,8 @@ class ChatSession:
|
||||
"""Emit status info via the UI."""
|
||||
if not self._last_usage:
|
||||
return
|
||||
self.ui.on_status(self._last_usage, self.context_window, self.reasoning_effort)
|
||||
usage: dict[str, Any] = {**self._last_usage, "model": self.model}
|
||||
self.ui.on_status(usage, self.context_window, self.reasoning_effort)
|
||||
|
||||
# -- Conversation compaction ------------------------------------------------
|
||||
|
||||
@@ -4366,7 +4490,7 @@ class ChatSession:
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
env=scrubbed_env(),
|
||||
env=scrubbed_env(extra=self._skill_resource_env()),
|
||||
)
|
||||
with self._procs_lock:
|
||||
self._active_procs.add(proc)
|
||||
@@ -5725,7 +5849,7 @@ class ChatSession:
|
||||
}
|
||||
|
||||
def _exec_watch(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
call_id = item["call_id"]
|
||||
action = item["action"]
|
||||
|
||||
@@ -45,6 +45,9 @@ from turnstone.core.storage._schema import (
|
||||
workstream_overrides,
|
||||
workstreams,
|
||||
)
|
||||
from turnstone.core.storage._schema import (
|
||||
prompt_policies as prompt_policies_t,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
MCP_SERVER_MUTABLE as _MCP_SERVER_MUTABLE,
|
||||
)
|
||||
@@ -57,6 +60,9 @@ from turnstone.core.storage._utils import (
|
||||
from turnstone.core.storage._utils import (
|
||||
POLICY_MUTABLE as _POLICY_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
PROMPT_POLICY_MUTABLE as _PROMPT_POLICY_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
ROLE_MUTABLE as _ROLE_MUTABLE,
|
||||
)
|
||||
@@ -3087,6 +3093,73 @@ class PostgreSQLBackend:
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Prompt policies -------------------------------------------------------
|
||||
|
||||
def list_prompt_policies(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(prompt_policies_t).order_by(prompt_policies_t.c.priority)
|
||||
if org_id:
|
||||
q = q.where(prompt_policies_t.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "enabled") for r in rows]
|
||||
|
||||
def get_prompt_policy(self, policy_id: str) -> dict[str, Any] | None:
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(prompt_policies_t).where(prompt_policies_t.c.policy_id == policy_id)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_dict(row, "enabled")
|
||||
|
||||
def upsert_prompt_policy(self, policy: dict[str, Any]) -> None:
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
existing = conn.execute(
|
||||
sa.select(prompt_policies_t).where(
|
||||
prompt_policies_t.c.policy_id == policy["policy_id"]
|
||||
)
|
||||
).fetchone()
|
||||
if existing:
|
||||
fields = {k: v for k, v in policy.items() if k in _PROMPT_POLICY_MUTABLE}
|
||||
fields["updated"] = now
|
||||
if "enabled" in fields:
|
||||
fields["enabled"] = 1 if fields["enabled"] else 0
|
||||
conn.execute(
|
||||
sa.update(prompt_policies_t)
|
||||
.where(prompt_policies_t.c.policy_id == policy["policy_id"])
|
||||
.values(**fields)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
sa.insert(prompt_policies_t),
|
||||
{
|
||||
"policy_id": policy["policy_id"],
|
||||
"name": policy["name"],
|
||||
"content": policy["content"],
|
||||
"tool_gate": policy.get("tool_gate", ""),
|
||||
"priority": policy.get("priority", 0),
|
||||
"enabled": 1 if policy.get("enabled", True) else 0,
|
||||
"org_id": policy.get("org_id", ""),
|
||||
"created_by": policy.get("created_by", ""),
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def delete_prompt_policy(self, policy_id: str) -> bool:
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(prompt_policies_t).where(prompt_policies_t.c.policy_id == policy_id)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- TLS / ACME ------------------------------------------------------------
|
||||
|
||||
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
|
||||
|
||||
@@ -1048,6 +1048,24 @@ class StorageBackend(Protocol):
|
||||
"""Delete a model definition. Returns True if existed."""
|
||||
...
|
||||
|
||||
# -- Prompt policies -------------------------------------------------------
|
||||
|
||||
def list_prompt_policies(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
"""Return all prompt policies ordered by priority."""
|
||||
...
|
||||
|
||||
def get_prompt_policy(self, policy_id: str) -> dict[str, Any] | None:
|
||||
"""Return prompt policy dict or None."""
|
||||
...
|
||||
|
||||
def upsert_prompt_policy(self, policy: dict[str, Any]) -> None:
|
||||
"""Create or update a prompt policy."""
|
||||
...
|
||||
|
||||
def delete_prompt_policy(self, policy_id: str) -> bool:
|
||||
"""Delete a prompt policy. Returns True if existed."""
|
||||
...
|
||||
|
||||
# -- TLS / ACME (lacme Store) ----------------------------------------------
|
||||
|
||||
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
|
||||
|
||||
@@ -575,6 +575,25 @@ model_definitions = sa.Table(
|
||||
|
||||
sa.Index("idx_model_definitions_enabled", model_definitions.c.enabled)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt policies — system message behavioral rules (admin-managed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
prompt_policies = sa.Table(
|
||||
"prompt_policies",
|
||||
metadata,
|
||||
sa.Column("policy_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("tool_gate", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("priority", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
|
||||
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),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC identity tables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -45,6 +45,9 @@ from turnstone.core.storage._schema import (
|
||||
workstream_overrides,
|
||||
workstreams,
|
||||
)
|
||||
from turnstone.core.storage._schema import (
|
||||
prompt_policies as prompt_policies_t,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
MCP_SERVER_MUTABLE as _MCP_SERVER_MUTABLE,
|
||||
)
|
||||
@@ -57,6 +60,9 @@ from turnstone.core.storage._utils import (
|
||||
from turnstone.core.storage._utils import (
|
||||
POLICY_MUTABLE as _POLICY_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
PROMPT_POLICY_MUTABLE as _PROMPT_POLICY_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
ROLE_MUTABLE as _ROLE_MUTABLE,
|
||||
)
|
||||
@@ -3152,6 +3158,73 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Prompt policies -------------------------------------------------------
|
||||
|
||||
def list_prompt_policies(self, org_id: str = "") -> list[dict[str, Any]]:
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(prompt_policies_t).order_by(prompt_policies_t.c.priority)
|
||||
if org_id:
|
||||
q = q.where(prompt_policies_t.c.org_id == org_id)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "enabled") for r in rows]
|
||||
|
||||
def get_prompt_policy(self, policy_id: str) -> dict[str, Any] | None:
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(prompt_policies_t).where(prompt_policies_t.c.policy_id == policy_id)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_dict(row, "enabled")
|
||||
|
||||
def upsert_prompt_policy(self, policy: dict[str, Any]) -> None:
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
existing = conn.execute(
|
||||
sa.select(prompt_policies_t).where(
|
||||
prompt_policies_t.c.policy_id == policy["policy_id"]
|
||||
)
|
||||
).fetchone()
|
||||
if existing:
|
||||
fields = {k: v for k, v in policy.items() if k in _PROMPT_POLICY_MUTABLE}
|
||||
fields["updated"] = now
|
||||
if "enabled" in fields:
|
||||
fields["enabled"] = 1 if fields["enabled"] else 0
|
||||
conn.execute(
|
||||
sa.update(prompt_policies_t)
|
||||
.where(prompt_policies_t.c.policy_id == policy["policy_id"])
|
||||
.values(**fields)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
sa.insert(prompt_policies_t),
|
||||
{
|
||||
"policy_id": policy["policy_id"],
|
||||
"name": policy["name"],
|
||||
"content": policy["content"],
|
||||
"tool_gate": policy.get("tool_gate", ""),
|
||||
"priority": policy.get("priority", 0),
|
||||
"enabled": 1 if policy.get("enabled", True) else 0,
|
||||
"org_id": policy.get("org_id", ""),
|
||||
"created_by": policy.get("created_by", ""),
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def delete_prompt_policy(self, policy_id: str) -> bool:
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(prompt_policies_t).where(prompt_policies_t.c.policy_id == policy_id)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- TLS / ACME ------------------------------------------------------------
|
||||
|
||||
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
|
||||
|
||||
@@ -108,6 +108,7 @@ MODEL_DEFINITION_MUTABLE = frozenset(
|
||||
"enabled",
|
||||
}
|
||||
)
|
||||
PROMPT_POLICY_MUTABLE = frozenset({"name", "content", "tool_gate", "priority", "enabled"})
|
||||
VERDICT_MUTABLE = frozenset(
|
||||
{
|
||||
"user_decision",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Create prompt_policies table for system message composition.
|
||||
|
||||
Revision ID: 031
|
||||
Revises: 030
|
||||
Create Date: 2026-03-31
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "031"
|
||||
down_revision = "030"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"prompt_policies",
|
||||
sa.Column("policy_id", sa.Text, primary_key=True),
|
||||
sa.Column("name", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("tool_gate", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("priority", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("prompt_policies")
|
||||
@@ -12,7 +12,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
|
||||
ws_id: str | None = ...,
|
||||
*,
|
||||
skill: str | None = ...,
|
||||
client_type: str = ...,
|
||||
) -> ChatSession: ...
|
||||
|
||||
|
||||
@@ -136,6 +137,7 @@ class WorkstreamManager:
|
||||
skill_id: str = "",
|
||||
skill_version: int = 0,
|
||||
ws_id: str = "",
|
||||
client_type: str = "",
|
||||
) -> Workstream:
|
||||
"""Create a new workstream. Returns the new ws.
|
||||
|
||||
@@ -177,7 +179,10 @@ class WorkstreamManager:
|
||||
ws = Workstream(id=ws_id, name=name) if ws_id else Workstream(name=name)
|
||||
if ui_factory:
|
||||
ws.ui = ui_factory(ws.id)
|
||||
ws.session = self._session_factory(ws.ui, model, ws.id, skill=skill)
|
||||
factory_kwargs: dict[str, Any] = {"skill": skill}
|
||||
if client_type:
|
||||
factory_kwargs["client_type"] = client_type
|
||||
ws.session = self._session_factory(ws.ui, model, ws.id, **factory_kwargs)
|
||||
|
||||
# Authoritative insert under lock with re-check (another thread may
|
||||
# have filled capacity while we were unlocked).
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""System message composition harness.
|
||||
|
||||
Assembles modular system messages from BASE (persona), ENV (client surface),
|
||||
CONTEXT (session variables), TOOLS (usage patterns), and POLICIES (behavioral
|
||||
rules). Replaces the monolithic persona+tools section of
|
||||
``ChatSession._init_system_messages()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import enum
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_PROMPTS_DIR = Path(__file__).resolve().parent # turnstone/prompts/
|
||||
# Files are read once at import time — they're static markdown.
|
||||
# This matches the pattern in tools.py where JSON schemas are loaded once.
|
||||
_FILE_CACHE: dict[Path, str] = {}
|
||||
|
||||
|
||||
def _load(relpath: str) -> str:
|
||||
"""Load and cache a prompt module file."""
|
||||
path = _PROMPTS_DIR / relpath
|
||||
if path not in _FILE_CACHE:
|
||||
_FILE_CACHE[path] = path.read_text()
|
||||
return _FILE_CACHE[path]
|
||||
|
||||
|
||||
class ClientType(enum.StrEnum):
|
||||
WEB = "web"
|
||||
CLI = "cli"
|
||||
CHAT = "chat"
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class SessionContext:
|
||||
current_datetime: str # ISO 8601, required
|
||||
timezone: str # system tz abbreviation, required
|
||||
username: str # users.username, required
|
||||
|
||||
|
||||
# File-based policy-to-tool gating (defaults).
|
||||
# DB policies carry their own tool_gate field.
|
||||
POLICY_TOOL_GATES: dict[str, str] = {
|
||||
"web_search": "web_search",
|
||||
}
|
||||
|
||||
_ENV_MAP: dict[ClientType, str] = {
|
||||
ClientType.WEB: "env/web.md",
|
||||
ClientType.CLI: "env/cli.md",
|
||||
ClientType.CHAT: "env/chat.md",
|
||||
}
|
||||
|
||||
|
||||
def _build_context(ctx: SessionContext) -> str:
|
||||
"""Build the CONTEXT module from session variables."""
|
||||
return (
|
||||
"## Session Context\n"
|
||||
"\n"
|
||||
f"- **Current date/time:** {ctx.current_datetime} ({ctx.timezone})\n"
|
||||
f"- **User:** {ctx.username}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_context(ctx: SessionContext) -> None:
|
||||
"""Validate required fields and format constraints."""
|
||||
if not ctx.current_datetime:
|
||||
raise ValueError("current_datetime is required")
|
||||
if not ctx.timezone:
|
||||
raise ValueError("timezone is required")
|
||||
if not ctx.username:
|
||||
raise ValueError("username is required")
|
||||
# Validate ISO 8601
|
||||
try:
|
||||
datetime.fromisoformat(ctx.current_datetime)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"current_datetime is not valid ISO 8601: {ctx.current_datetime}") from exc
|
||||
|
||||
|
||||
def compose_system_message(
|
||||
client_type: ClientType,
|
||||
context: SessionContext,
|
||||
available_tools: frozenset[str],
|
||||
policies: list[str] | None = None,
|
||||
db_policies: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""Compose a system message from modular components.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
client_type:
|
||||
Target rendering surface (web, cli, chat).
|
||||
context:
|
||||
Per-session variables (datetime, timezone, username).
|
||||
available_tools:
|
||||
Set of available tool names (used for policy gating).
|
||||
policies:
|
||||
Explicit file-based policy names to include (e.g. ``["web_search"]``).
|
||||
db_policies:
|
||||
Database-backed policies from ``storage.list_prompt_policies()``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The fully assembled system message, modules separated by double newlines.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
# 1. BASE — always included
|
||||
parts.append(_load("base.md"))
|
||||
|
||||
# 2. ENV — exactly one, selected by client type
|
||||
if client_type not in _ENV_MAP:
|
||||
raise ValueError(f"Unknown client_type: {client_type!r}")
|
||||
parts.append(_load(_ENV_MAP[client_type]))
|
||||
|
||||
# 3. CONTEXT — built programmatically (no template engine)
|
||||
_validate_context(context)
|
||||
parts.append(_build_context(context))
|
||||
|
||||
# 4. TOOLS — if any tools are available
|
||||
if available_tools:
|
||||
parts.append(_load("tools.md"))
|
||||
|
||||
# 5. POLICIES — resolve from DB first, fall back to files
|
||||
# DB policies indexed by name for O(1) override lookup.
|
||||
db_by_name: dict[str, dict[str, Any]] = {}
|
||||
if db_policies:
|
||||
db_by_name = {p["name"]: p for p in db_policies if p.get("enabled", True)}
|
||||
|
||||
for policy_name in policies or []:
|
||||
db_row = db_by_name.pop(policy_name, None)
|
||||
if db_row:
|
||||
# DB override — use its content and tool_gate
|
||||
gate = db_row.get("tool_gate", "")
|
||||
if gate and gate not in available_tools:
|
||||
log.debug("Skipping DB policy %r: requires tool %r", policy_name, gate)
|
||||
continue
|
||||
parts.append(db_row["content"])
|
||||
else:
|
||||
# File-based fallback
|
||||
path = _PROMPTS_DIR / "policies" / f"{policy_name}.md"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Policy module not found: {path}")
|
||||
gate = POLICY_TOOL_GATES.get(policy_name, "")
|
||||
if gate and gate not in available_tools:
|
||||
log.debug("Skipping file policy %r: requires tool %r", policy_name, gate)
|
||||
continue
|
||||
parts.append(_load(f"policies/{policy_name}.md"))
|
||||
|
||||
# DB-only policies (not in the explicit list) — sorted by priority
|
||||
for db_row in sorted(db_by_name.values(), key=lambda p: p.get("priority", 0)):
|
||||
gate = db_row.get("tool_gate", "")
|
||||
if gate and gate not in available_tools:
|
||||
continue
|
||||
parts.append(db_row["content"])
|
||||
|
||||
return "\n\n".join(parts)
|
||||
@@ -0,0 +1,9 @@
|
||||
You are a resident engineer on a small, focused infrastructure team. You've been here a while. You know the codebase. You know the tools. You know their limits.
|
||||
|
||||
Your team trusts you with real work: investigating bugs, implementing features, reviewing security, writing code that ships. You have access to the project's files, git history, and a running database. You don't have access to everything — some tools require approval, some paths are restricted, and that's by design. You work within those boundaries.
|
||||
|
||||
You think before you act. You read before you edit. You verify before you commit. When something breaks, you diagnose before you retry. When you're uncertain, you say so. When a request is ambiguous, you make a reasonable call and note what you assumed — you don't stall asking for permission on every judgment call.
|
||||
|
||||
When you disagree with a direction, you push back with reasoning — then defer to the team's call.
|
||||
|
||||
You are not performing a demo. There is no audience. The code you write will run. The files you edit are real. The commits you make go to a shared repository. Act accordingly.
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
## Output Environment
|
||||
|
||||
Your responses are delivered in a third-party chat platform (Slack, Discord, or Microsoft Teams). These platforms have constrained and inconsistent markdown support. Optimize for maximum portability and readability across all of them.
|
||||
|
||||
**Available rendering:**
|
||||
|
||||
- **Bold** (`**text**`) — Supported everywhere.
|
||||
- **Italic** (`*text*`) — Supported everywhere (Slack also accepts `_text_`).
|
||||
- **Inline code** (`` `text` ``) — Supported everywhere.
|
||||
- **Code blocks** (triple backticks) — Supported everywhere, but language-specific syntax highlighting is inconsistent. Include language tags anyway for clients that support them.
|
||||
- **Bullet lists** — Supported everywhere. Use `-` syntax.
|
||||
- **Links** — `[text](url)` works in Slack and Discord. Teams may render inconsistently. Use bare URLs when maximum portability matters.
|
||||
|
||||
**Not reliably available:**
|
||||
|
||||
- **Tables** — Slack and Discord do not render markdown tables. They display as broken pipe characters. Do not use them. Use aligned code blocks or bullet lists for structured data instead.
|
||||
- **Headings** (`#`, `##`) — Slack does not support them (renders as literal `#`). Use **bold text** on its own line as a heading substitute.
|
||||
- **Mermaid / KaTeX** — Not available. Do not use them.
|
||||
- **Blockquotes** (`>`) — Supported in Slack and Discord, not reliably in Teams. Use sparingly.
|
||||
- **Nested lists** — Inconsistent. Avoid nesting deeper than one level.
|
||||
|
||||
**Formatting principles:**
|
||||
- Keep responses concise. Chat platforms favor short, scannable messages over long-form prose.
|
||||
- Use emoji sparingly for visual anchoring: ✅ ❌ ⚠️ 🔍 are useful; decorative emoji is noise.
|
||||
- For structured comparisons that would normally be a table, use a code block:
|
||||
```
|
||||
Model A: 95.2% accuracy, 1.2s latency
|
||||
Model B: 91.8% accuracy, 0.4s latency
|
||||
```
|
||||
- Break long responses into logical chunks. A response that requires scrolling in a chat window is too long — consider splitting across messages or summarizing with an offer to elaborate.
|
||||
- When referencing files, commands, or code, always use inline code formatting for scannability.
|
||||
- Math expressions should use plain programming notation: `(a * b) / c`, not LaTeX.
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
## Output Environment
|
||||
|
||||
Your workspace is a terminal. Your responses are rendered as plain text in a monospace font with limited formatting support. Design your output for readability in this context.
|
||||
|
||||
**Available rendering:**
|
||||
|
||||
- **Code blocks** — Rendered in monospace with basic syntax highlighting. Language tags are still useful (```python, etc.) but rendering quality varies by terminal emulator.
|
||||
- **Basic markdown** — Bold (**text**) and inline code (`text`) may render depending on the client. Headings (#) render as plain text with emphasis. Tables render as-is in monospace (pipe-aligned tables work well).
|
||||
- **No diagram rendering** — Mermaid, KaTeX, and other embedded renderers are not available. Do not use them.
|
||||
|
||||
**Formatting principles:**
|
||||
- Use indentation, whitespace, and ASCII structure for clarity.
|
||||
- For flows and architectures, use simple text-based representations:
|
||||
```
|
||||
Input → Processing → Output
|
||||
```
|
||||
or indented tree structures, not Mermaid blocks.
|
||||
- For math, write expressions inline using programming notation: `(a * b) / c`, `sum(x_i for i in 1..n)`, `sqrt(n)`. Do not use LaTeX/KaTeX syntax.
|
||||
- Keep line lengths reasonable (~80-100 chars) for terminal readability.
|
||||
- Tables work well — keep them pipe-aligned and concise.
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
## Output Environment
|
||||
|
||||
Your responses are rendered in a rich web client with full markdown support. Use the available rendering capabilities to communicate clearly — prefer structured visuals over walls of text when they aid understanding.
|
||||
|
||||
**Available rendering:**
|
||||
|
||||
- **Code blocks** — Syntax-highlighted via highlight.js. Always specify the language tag (```python, ```sql, ```yaml, etc.) for proper highlighting.
|
||||
- **Diagrams** — Mermaid.js is supported via ```mermaid code blocks. Use flowcharts, sequence diagrams, state diagrams, ER diagrams, and Gantt charts when explaining flows, architectures, or processes. Prefer a diagram over a verbal description of a system or sequence.
|
||||
- **Math** — KaTeX is supported for both inline (`$...$`) and display (`$$...$$`) notation. Use proper mathematical typesetting when discussing formulas, equations, or formal notation rather than ASCII approximations.
|
||||
- **Standard markdown** — Tables, headings, bold, italic, lists, blockquotes, horizontal rules, footnotes, and definition lists all render correctly. Use tables for structured comparisons. Use headings to organize long responses.
|
||||
- **GFM callouts** — `> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]` render as styled alert boxes. Use them for important caveats or warnings.
|
||||
|
||||
**Formatting principles:**
|
||||
- Lead with the answer, then support with visuals — don't bury conclusions after a diagram.
|
||||
- Mermaid diagrams should be self-contained and labeled clearly; the reader may not have surrounding context if they screenshot it.
|
||||
- Use KaTeX for any expression that would be awkward in plain text (fractions, subscripts, summations, Greek letters, etc.).
|
||||
- Don't use rich formatting gratuitously — a one-line answer doesn't need a flowchart.
|
||||
@@ -0,0 +1,26 @@
|
||||
## Web Search Policy
|
||||
|
||||
You have tools for reading files, searching the codebase, and running commands.
|
||||
Use them first. Web search is for information that doesn't exist in the local
|
||||
workspace.
|
||||
|
||||
**Use local tools, not web search, for:**
|
||||
- Anything in the codebase — file contents, function signatures, config values,
|
||||
test results, git history, dependency versions (`read_file`, `search`, `bash`)
|
||||
- Language syntax, standard library behavior, well-established patterns —
|
||||
your training covers this
|
||||
- Anything the user can answer faster than a search round-trip — ask them
|
||||
|
||||
**Use web search for:**
|
||||
- Package versions, changelogs, or deprecation notices newer than your
|
||||
knowledge cutoff
|
||||
- CVEs, security advisories, or vulnerability details for specific versions
|
||||
- API behavior or SDK changes you're uncertain about — verify rather than guess
|
||||
- Anything the user explicitly asks you to search for
|
||||
- Current status of external services, outages, or recent announcements
|
||||
|
||||
**When searching:**
|
||||
- One query at a time. Evaluate results before searching again.
|
||||
- Keep queries specific: `httpx 0.28 changelog` not `httpx python http client latest version changes`
|
||||
- Link to sources when citing external information. Bare URLs are fine.
|
||||
- Don't narrate the search — just do it and present what you found.
|
||||
@@ -0,0 +1,39 @@
|
||||
TOOL PATTERNS:
|
||||
|
||||
Modify existing file → read_file then edit_file:
|
||||
read_file(path='config.py') → edit_file(path='config.py')
|
||||
|
||||
Modify multiple files → read_file then edit_file each:
|
||||
read_file(path='a.py') → edit_file(path='a.py') → read_file(path='b.py') → edit_file(path='b.py')
|
||||
|
||||
Create new file → write_file (generate reasonable content even if the request is vague):
|
||||
write_file(path='hello.py', content='...')
|
||||
write_file(path='README.md', content='# Project\nDescription.')
|
||||
|
||||
Create a file then run it → write_file then bash:
|
||||
write_file(path='fib.py', content='...') → bash(command='python fib.py')
|
||||
|
||||
Find something across files → search:
|
||||
search(query='test_')
|
||||
|
||||
Find and modify → search then read_file then edit_file:
|
||||
search(query='MAX_RETRIES') → read_file(path='found.py') → edit_file(path='found.py')
|
||||
|
||||
Plan, design, or architect something → explore codebase then plan_agent:
|
||||
bash(command='ls') → read_file(path='app.py') → plan_agent(goal='add caching to the application')
|
||||
plan_agent(goal='refactor database layer from monolith to service')
|
||||
plan_agent(goal='restructure auth module')
|
||||
|
||||
Run a command, git, or tests → bash:
|
||||
bash(command='git log -5')
|
||||
bash(command='pytest')
|
||||
|
||||
Retrieve a URL → web_fetch:
|
||||
web_fetch(url='https://example.com')
|
||||
|
||||
Search the web for information → web_search:
|
||||
web_search(query='current population of Tokyo')
|
||||
|
||||
Look up command flags or documentation → man:
|
||||
man(page='tar')
|
||||
man(page='grep')
|
||||
@@ -4,7 +4,7 @@ Usage::
|
||||
|
||||
from turnstone.sdk import TurnstoneConsole
|
||||
|
||||
with TurnstoneConsole("http://localhost:8081", token="tok_xxx") as client:
|
||||
with TurnstoneConsole("http://localhost:8090", token="ts_your_api_token") as client:
|
||||
overview = client.overview()
|
||||
print(f"Nodes: {overview.nodes}, Workstreams: {overview.workstreams}")
|
||||
"""
|
||||
@@ -73,7 +73,7 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "http://localhost:8081",
|
||||
base_url: str = "http://localhost:8090",
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
httpx_client: httpx.AsyncClient | None = None,
|
||||
@@ -199,6 +199,7 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
resume_ws: str = "",
|
||||
target_node: str = "",
|
||||
user_id: str = "",
|
||||
client_type: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a workstream via the console's routing proxy.
|
||||
|
||||
@@ -224,6 +225,8 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
body["target_node"] = target_node
|
||||
if user_id:
|
||||
body["user_id"] = user_id
|
||||
if client_type:
|
||||
body["client_type"] = client_type
|
||||
return await self._request("POST", "/v1/api/route/workstreams/new", json_body=body)
|
||||
|
||||
async def route_send(self, message: str, ws_id: str) -> dict[str, Any]:
|
||||
@@ -958,19 +961,20 @@ class TurnstoneConsole:
|
||||
|
||||
Usage::
|
||||
|
||||
with TurnstoneConsole("http://localhost:8081", token="tok_xxx") as client:
|
||||
with TurnstoneConsole("http://localhost:8090", token="ts_your_api_token") as client:
|
||||
overview = client.overview()
|
||||
print(f"Nodes: {overview.nodes}")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "http://localhost:8081",
|
||||
base_url: str = "http://localhost:8090",
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
ca_cert: str | None = None,
|
||||
client_cert: str | None = None,
|
||||
client_key: str | None = None,
|
||||
token_factory: Callable[[], str] | None = None,
|
||||
) -> None:
|
||||
self._runner = _SyncRunner()
|
||||
self._async = AsyncTurnstoneConsole(
|
||||
@@ -980,6 +984,7 @@ class TurnstoneConsole:
|
||||
ca_cert=ca_cert,
|
||||
client_cert=client_cert,
|
||||
client_key=client_key,
|
||||
token_factory=token_factory,
|
||||
)
|
||||
|
||||
# -- cluster overview ----------------------------------------------------
|
||||
@@ -1059,6 +1064,7 @@ class TurnstoneConsole:
|
||||
resume_ws: str = "",
|
||||
target_node: str = "",
|
||||
user_id: str = "",
|
||||
client_type: str = "",
|
||||
) -> dict[str, Any]:
|
||||
return self._runner.run(
|
||||
self._async.route_create_workstream(
|
||||
@@ -1071,6 +1077,7 @@ class TurnstoneConsole:
|
||||
resume_ws=resume_ws,
|
||||
target_node=target_node,
|
||||
user_id=user_id,
|
||||
client_type=client_type,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -300,6 +300,36 @@ class ClusterSnapshotEvent(ClusterEvent):
|
||||
timestamp: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeSnapshotEvent(ClusterEvent):
|
||||
"""Full node state delivered on SSE connect to ``/v1/api/events/global``."""
|
||||
|
||||
type: str = "node_snapshot"
|
||||
node_id: str = ""
|
||||
workstreams: list[dict[str, Any]] = field(default_factory=list)
|
||||
health: dict[str, Any] = field(default_factory=dict)
|
||||
aggregate: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthChangedEvent(ClusterEvent):
|
||||
"""Circuit breaker state transition on a server node."""
|
||||
|
||||
type: str = "health_changed"
|
||||
circuit_state: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AggregateEvent(ClusterEvent):
|
||||
"""Periodic aggregate metrics from a server node."""
|
||||
|
||||
type: str = "aggregate"
|
||||
total_tokens: int = 0
|
||||
total_tool_calls: int = 0
|
||||
active_count: int = 0
|
||||
total_count: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type registries (built after all classes are defined)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -354,5 +384,8 @@ _CLUSTER_REGISTRY: dict[str, type[ClusterEvent]] = {
|
||||
ClusterWsClosedEvent,
|
||||
ClusterWsRenameEvent,
|
||||
ClusterSnapshotEvent,
|
||||
NodeSnapshotEvent,
|
||||
HealthChangedEvent,
|
||||
AggregateEvent,
|
||||
]
|
||||
}
|
||||
|
||||
+31
-2
@@ -4,7 +4,7 @@ Usage::
|
||||
|
||||
from turnstone.sdk import TurnstoneServer
|
||||
|
||||
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
with TurnstoneServer("http://localhost:8080", token="ts_your_api_token") as client:
|
||||
ws = client.create_workstream(name="Analysis")
|
||||
result = client.send_and_wait("Hello", ws.ws_id)
|
||||
print(result.content)
|
||||
@@ -37,6 +37,7 @@ from turnstone.sdk._base import _BaseClient
|
||||
from turnstone.sdk._sync import _SyncRunner
|
||||
from turnstone.sdk._types import TurnResult
|
||||
from turnstone.sdk.events import (
|
||||
ClusterEvent,
|
||||
ContentEvent,
|
||||
ErrorEvent,
|
||||
ReasoningEvent,
|
||||
@@ -98,6 +99,7 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
auto_approve_tools: str = "",
|
||||
user_id: str = "",
|
||||
ws_id: str = "",
|
||||
client_type: str = "",
|
||||
) -> CreateWorkstreamResponse:
|
||||
body: dict[str, Any] = {}
|
||||
if name:
|
||||
@@ -118,6 +120,8 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
body["user_id"] = user_id
|
||||
if ws_id:
|
||||
body["ws_id"] = ws_id
|
||||
if client_type:
|
||||
body["client_type"] = client_type
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/v1/api/workstreams/new",
|
||||
@@ -199,6 +203,22 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
async for data in self._stream_sse("/v1/api/events/global"):
|
||||
yield ServerEvent.from_dict(data)
|
||||
|
||||
async def stream_node_events(
|
||||
self, *, expected_node_id: str = ""
|
||||
) -> AsyncIterator[ClusterEvent]:
|
||||
"""Iterate over node-level SSE events (snapshot + deltas).
|
||||
|
||||
Connects to ``/v1/api/events/global`` with the optional
|
||||
``expected_node_id`` param for identity verification. Yields
|
||||
``ClusterEvent`` instances (``NodeSnapshotEvent``, ``HealthChangedEvent``,
|
||||
etc.) suitable for console collector consumption.
|
||||
"""
|
||||
params: dict[str, str] = {}
|
||||
if expected_node_id:
|
||||
params["expected_node_id"] = expected_node_id
|
||||
async for data in self._stream_sse("/v1/api/events/global", params=params):
|
||||
yield ClusterEvent.from_dict(data)
|
||||
|
||||
# -- high-level convenience ----------------------------------------------
|
||||
|
||||
async def send_and_wait(
|
||||
@@ -412,7 +432,7 @@ class TurnstoneServer:
|
||||
|
||||
Usage::
|
||||
|
||||
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
with TurnstoneServer("http://localhost:8080", token="ts_your_api_token") as client:
|
||||
ws = client.create_workstream(name="Analysis")
|
||||
result = client.send_and_wait("Hello", ws.ws_id)
|
||||
print(result.content)
|
||||
@@ -426,6 +446,7 @@ class TurnstoneServer:
|
||||
ca_cert: str | None = None,
|
||||
client_cert: str | None = None,
|
||||
client_key: str | None = None,
|
||||
token_factory: Callable[[], str] | None = None,
|
||||
) -> None:
|
||||
self._runner = _SyncRunner()
|
||||
self._async = AsyncTurnstoneServer(
|
||||
@@ -435,6 +456,7 @@ class TurnstoneServer:
|
||||
ca_cert=ca_cert,
|
||||
client_cert=client_cert,
|
||||
client_key=client_key,
|
||||
token_factory=token_factory,
|
||||
)
|
||||
|
||||
# -- workstream management -----------------------------------------------
|
||||
@@ -457,6 +479,7 @@ class TurnstoneServer:
|
||||
auto_approve_tools: str = "",
|
||||
user_id: str = "",
|
||||
ws_id: str = "",
|
||||
client_type: str = "",
|
||||
) -> CreateWorkstreamResponse:
|
||||
return self._runner.run(
|
||||
self._async.create_workstream(
|
||||
@@ -469,6 +492,7 @@ class TurnstoneServer:
|
||||
auto_approve_tools=auto_approve_tools,
|
||||
user_id=user_id,
|
||||
ws_id=ws_id,
|
||||
client_type=client_type,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -509,6 +533,11 @@ class TurnstoneServer:
|
||||
def stream_global_events(self) -> Iterator[ServerEvent]:
|
||||
return self._runner.run_iter(self._async.stream_global_events())
|
||||
|
||||
def stream_node_events(self, *, expected_node_id: str = "") -> Iterator[ClusterEvent]:
|
||||
return self._runner.run_iter(
|
||||
self._async.stream_node_events(expected_node_id=expected_node_id)
|
||||
)
|
||||
|
||||
# -- high-level convenience ----------------------------------------------
|
||||
|
||||
def send_and_wait(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user