mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ab60853f5 | |||
| fed5b96a6f | |||
| 4a65535e00 | |||
| 519b86f56e | |||
| 024a2e98d2 | |||
| e9c141aba5 | |||
| b038dbdd5b |
+34
-46
@@ -1,61 +1,49 @@
|
||||
# =============================================================================
|
||||
# Turnstone environment overrides — ALL OPTIONAL for the dev stack.
|
||||
# Turnstone Environment Variables
|
||||
# Copy to .env and adjust values for your deployment.
|
||||
#
|
||||
# `docker compose up` from a clone works with zero config: every value below
|
||||
# has a built-in (insecure) default. Copy this file to `.env` only to override.
|
||||
#
|
||||
# The PRODUCTION stack (turnstone/deploy/compose.yaml) has no baked-in secrets
|
||||
# and DOES require TURNSTONE_JWT_SECRET and POSTGRES_PASSWORD.
|
||||
#
|
||||
# Note: for a turnstone process running on bare metal (not in a container),
|
||||
# put secrets in ~/.config/turnstone/config.toml (chmod 0600), not the
|
||||
# environment. See docs/docker.md "Join a bare-metal host".
|
||||
# Usage:
|
||||
# Single node: docker compose --profile production up
|
||||
# 10-node cluster: docker compose --profile cluster up
|
||||
# =============================================================================
|
||||
|
||||
# -- LLM backend --------------------------------------------------------------
|
||||
# Optional: nodes boot without an LLM. Add real model backends from the console
|
||||
# UI (Models tab). These only set the bootstrap default a node starts with.
|
||||
# LLM_BASE_URL=http://host.docker.internal:8000/v1
|
||||
# OPENAI_API_KEY=dummy
|
||||
# ANTHROPIC_API_KEY=sk-ant-... # set instead of OPENAI_API_KEY for Anthropic
|
||||
# TURNSTONE_SEARXNG_URL=http://searxng:8080 # web_search backend (default: bundled service; set to an external SearxNG)
|
||||
# MODEL= # default model alias
|
||||
# -- LLM Backend --------------------------------------------------------------
|
||||
LLM_BASE_URL=http://host.docker.internal:8000/v1
|
||||
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
|
||||
|
||||
# -- Secrets ------------------------------------------------------------------
|
||||
# The dev stack defaults these to INSECURE values. Always set real ones for
|
||||
# anything reachable beyond localhost. Generate the JWT secret with:
|
||||
# python -c "import secrets; print(secrets.token_hex(32))"
|
||||
# TURNSTONE_JWT_SECRET=
|
||||
# POSTGRES_PASSWORD=
|
||||
# -- Authentication (required) ------------------------------------------------
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
TURNSTONE_JWT_SECRET=changeme-to-32-bytes-of-hex
|
||||
|
||||
# -- Database -----------------------------------------------------------------
|
||||
# Defaults to the bundled PostgreSQL (shared by every service — required for
|
||||
# the console to discover nodes). Override to point at an external database:
|
||||
# TURNSTONE_DB_BACKEND=postgresql
|
||||
# -- Database ------------------------------------------------------------------
|
||||
# Single-node default is SQLite (zero config). Set these for PostgreSQL:
|
||||
# DB_BACKEND=postgresql
|
||||
# POSTGRES_USER=turnstone
|
||||
# TURNSTONE_DB_URL=postgresql+psycopg://turnstone:<pw>@postgres:5432/turnstone
|
||||
# POSTGRES_PASSWORD=changeme
|
||||
# DATABASE_URL=postgresql+psycopg://turnstone:changeme@postgres:5432/turnstone
|
||||
|
||||
# -- Ports / networking -------------------------------------------------------
|
||||
# The dashboard is reached via Caddy only (HTTP/2 avoids the browser's
|
||||
# 6-connection cap on the console's SSE streams). Both stacks expose the same
|
||||
# two host ports; everything else is proxied through the console.
|
||||
# CONSOLE_HTTPS_PORT=8443 # Caddy (dashboard HTTPS)
|
||||
# POSTGRES_PORT=5432 # exposed for bare-metal host joins
|
||||
# POSTGRES_BIND=127.0.0.1 # set 0.0.0.0 to let another machine join
|
||||
# -- Ports ---------------------------------------------------------------------
|
||||
# SERVER_PORT=8080
|
||||
# CONSOLE_PORT=8090
|
||||
|
||||
# -- Workspace ----------------------------------------------------------------
|
||||
# Bind-mount a host directory the model can read/write at /workspace:
|
||||
# -- 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 config file
|
||||
# -- Agent behavior ------------------------------------------------------------
|
||||
# SKIP_PERMISSIONS=true # Auto-approve all tool calls (dev only)
|
||||
# MCP_CONFIG=/workspace/mcp.json# MCP server configuration file
|
||||
|
||||
# -- Channel gateway (Discord / Slack) ----------------------------------------
|
||||
# -- Discord channel gateway ---------------------------------------------------
|
||||
# TURNSTONE_DISCORD_TOKEN=
|
||||
# TURNSTONE_DISCORD_GUILD=0
|
||||
# TURNSTONE_SLACK_TOKEN=xoxb-...
|
||||
# TURNSTONE_SLACK_APP_TOKEN=xapp-...
|
||||
|
||||
# -- Production image tag ------------------------------------------------------
|
||||
# TURNSTONE_IMAGE_TAG=latest # pin the ghcr.io image (production stack)
|
||||
# -- 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
|
||||
|
||||
|
||||
+14
-34
@@ -14,7 +14,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
@@ -39,20 +39,13 @@ jobs:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
# Node is required by tests/test_renderer_js.py — without
|
||||
# explicit setup, that suite silently skips if the runner
|
||||
# image happens not to ship Node, masking regressions in
|
||||
# the browser-side renderer.
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}
|
||||
@@ -75,14 +68,11 @@ jobs:
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- run: pip install -e ".[test]"
|
||||
- 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
|
||||
@@ -90,7 +80,7 @@ jobs:
|
||||
wheel-completeness:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
@@ -137,8 +127,8 @@ jobs:
|
||||
lock-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- run: uv lock --check
|
||||
@@ -146,8 +136,8 @@ jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
@@ -156,17 +146,7 @@ jobs:
|
||||
- run: uv sync --frozen --all-extras
|
||||
- run: uv pip install pip-audit
|
||||
- name: Security audit (dependencies)
|
||||
# PYSEC-2025-183 (pyjwt): "weak encryption" — disputed by the
|
||||
# supplier because the key length is chosen by the calling
|
||||
# application, not the library. Turnstone generates its JWT
|
||||
# signing keys via the standard ``secrets`` module at
|
||||
# operator-controlled strength (see ``turnstone/core/auth.py``),
|
||||
# so the advisory does not apply. pyjwt 2.12.1 is the current
|
||||
# latest release; no fix version exists.
|
||||
run: >-
|
||||
uv export --no-emit-project --frozen
|
||||
| uv run pip-audit --strict --desc -r /dev/stdin
|
||||
--ignore-vuln PYSEC-2025-183
|
||||
run: uv export --no-emit-project --frozen | uv run pip-audit --strict --desc -r /dev/stdin
|
||||
|
||||
security-ts:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -174,8 +154,8 @@ jobs:
|
||||
run:
|
||||
working-directory: sdk/typescript
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- run: npm ci
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -67,12 +67,12 @@ jobs:
|
||||
fi
|
||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
|
||||
- name: Build and push
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Docker Security Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, "stable/*"]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly Monday 06:00 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- run: docker build -t turnstone:scan .
|
||||
- uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
image-ref: "turnstone:scan"
|
||||
severity: "HIGH,CRITICAL"
|
||||
exit-code: "1"
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
@@ -44,12 +44,12 @@ jobs:
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
- run: python -m build
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
- 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@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
fi
|
||||
echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ steps.ref.outputs.head_ref }}
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
id: detect
|
||||
run: |
|
||||
updates=()
|
||||
for lib in katex hljs mermaid hls; do
|
||||
for lib in katex hljs mermaid; do
|
||||
version=$(grep -oE "${lib}-[0-9.]+" pyproject.toml | head -1 | sed "s/${lib}-//")
|
||||
[[ -z "$version" ]] && continue
|
||||
[[ -d "turnstone/shared_static/${lib}-${version}" ]] && continue
|
||||
|
||||
@@ -9,11 +9,6 @@ build/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
# Local compose overrides (e.g. run.sh's node-count limiter, bootstrap output)
|
||||
compose.override.yaml
|
||||
compose.override.yml
|
||||
docker-compose.override.yaml
|
||||
docker-compose.override.yml
|
||||
*.so
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
@@ -26,5 +21,3 @@ PROGRESS.md
|
||||
.coverage
|
||||
tools/skill_audit_analysis/data/
|
||||
tools/skill_audit_analysis/output/
|
||||
design_ideas/
|
||||
.claude/
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# libexpat integer overflow — no fix available in Debian repos yet
|
||||
# https://avd.aquasec.com/nvd/cve-2026-25210
|
||||
# Review: remove this entry once a patched libexpat1 is published
|
||||
CVE-2026-25210
|
||||
|
||||
# ncurses buffer overflow — no fix in Debian 13 repos yet
|
||||
# Affects libncursesw6, libtinfo6, ncurses-base, ncurses-bin
|
||||
# https://avd.aquasec.com/nvd/cve-2025-69720
|
||||
CVE-2025-69720
|
||||
|
||||
# nghttp2 DoS via malformed HTTP/2 frames — no fix in Debian 13 repos yet
|
||||
# Affects libnghttp2-14
|
||||
# https://avd.aquasec.com/nvd/cve-2026-27135
|
||||
CVE-2026-27135
|
||||
|
||||
# systemd arbitrary code execution via spurious IPC — no fix in Debian 13 repos yet
|
||||
# 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
|
||||
-2086
File diff suppressed because it is too large
Load Diff
+2
-10
@@ -26,15 +26,7 @@ transferring ownership.
|
||||
```
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[test,dev]"
|
||||
```
|
||||
|
||||
The `dev` extra installs `ruff` and `mypy`. Before pushing, run:
|
||||
|
||||
```
|
||||
ruff check turnstone tests
|
||||
mypy turnstone
|
||||
pytest
|
||||
pip install -e ".[test]"
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
@@ -56,4 +48,4 @@ Open an issue at https://github.com/turnstonelabs/turnstone/issues with:
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the
|
||||
project's [Apache License 2.0](LICENSE).
|
||||
project's [Business Source License 1.1](LICENSE).
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Contributors
|
||||
|
||||
Turnstone is written and maintained by Patrick Buckley
|
||||
([@eous](https://github.com/eous)).
|
||||
|
||||
The following people have contributed code to the project — thank you:
|
||||
|
||||
- Burhan ([@Burhan-Q](https://github.com/Burhan-Q))
|
||||
- chrismuzyn ([@chrismuzyn](https://github.com/chrismuzyn))
|
||||
- daoxley ([@daoxley](https://github.com/daoxley))
|
||||
- Robert DeAngelis ([@OriginalOrangeXD](https://github.com/OriginalOrangeXD))
|
||||
- William ([@sillyWillieBilly](https://github.com/sillyWillieBilly))
|
||||
- [@pizzaandcheese](https://github.com/pizzaandcheese)
|
||||
+4
-7
@@ -8,17 +8,14 @@ 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.19 /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
|
||||
|
||||
# System dependencies: psycopg (libpq5), developer tooling for agent workflows.
|
||||
# ripgrep is the preferred backend for the search tool — natively bounds
|
||||
# per-line, per-file, and per-filesize so pathological inputs (minified
|
||||
# bundles, training-data JSONL with multi-MB single records) can't OOM us.
|
||||
# System dependencies: psycopg (libpq5), developer tooling for agent workflows
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||
libpq5 git curl jq man-db manpages procps file ripgrep \
|
||||
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)
|
||||
@@ -33,7 +30,7 @@ RUN useradd --create-home --shell /bin/bash turnstone
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first (cached layer — only re-runs when deps change)
|
||||
COPY pyproject.toml uv.lock README.md LICENSE NOTICE THIRD-PARTY-NOTICES ./
|
||||
COPY pyproject.toml uv.lock README.md LICENSE ./
|
||||
RUN uv sync --frozen --no-install-project --no-dev \
|
||||
--no-compile --extra all
|
||||
|
||||
|
||||
@@ -1,201 +1,62 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved.
|
||||
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
Parameters
|
||||
|
||||
1. Definitions.
|
||||
Licensor: Patrick Buckley
|
||||
Licensed Work: Turnstone 0.2.0. The Licensed Work is (c) 2025-2026 Patrick Buckley.
|
||||
Additional Use Grant: You may make production use of the Licensed Work, provided
|
||||
your use does not include providing the Licensed Work to third
|
||||
parties as a hosted or managed service, where the service
|
||||
provides users with access to any substantial set of the
|
||||
features or functionality of the Licensed Work.
|
||||
Change Date: 2030-03-01
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
For information about alternative licensing arrangements for the Licensed Work,
|
||||
please contact buckleypm@gmail.com.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
Notice
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
Business Source License 1.1
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
Terms
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
The Licensor hereby grants you the right to copy, modify, create derivative
|
||||
works, redistribute, and make non-production use of the Licensed Work. The
|
||||
Licensor may make an Additional Use Grant, above, permitting limited production use.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
Effective on the Change Date, or the fourth anniversary of the first publicly
|
||||
available distribution of a specific version of the Licensed Work under this
|
||||
License, whichever comes first, the Licensor hereby grants you rights under
|
||||
the terms of the Change License, and the rights granted in the paragraph
|
||||
above terminate.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
If your use of the Licensed Work does not comply with the requirements
|
||||
currently in effect as described in this License, you must purchase a
|
||||
commercial license from the Licensor, its affiliated entities, or authorized
|
||||
resellers, or you must refrain from using the Licensed Work.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
All copies of the original and modified Licensed Work, and derivative works
|
||||
of the Licensed Work, are subject to this License. This License applies
|
||||
separately for each version of the Licensed Work and the Change Date may vary
|
||||
for each version of the Licensed Work released by Licensor.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
You must conspicuously display this License on each original or modified copy
|
||||
of the Licensed Work. If you receive the Licensed Work in original or
|
||||
modified form from a third party, the terms and conditions set forth in this
|
||||
License apply to your use of that work.
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
Any use of the Licensed Work in violation of this License will automatically
|
||||
terminate your rights under this License for the current and all other
|
||||
versions of the Licensed Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
This License does not grant you any right in any trademark or logo of
|
||||
Licensor or its affiliates (provided that you may use a trademark or logo of
|
||||
Licensor as expressly required by this License).
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
||||
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
||||
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
||||
TITLE.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
Turnstone
|
||||
Copyright 2025-2026 Patrick Buckley
|
||||
|
||||
Licensed under the Apache License, Version 2.0; see the LICENSE file.
|
||||
|
||||
Third-party software bundled with this distribution is listed in the
|
||||
THIRD-PARTY-NOTICES file; each component remains under its own license.
|
||||
+10
-10
@@ -42,20 +42,20 @@ That's it — no flags, no arguments. The wizard prompts for everything.
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
- **Single-node production** — `docker compose up` against the bundled
|
||||
`turnstone/deploy/compose.yaml`: 1 server + console + channel + PostgreSQL,
|
||||
pulled from ghcr.io. Good for most deployments.
|
||||
- **Local multi-node cluster** — clone the repo and run `docker compose up` at
|
||||
the root for a 10-node fleet + console + Caddy + channel, built locally.
|
||||
The wizard supports two deployment modes:
|
||||
|
||||
See [docs/docker.md](docs/docker.md) for both.
|
||||
- **Single-node production** (`docker compose --profile production up`) —
|
||||
1 server + console + PostgreSQL. Good for most use cases.
|
||||
- **Multi-node cluster** (`docker compose --profile cluster up`) —
|
||||
10-node server fleet + console + PostgreSQL. For high-throughput or
|
||||
HA deployments.
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
$ turnstone-bootstrap
|
||||
|
||||
Turnstone Bootstrap Wizard v1.5.0
|
||||
Turnstone Bootstrap Wizard v0.5.4
|
||||
────────────────────────────────────────────────
|
||||
|
||||
Which provider for this wizard?
|
||||
@@ -87,6 +87,6 @@ $ turnstone-bootstrap
|
||||
|
||||
## See Also
|
||||
|
||||
- [Docker Deployment](docs/docker.md) — manual compose setup and profiles
|
||||
- [Security](docs/security.md) — auth architecture and token types
|
||||
- [Governance](docs/governance.md) — roles, policies, and templates
|
||||
- [Docker Deployment](docker.md) — manual compose setup and profiles
|
||||
- [Security](security.md) — auth architecture and token types
|
||||
- [Governance](governance.md) — roles, policies, and templates
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
[](https://github.com/turnstonelabs/turnstone/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/turnstone/)
|
||||
[](https://pypi.org/project/turnstone/)
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/Nh3bWMacaq)
|
||||
[](LICENSE)
|
||||
|
||||
Self-hosted, local-first orchestration for tool-using AI agents. Give LLMs real tools — shell, files, search, web — and run them across your own cluster with direct HTTP routing and interactive interfaces. Your code, your models, your data stay on hardware you control: no telemetry, no phone-home.
|
||||
Multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers with direct HTTP routing, interactive interfaces, and enterprise governance.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero.png" alt="Turnstone coordinator — parallel tool batches with judge-graded approval and child workstream tracking" width="960"/>
|
||||
<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.
|
||||
@@ -27,13 +26,12 @@ See [docs/releasing.md](docs/releasing.md) for the full release process.
|
||||
|
||||
Turnstone gives LLMs tools — shell, files, search, web, planning — and orchestrates multi-turn conversations where the model investigates, acts, and reports.
|
||||
|
||||
- **Local-first & private** — runs entirely on hardware you control, with no telemetry and no phone-home. Point it at local models (vLLM, llama.cpp, Ollama) or commercial APIs you hold the keys to — your prompts and data never transit a third party you didn't choose.
|
||||
- **Bring your own models** — OpenAI-compatible APIs (vLLM, llama.cpp, NIM), the Anthropic Messages API, and Google Gemini, mixed freely per role
|
||||
- **Interactive sessions** — terminal CLI or browser UI with parallel workstreams
|
||||
- **Cluster dashboard** — real-time view of every node and workstream, with a rendezvous routing proxy
|
||||
- **Intent validation** — an LLM judge (your model) grades every tool call with a risk assessment and evidence before it runs
|
||||
- **Cluster dashboard** — real-time view of all nodes and workstreams with console routing proxy
|
||||
- **Intent validation** — LLM judge evaluates every tool call with risk assessments and evidence
|
||||
- **Governance** — RBAC, OIDC SSO, tool policies, skills, usage tracking, audit logs
|
||||
- **Multi-provider** — OpenAI-compatible APIs (vLLM, llama.cpp, NIM), Anthropic Messages API, and Google Gemini
|
||||
- **MCP support** — external tool servers with native deferred loading (Anthropic/OpenAI) or BM25 fallback
|
||||
- **Team controls when you need them** — optional RBAC, SSO, tool policies, and audit logs, all stored in your own database
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/architecture-overview.svg" alt="Turnstone system architecture" width="960"/>
|
||||
@@ -51,42 +49,18 @@ turnstone --base-url http://localhost:8000/v1
|
||||
turnstone-server --port 8080 --base-url http://localhost:8000/v1
|
||||
|
||||
# Cluster dashboard
|
||||
pip install turnstone[console]
|
||||
turnstone-console --port 8090
|
||||
```
|
||||
|
||||
For PostgreSQL (recommended for production):
|
||||
|
||||
```bash
|
||||
export TURNSTONE_DB_BACKEND=postgresql
|
||||
export TURNSTONE_DB_URL="postgresql+psycopg://user:pass@localhost:5432/turnstone"
|
||||
turnstone-server --port 8080 --base-url http://localhost:8000/v1
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
One-line install — autodetects Ubuntu/Debian, Fedora/RHEL, Arch, and WSL,
|
||||
installs git + Docker if missing, generates secrets, and starts the stack:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/turnstonelabs/turnstone/main/run.sh | bash
|
||||
cp .env.example .env # edit LLM_BASE_URL, OPENAI_API_KEY, etc.
|
||||
docker compose --profile production up
|
||||
```
|
||||
|
||||
Or, if you already have Docker, clone the repo and run it yourself:
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
That builds one image and brings up a full local cluster — PostgreSQL, console,
|
||||
Caddy, channel gateway, and 10 server nodes — with no `.env` required (it ships
|
||||
with insecure dev defaults). Open the dashboard at https://localhost:8443 (Caddy
|
||||
serves it over TLS with its own local CA — trust it once). Nodes boot without an
|
||||
LLM; add model backends from the console UI.
|
||||
|
||||
For production (released images from ghcr.io, real secrets required), use the
|
||||
bundled stack: `docker compose -f turnstone/deploy/compose.yaml up`.
|
||||
|
||||
See [QUICKSTART.md](QUICKSTART.md) for the bootstrap wizard and [docs/docker.md](docs/docker.md) for Docker configuration.
|
||||
See [QUICKSTART.md](QUICKSTART.md) for the bootstrap wizard and [docs/docker.md](docs/docker.md) for Docker configuration and profiles.
|
||||
|
||||
### Programmatic (SDK)
|
||||
|
||||
@@ -101,20 +75,20 @@ with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
|
||||
## Tools
|
||||
|
||||
Built-in tools for shell, files, search, web, memory, notifications, and autonomous sub-agents — plus external tools via [MCP](https://modelcontextprotocol.io/) with native deferred loading. See [docs/tools.md](docs/tools.md) for the full reference and [docs/mcp-registry.md](docs/mcp-registry.md) for MCP configuration.
|
||||
Built-in tools for shell, files, search, web, memory, notifications, and autonomous sub-agents — plus external tools via [MCP](https://modelcontextprotocol.io/) with native deferred loading. See [docs/tools.md](docs/tools.md) for the full reference and [docs/mcp.md](docs/mcp.md) for MCP configuration.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Single-node**: Client → Server (direct HTTP + SSE). No external dependencies beyond the database.
|
||||
|
||||
**Multi-node**: Client → Console (rendezvous routing proxy) → Server nodes. The console picks the target node for each workstream via rendezvous (HRW) hashing over the live service registry — pure function of `(ws_id, live_nodes)`, no stored bucket state, deterministic across readers. A node join or drop only re-routes the keys that score highest on the affected node.
|
||||
**Multi-node**: Client → Console (hash ring routing proxy) → Server nodes. The console maintains a 65536-entry bucket cache for O(1) workstream routing. A rebalancer daemon redistributes buckets when nodes join or leave.
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `turnstone` | Terminal CLI (REPL) |
|
||||
| `turnstone-server` | Web UI + REST API + SSE events |
|
||||
| `turnstone-console` | Cluster dashboard + routing proxy + admin panel |
|
||||
| `turnstone-channel` | Channel gateway (Discord and Slack adapters) |
|
||||
| `turnstone-channel` | Channel gateway (Discord, with adapters for Slack/Teams planned) |
|
||||
| `turnstone-admin` | User/token management CLI |
|
||||
| `turnstone-eval` | Eval harness for prompt/tool optimization |
|
||||
| `turnstone-bootstrap` | LLM-guided setup wizard |
|
||||
@@ -134,7 +108,7 @@ UML diagrams in [`docs/diagrams/`](docs/diagrams/):
|
||||
| [Console Data Flow](docs/diagrams/png/11-console-data-flow.png) | Dashboard data collection |
|
||||
| [Deployment](docs/diagrams/png/12-deployment.png) | Docker Compose topology |
|
||||
| [Auth](docs/diagrams/png/15-auth-architecture.png) | JWT, scopes, login flows |
|
||||
| [Channels](docs/diagrams/png/16-channel-architecture.png) | Discord / Slack adapters + routing |
|
||||
| [Channels](docs/diagrams/png/16-channel-architecture.png) | Discord adapter + routing |
|
||||
| [Judge](docs/diagrams/png/22-judge-architecture.png) | Intent validation pipeline |
|
||||
| [OIDC](docs/diagrams/png/25-oidc-architecture.png) | SSO authorization code flow |
|
||||
|
||||
@@ -153,20 +127,15 @@ UML diagrams in [`docs/diagrams/`](docs/diagrams/):
|
||||
| Console dashboard | [docs/console.md](docs/console.md) |
|
||||
| Eval harness | [docs/eval.md](docs/eval.md) |
|
||||
| Tools reference | [docs/tools.md](docs/tools.md) |
|
||||
| MCP integration | [docs/mcp-registry.md](docs/mcp-registry.md) |
|
||||
| MCP integration | [docs/mcp.md](docs/mcp.md) |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11+
|
||||
- An OpenAI-compatible API endpoint, Anthropic API key, or Google Gemini API key
|
||||
- Optional: Discord / Slack channel integrations (`pip install turnstone[discord,slack]`)
|
||||
- Optional: PostgreSQL (`pip install turnstone[postgres]`), Anthropic (`pip install turnstone[anthropic]`)
|
||||
- [Git LFS](https://git-lfs.com/) for cloning (diagram PNGs)
|
||||
|
||||
## Community
|
||||
|
||||
Questions, ideas, or want to show what you're building? Join us on Discord:
|
||||
**[discord.gg/Nh3bWMacaq](https://discord.gg/Nh3bWMacaq)**.
|
||||
|
||||
## License
|
||||
|
||||
[Apache License 2.0](LICENSE), as of version 1.6.0. Versions 1.5.x and earlier remain under the Business Source License 1.1 they shipped with.
|
||||
[Business Source License 1.1](LICENSE) — free for all use except hosting as a managed service. Converts to Apache 2.0 on 2030-03-01.
|
||||
|
||||
+4
-4
@@ -2,11 +2,11 @@ Turnstone — Third-Party Notices
|
||||
|
||||
This file contains the licenses and notices for third-party software bundled
|
||||
with Turnstone. Each bundled dependency retains its original license; the
|
||||
Turnstone Apache-2.0 license does not apply to these components.
|
||||
Turnstone BUSL-1.1 license does not apply to these components.
|
||||
|
||||
================================================================================
|
||||
|
||||
KaTeX 0.17.0
|
||||
KaTeX 0.16.38
|
||||
https://katex.org/
|
||||
https://github.com/KaTeX/KaTeX
|
||||
|
||||
@@ -70,7 +70,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
================================================================================
|
||||
|
||||
Mermaid 11.15.0
|
||||
Mermaid 11.13.0
|
||||
https://mermaid.js.org/
|
||||
https://github.com/mermaid-js/mermaid
|
||||
|
||||
@@ -98,7 +98,7 @@ SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
|
||||
hls.js 1.6.16
|
||||
hls.js 1.6.15
|
||||
https://github.com/video-dev/hls.js
|
||||
|
||||
Copyright 2017 Dailymotion
|
||||
|
||||
+141
-204
@@ -1,50 +1,16 @@
|
||||
# =============================================================================
|
||||
# Turnstone — local cluster stack (docker compose)
|
||||
# Turnstone Docker Compose Stack — Development
|
||||
#
|
||||
# Clone the repo and run:
|
||||
# This file is for local development from a git clone. It builds images
|
||||
# locally from the Dockerfile. If you installed via pip/pipx, run
|
||||
# `turnstone-bootstrap` instead — it writes a production compose.yaml
|
||||
# that pulls pre-built images from ghcr.io.
|
||||
#
|
||||
# docker compose up
|
||||
#
|
||||
# That builds one image and brings up a complete, console-visible cluster:
|
||||
# PostgreSQL + console + Caddy + channel gateway + 10 server nodes (node-1…10).
|
||||
#
|
||||
# Dashboard: https://localhost:8443 (Caddy's local CA — trust it once)
|
||||
#
|
||||
# Access is via Caddy only — the console's plain-HTTP port is intentionally not
|
||||
# published (HTTP/2 from Caddy avoids the browser's 6-connection cap on the
|
||||
# dashboard's SSE streams). Trust Caddy's root once:
|
||||
# docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt
|
||||
#
|
||||
# It works out of the box with INSECURE dev defaults (see the secret/password
|
||||
# values below) so there's nothing to configure first. A .env file still
|
||||
# overrides any value. For a real deployment use the bundled production stack
|
||||
# at turnstone/deploy/compose.yaml — it pulls released images from ghcr.io and
|
||||
# requires you to set real secrets.
|
||||
#
|
||||
# Bring your own LLM: nodes boot without one and show up in the console
|
||||
# immediately. Add model backends (OpenAI / Anthropic / local vLLM) from the
|
||||
# console UI's Models tab, or point LLM_BASE_URL / OPENAI_API_KEY (below) at an
|
||||
# OpenAI-compatible endpoint.
|
||||
#
|
||||
# Fewer nodes (lighter machines):
|
||||
# docker compose up postgres console caddy channel node-1 node-2 node-3
|
||||
#
|
||||
# Join a bare-metal host: Postgres is published on 127.0.0.1:5432, so a
|
||||
# turnstone-server running directly on this machine (e.g. to use a local GPU)
|
||||
# can join the same cluster. Keep the secret + connection settings in
|
||||
# ~/.config/turnstone/config.toml (chmod 0600 — the loader warns otherwise):
|
||||
# [auth]
|
||||
# jwt_secret = "dev-only-insecure-jwt-secret-change-me-for-real-deployments"
|
||||
# [database]
|
||||
# backend = "postgresql"
|
||||
# url = "postgresql+psycopg://turnstone:turnstone@localhost:5432/turnstone"
|
||||
# [api]
|
||||
# base_url = "http://localhost:8000/v1"
|
||||
# api_key = "dummy"
|
||||
# then run (node identity isn't a secret, so it stays on the command line):
|
||||
# TURNSTONE_NODE_ID=host-1 TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
|
||||
# turnstone-server --host 0.0.0.0 --port 8080
|
||||
# It registers in Postgres and the console reaches it back via host.docker.internal.
|
||||
# Usage:
|
||||
# Infra only: docker compose up
|
||||
# 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
|
||||
# =============================================================================
|
||||
|
||||
name: turnstone
|
||||
@@ -57,30 +23,16 @@ volumes:
|
||||
turnstone-data:
|
||||
workspace:
|
||||
postgres-data:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
searxng-cache:
|
||||
|
||||
# -- Shared values (scalar anchors) -------------------------------------------
|
||||
# Defined once here, referenced (*alias) by every service so the dev defaults
|
||||
# can't drift. All `${VAR:-default}` values are still overridable via .env.
|
||||
x-shared:
|
||||
# INSECURE dev default. Every service MUST share ONE secret — the console
|
||||
# mints its own service token (signed with this) to reach the nodes. Override
|
||||
# TURNSTONE_JWT_SECRET in .env for anything that isn't a local sandbox.
|
||||
jwt-secret: &jwt-secret "${TURNSTONE_JWT_SECRET:-dev-only-insecure-jwt-secret-change-me-for-real-deployments}"
|
||||
db-backend: &db-backend "${TURNSTONE_DB_BACKEND:-postgresql}"
|
||||
# All services point at the same Postgres. Node discovery REQUIRES a shared
|
||||
# DB: each server registers + heartbeats into a `services` table that the
|
||||
# console polls. (SQLite-per-container can't see other containers.)
|
||||
db-url: &db-url "${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}"
|
||||
|
||||
services:
|
||||
# -------------------------------------------------------------------
|
||||
# PostgreSQL — the shared database that ties the cluster together.
|
||||
# PostgreSQL — production database (profile: production)
|
||||
# -------------------------------------------------------------------
|
||||
postgres:
|
||||
image: pgautoupgrade/pgautoupgrade:18-alpine
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
@@ -90,16 +42,8 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: turnstone
|
||||
POSTGRES_USER: ${POSTGRES_USER:-turnstone}
|
||||
# INSECURE dev default — override POSTGRES_PASSWORD in .env for real use.
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-turnstone}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required for production profile}
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
# Published on localhost so a bare-metal turnstone-server running on THIS
|
||||
# host can join the cluster (see "Join a bare-metal host" in the header).
|
||||
# Bound to 127.0.0.1 by default; set POSTGRES_BIND=0.0.0.0 to let another
|
||||
# machine connect — but set a real POSTGRES_PASSWORD first, or you'll expose
|
||||
# a database with the insecure default password to your network.
|
||||
ports:
|
||||
- "${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
@@ -113,21 +57,65 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
memory: 1G
|
||||
cpus: '1.0'
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# turnstone-console — cluster dashboard. Reach it ONLY through Caddy at
|
||||
# https://localhost:8443 (see the caddy service below).
|
||||
#
|
||||
# The console port (8090) is deliberately NOT published to the host: a plain
|
||||
# HTTP/1.1 origin caps the browser at 6 connections, which starves the
|
||||
# dashboard's per-pane SSE streams. Caddy serves the browser over HTTP/2
|
||||
# (multiplexed) and proxies to console:8090 internally, so the cap is gone.
|
||||
#
|
||||
# The single `build:` here produces the turnstone:local image every other
|
||||
# service reuses. extra_hosts lets the console reach a bare-metal server
|
||||
# advertising http://host.docker.internal:8080 (see "Join a host" below).
|
||||
# turnstone-server — Web UI + chat workstreams + LLM interaction
|
||||
# -------------------------------------------------------------------
|
||||
server:
|
||||
image: turnstone:local
|
||||
profiles:
|
||||
- production
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
turnstone-server
|
||||
--host 0.0.0.0
|
||||
--port 8080
|
||||
--base-url "$${LLM_BASE_URL}"
|
||||
--api-key "$${OPENAI_API_KEY}"
|
||||
$${MODEL:+--model $$MODEL}
|
||||
$${SKIP_PERMISSIONS:+--skip-permissions}
|
||||
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
|
||||
ports:
|
||||
- "${SERVER_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ${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:-}
|
||||
# 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}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-}
|
||||
- TURNSTONE_NODE_ID=${TURNSTONE_NODE_ID:-}
|
||||
- TURNSTONE_ADVERTISE_URL=${TURNSTONE_ADVERTISE_URL:-http://server:8080}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# turnstone-console — Cluster dashboard
|
||||
# -------------------------------------------------------------------
|
||||
console:
|
||||
image: turnstone:local
|
||||
@@ -138,18 +126,16 @@ services:
|
||||
- turnstone-console
|
||||
- --host=0.0.0.0
|
||||
- --port=8090
|
||||
ports:
|
||||
- "${CONSOLE_PORT:-8090}:8090"
|
||||
environment:
|
||||
TURNSTONE_JWT_SECRET: *jwt-secret
|
||||
TURNSTONE_DB_BACKEND: *db-backend
|
||||
TURNSTONE_DB_URL: *db-url
|
||||
TURNSTONE_CONSOLE_URL: http://console:8090
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# 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
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8090/health"]
|
||||
interval: 10s
|
||||
@@ -159,33 +145,14 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# caddy — browser TLS for the console dashboard.
|
||||
# Terminates HTTPS (Caddy's own local CA, see turnstone/deploy/Caddyfile) → console:8090.
|
||||
# Dashboard over TLS: https://localhost:${CONSOLE_HTTPS_PORT:-8443}
|
||||
# -------------------------------------------------------------------
|
||||
caddy:
|
||||
image: caddy:2.11
|
||||
depends_on:
|
||||
- console
|
||||
ports:
|
||||
- "${CONSOLE_HTTPS_PORT:-8443}:443"
|
||||
# SearxNG web UI — localhost-only (it has no auth). Browse https://localhost:8444.
|
||||
- "127.0.0.1:${SEARXNG_HTTPS_PORT:-8444}:8444"
|
||||
volumes:
|
||||
- ./turnstone/deploy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data # persist Caddy's local CA across restarts
|
||||
- caddy-config:/config
|
||||
networks:
|
||||
- turnstone-net
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# turnstone-channel — channel gateway (Discord and/or Slack).
|
||||
# Runs HTTP-only with no adapters until you set a token, so it's safe
|
||||
# to leave running. See docs/channels.md.
|
||||
# turnstone-channel — Channel gateway (Discord, Slack, etc.)
|
||||
# Requires TURNSTONE_DISCORD_TOKEN to enable Discord adapter
|
||||
# -------------------------------------------------------------------
|
||||
channel:
|
||||
image: turnstone:local
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -194,59 +161,36 @@ services:
|
||||
--http-host=0.0.0.0
|
||||
$${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD}
|
||||
environment:
|
||||
TURNSTONE_JWT_SECRET: *jwt-secret
|
||||
TURNSTONE_DB_BACKEND: *db-backend
|
||||
TURNSTONE_DB_URL: *db-url
|
||||
TURNSTONE_DISCORD_TOKEN: ${TURNSTONE_DISCORD_TOKEN:-}
|
||||
TURNSTONE_DISCORD_GUILD: ${TURNSTONE_DISCORD_GUILD:-0}
|
||||
TURNSTONE_SLACK_TOKEN: ${TURNSTONE_SLACK_TOKEN:-}
|
||||
TURNSTONE_SLACK_APP_TOKEN: ${TURNSTONE_SLACK_APP_TOKEN:-}
|
||||
TURNSTONE_CHANNEL_ADVERTISE_URL: http://channel:8091
|
||||
- TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-}
|
||||
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
|
||||
# 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+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
|
||||
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# searxng — self-hosted metasearch backing the web_search tool.
|
||||
# Internal-network only (no published port): nodes reach it at
|
||||
# http://searxng:8080. Config (JSON output on, limiter off) lives in
|
||||
# turnstone/deploy/searxng/settings.yml, mounted read-only. Commercial
|
||||
# models use native provider search and never hit this; it serves
|
||||
# local/vLLM models. Override the tag with SEARXNG_IMAGE_TAG in .env.
|
||||
# -------------------------------------------------------------------
|
||||
searxng:
|
||||
image: searxng/searxng:${SEARXNG_IMAGE_TAG:-latest}
|
||||
volumes:
|
||||
- ./turnstone/deploy/searxng:/etc/searxng:ro
|
||||
- searxng-cache:/var/cache/searxng # favicon + internal SQLite cache (survives restarts)
|
||||
networks:
|
||||
- turnstone-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
|
||||
# ===================================================================
|
||||
# Server fleet — node-1 … node-10
|
||||
# 10-node cluster (profile: cluster)
|
||||
#
|
||||
# Each node registers itself in Postgres on boot (unique
|
||||
# TURNSTONE_NODE_ID + TURNSTONE_ADVERTISE_URL) and the console
|
||||
# discovers it automatically — no static node list anywhere.
|
||||
# All nodes share the same PostgreSQL instance.
|
||||
# Access via console at :8090.
|
||||
#
|
||||
# node-1 carries the shared definition (&node / &node-env); node-2…10
|
||||
# inherit it and override only their identity.
|
||||
# Start: docker compose --profile cluster up
|
||||
# ===================================================================
|
||||
node-1: &node
|
||||
|
||||
# -- cluster servers ------------------------------------------------
|
||||
|
||||
server-1: &cluster-server
|
||||
image: turnstone:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
build: { context: ., dockerfile: Dockerfile }
|
||||
profiles: [cluster]
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -262,30 +206,23 @@ services:
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ${WORKSPACE_MOUNT:-workspace}:/workspace
|
||||
environment: &node-env
|
||||
TURNSTONE_JWT_SECRET: *jwt-secret
|
||||
TURNSTONE_DB_BACKEND: *db-backend
|
||||
TURNSTONE_DB_URL: *db-url
|
||||
# Bootstrap LLM defaults — real backends are configured in the console UI.
|
||||
environment: &cluster-server-env
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy}
|
||||
# web_search backend. Defaults to the bundled searxng service; point at an
|
||||
# external SearxNG by setting TURNSTONE_SEARXNG_URL in .env (empty disables).
|
||||
TURNSTONE_SEARXNG_URL: ${TURNSTONE_SEARXNG_URL:-http://searxng:8080}
|
||||
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
|
||||
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
|
||||
# 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:-}
|
||||
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
|
||||
TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql}
|
||||
TURNSTONE_DB_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone}
|
||||
TURNSTONE_NODE_ID: node-1
|
||||
TURNSTONE_ADVERTISE_URL: http://node-1:8080
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- turnstone-net
|
||||
TURNSTONE_ADVERTISE_URL: http://server-1:8080
|
||||
extra_hosts: ["host.docker.internal:host-gateway"]
|
||||
networks: [turnstone-net]
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
searxng:
|
||||
condition: service_healthy
|
||||
postgres: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
@@ -294,34 +231,34 @@ services:
|
||||
start_period: 60s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
limits: { memory: 384M, cpus: '0.5' }
|
||||
restart: unless-stopped
|
||||
|
||||
node-2:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-2, TURNSTONE_ADVERTISE_URL: "http://node-2:8080" }
|
||||
node-3:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-3, TURNSTONE_ADVERTISE_URL: "http://node-3:8080" }
|
||||
node-4:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-4, TURNSTONE_ADVERTISE_URL: "http://node-4:8080" }
|
||||
node-5:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-5, TURNSTONE_ADVERTISE_URL: "http://node-5:8080" }
|
||||
node-6:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-6, TURNSTONE_ADVERTISE_URL: "http://node-6:8080" }
|
||||
node-7:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-7, TURNSTONE_ADVERTISE_URL: "http://node-7:8080" }
|
||||
node-8:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-8, TURNSTONE_ADVERTISE_URL: "http://node-8:8080" }
|
||||
node-9:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-9, TURNSTONE_ADVERTISE_URL: "http://node-9:8080" }
|
||||
node-10:
|
||||
<<: *node
|
||||
environment: { <<: *node-env, TURNSTONE_NODE_ID: node-10, TURNSTONE_ADVERTISE_URL: "http://node-10:8080" }
|
||||
server-2:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-2, TURNSTONE_ADVERTISE_URL: "http://server-2:8080" }
|
||||
server-3:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-3, TURNSTONE_ADVERTISE_URL: "http://server-3:8080" }
|
||||
server-4:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-4, TURNSTONE_ADVERTISE_URL: "http://server-4:8080" }
|
||||
server-5:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-5, TURNSTONE_ADVERTISE_URL: "http://server-5:8080" }
|
||||
server-6:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-6, TURNSTONE_ADVERTISE_URL: "http://server-6:8080" }
|
||||
server-7:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-7, TURNSTONE_ADVERTISE_URL: "http://server-7:8080" }
|
||||
server-8:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-8, TURNSTONE_ADVERTISE_URL: "http://server-8:8080" }
|
||||
server-9:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-9, TURNSTONE_ADVERTISE_URL: "http://server-9:8080" }
|
||||
server-10:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-10, TURNSTONE_ADVERTISE_URL: "http://server-10:8080" }
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Bare-metal overlay — expose PostgreSQL and let the console reach
|
||||
# a turnstone-server running outside Docker on the host machine.
|
||||
#
|
||||
# Requires TURNSTONE_HOST_IP set to the host's routable IP address.
|
||||
#
|
||||
# Usage:
|
||||
# export TURNSTONE_HOST_IP="$(hostname -I | awk '{print $1}')"
|
||||
# docker compose --profile production \
|
||||
# -f compose.yaml -f deploy/docker-compose.bare-metal.yml up
|
||||
#
|
||||
# Then on the host:
|
||||
# export TURNSTONE_JWT_SECRET="<same as .env>"
|
||||
# export TURNSTONE_DB_BACKEND=postgresql
|
||||
# export TURNSTONE_DB_URL="postgresql://turnstone:<pw>@localhost:5432/turnstone"
|
||||
# export TURNSTONE_NODE_ID="bare-metal-1"
|
||||
# export TURNSTONE_ADVERTISE_URL="http://${TURNSTONE_HOST_IP}:8080"
|
||||
# python -m turnstone.server --host 0.0.0.0 --port 8080 \
|
||||
# --base-url http://localhost:8000/v1 --api-key "$OPENAI_API_KEY"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
|
||||
console:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
# Console needs to reach the bare-metal server on the host
|
||||
TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}"
|
||||
|
||||
channel:
|
||||
ports:
|
||||
- "${CHANNEL_PORT:-8091}:8091"
|
||||
environment:
|
||||
# Channel gateway advertises with host-routable IP so the
|
||||
# bare-metal server can reach it for schedule notifications
|
||||
TURNSTONE_CHANNEL_ADVERTISE_URL: "http://${TURNSTONE_HOST_IP}:${CHANNEL_PORT:-8091}"
|
||||
# Channel needs to reach the bare-metal server on the host
|
||||
TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}"
|
||||
@@ -1,8 +1,7 @@
|
||||
# TLS overlay — enables mTLS across the turnstone deployment.
|
||||
# TLS overlay — enables mTLS across the turnstone cluster.
|
||||
#
|
||||
# Layers on the production stack (it patches the `server`, `console`, and
|
||||
# `channel` services that file defines):
|
||||
# docker compose -f turnstone/deploy/compose.yaml -f deploy/docker-compose.tls.yml up
|
||||
# Usage (requires base compose.yaml with production profile):
|
||||
# docker compose -f compose.yaml -f deploy/docker-compose.tls.yml --profile production up
|
||||
#
|
||||
# The tls-init service bootstraps a CA and issues certs.
|
||||
# All turnstone services auto-provision their own certs via the
|
||||
@@ -13,7 +12,7 @@ services:
|
||||
# Runs as root to create directories in the volume, then chowns
|
||||
# to turnstone:turnstone with restrictive perms (keys 0600).
|
||||
tls-init:
|
||||
image: ghcr.io/turnstonelabs/turnstone:${TURNSTONE_IMAGE_TAG:-latest}
|
||||
build: .
|
||||
user: root
|
||||
command:
|
||||
- sh
|
||||
|
||||
@@ -7,6 +7,6 @@ appVersion: "0.3.0"
|
||||
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: ~18.7.0
|
||||
version: ~18.5.0
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
condition: postgresql.enabled
|
||||
|
||||
@@ -103,18 +103,13 @@ network_policies:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- Web search (SearxNG) ---
|
||||
# Turnstone talks only to its SearxNG instance over HTTP; SearxNG itself makes
|
||||
# the outbound calls to search engines (and is NOT governed by this policy —
|
||||
# it runs as a separate service). The host/port below is the bundled compose
|
||||
# service name; if your SearxNG runs elsewhere, set it to match
|
||||
# TURNSTONE_SEARXNG_URL.
|
||||
# --- Web search fallback (Tavily) ---
|
||||
|
||||
searxng:
|
||||
name: searxng-search
|
||||
tavily_api:
|
||||
name: tavily-search
|
||||
endpoints:
|
||||
- host: searxng
|
||||
port: 8080
|
||||
- host: api.tavily.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
+11
-75
@@ -2,69 +2,13 @@
|
||||
"""Health check for turnstone containers.
|
||||
|
||||
Usage: healthcheck.py <url>
|
||||
Exit 0 if the endpoint returns {"status": "ok"} or {"status": "degraded"},
|
||||
exit 1 otherwise. Uses only stdlib — no pip dependencies required.
|
||||
|
||||
When the node serves mTLS (tls.enabled), a plain-HTTP probe is rejected at
|
||||
the socket, so on failure this script retries over HTTPS, presenting the
|
||||
node's own certificate as the client cert and pinning the cluster CA. The
|
||||
PEM files are the ones the server writes at boot under
|
||||
$TURNSTONE_TLS_PEM_DIR (default: <tmpdir>/turnstone-tls). The host is
|
||||
rewritten to "localhost" for the TLS attempt because the internal CA issues
|
||||
DNS SANs only — certificate verification rejects a literal-IP dial.
|
||||
|
||||
When mTLS is disabled (the default), the plain probe succeeds and nothing
|
||||
here changes: the PEM directory is never consulted.
|
||||
Exit 0 if the endpoint returns {"status": "ok"}, exit 1 otherwise.
|
||||
Uses only stdlib — no pip dependencies required.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
|
||||
def _check(url: str, context: ssl.SSLContext | None = None) -> None:
|
||||
"""Probe one URL; raise if unreachable or the payload is unhealthy."""
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5, context=context) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
if data.get("status") not in ("ok", "degraded"):
|
||||
raise RuntimeError(f"unhealthy payload: {data}")
|
||||
|
||||
|
||||
def _pem_root() -> Path:
|
||||
"""PEM runtime root.
|
||||
|
||||
Must mirror turnstone.core.tls.tls_pem_runtime_dir — this script is
|
||||
standalone stdlib and cannot import turnstone; a drift-guard test in
|
||||
tests/test_docker_healthcheck.py pins the two together.
|
||||
"""
|
||||
root_env = os.environ.get("TURNSTONE_TLS_PEM_DIR")
|
||||
return Path(root_env) if root_env else Path(tempfile.gettempdir()) / "turnstone-tls"
|
||||
|
||||
|
||||
def _find_pem_dir() -> Path | None:
|
||||
"""Locate the newest complete PEM dir written by the server at boot."""
|
||||
root = _pem_root()
|
||||
candidates = [
|
||||
d
|
||||
for d in root.glob("lacme-pem-*")
|
||||
if all((d / name).is_file() for name in ("fullchain.pem", "key.pem", "ca.pem"))
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda d: d.stat().st_mtime)
|
||||
|
||||
|
||||
def _tls_url(url: str) -> str:
|
||||
"""Rewrite scheme to https and host to localhost, keeping port and path."""
|
||||
parts = urlsplit(url)
|
||||
netloc = f"localhost:{parts.port}" if parts.port else "localhost"
|
||||
return urlunsplit(("https", netloc, parts.path, parts.query, parts.fragment))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -74,24 +18,16 @@ def main() -> None:
|
||||
|
||||
url = sys.argv[1]
|
||||
try:
|
||||
_check(url)
|
||||
sys.exit(0)
|
||||
except Exception as plain_exc:
|
||||
pem_dir = _find_pem_dir()
|
||||
if pem_dir is None:
|
||||
print(f"Health check failed: {plain_exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
context = ssl.create_default_context(cafile=str(pem_dir / "ca.pem"))
|
||||
context.load_cert_chain(str(pem_dir / "fullchain.pem"), str(pem_dir / "key.pem"))
|
||||
_check(_tls_url(url), context=context)
|
||||
sys.exit(0)
|
||||
except Exception as tls_exc:
|
||||
print(
|
||||
f"Health check failed: plain: {plain_exc}; mtls: {tls_exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
if data.get("status") in ("ok", "degraded"):
|
||||
sys.exit(0)
|
||||
print(f"Unhealthy: {data}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"Health check failed: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+74
-211
@@ -229,12 +229,12 @@ below.
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/workstreams/{ws_id}/events`
|
||||
### `GET /v1/api/events?ws_id=<id>`
|
||||
|
||||
Opens a Server-Sent Events stream scoped to a single workstream. The connection
|
||||
remains open indefinitely; the server pushes events as they occur.
|
||||
|
||||
**Path parameters:**
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|----------------------------|
|
||||
@@ -281,7 +281,6 @@ Each message in the `messages` array has:
|
||||
| `role` | string | `"user"`, `"assistant"`, or `"tool"` |
|
||||
| `content` | string or null | Text content of the message |
|
||||
| `tool_calls` | array or null | Present only on assistant messages with calls |
|
||||
| `reasoning` | string (optional) | Concatenated reasoning / chain-of-thought text on assistant turns whose `provider_data` carried reasoning-bearing blocks (Anthropic `thinking`, OpenAI Responses `reasoning`, or synthetic `reasoning_text` from local-model servers). Present only when the active model's `surface_persisted_reasoning` flag is True. |
|
||||
|
||||
Each entry in `tool_calls`:
|
||||
|
||||
@@ -326,44 +325,6 @@ finalize any in-progress assistant message.
|
||||
{"type": "stream_end"}
|
||||
```
|
||||
|
||||
**`state_change`** -- the worker thread transitioned to a new state. Drives
|
||||
the client's busy-mode (composer in send vs. stop, spinner indicators,
|
||||
auto-focus on idle). Sent live during normal operation AND on every fresh
|
||||
SSE subscribe (so a mid-stream page refresh restores the correct composer
|
||||
state without waiting for the next live transition).
|
||||
|
||||
```json
|
||||
{"type": "state_change", "state": "running"}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------|--------|----------------------------------------------------------------------|
|
||||
| `state` | string | One of `"running"`, `"thinking"`, `"attention"`, `"idle"`, `"error"` |
|
||||
|
||||
**`in_progress_snapshot`** -- one-shot replay of the in-progress turn's
|
||||
content + reasoning text-so-far when this client connects mid-stream.
|
||||
Lets a refreshing browser tab restore partial assistant text immediately
|
||||
instead of waiting for the response to complete. Yielded once after the
|
||||
kind-specific replay phase (history + pending), only when at least one
|
||||
of `content` / `reasoning` is non-empty. Both halves render into the same
|
||||
assistant bubble the live `content` / `reasoning` events would target;
|
||||
clients should treat the snapshot as idempotent (skip overwrite if the
|
||||
current local buffer is already a superset prefix — covers EventSource
|
||||
auto-reconnect re-replays).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "in_progress_snapshot",
|
||||
"content": "Here is the answer so far: it depends on ",
|
||||
"reasoning": "The user is asking about a comparison; let me think about..."
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|--------|------------------------------------------------------------|
|
||||
| `content` | string | Joined assistant content text accumulated this turn |
|
||||
| `reasoning` | string | Joined reasoning / chain-of-thought text accumulated |
|
||||
|
||||
**`tool_info`** -- one or more tool calls that were auto-approved (no user
|
||||
action required).
|
||||
|
||||
@@ -385,7 +346,7 @@ action required).
|
||||
```
|
||||
|
||||
**`approve_request`** -- one or more tool calls that require user approval. The
|
||||
client must respond via `POST /v1/api/workstreams/{ws_id}/approve`.
|
||||
client must respond via `POST /v1/api/approve`.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -455,6 +416,13 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
|
||||
| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic) |
|
||||
| `cache_read_tokens` | int | Tokens served from prompt cache (Anthropic + OpenAI) |
|
||||
|
||||
**`plan_review`** -- the model is proposing a plan and wants feedback. The
|
||||
client must respond via `POST /v1/api/plan`.
|
||||
|
||||
```json
|
||||
{"type": "plan_review", "content": "Step 1: ...\nStep 2: ..."}
|
||||
```
|
||||
|
||||
**`info`** -- an informational message (e.g. command output).
|
||||
|
||||
```json
|
||||
@@ -482,7 +450,7 @@ after `/clear` or `/new` commands).
|
||||
```
|
||||
|
||||
**`cancelled`** -- a cancel request was acknowledged (via the Stop button or
|
||||
`POST /v1/api/workstreams/{ws_id}/cancel`). This signals that cancellation is in progress, not
|
||||
`POST /v1/api/cancel`). This signals that cancellation is in progress, not
|
||||
that it is complete. The worker thread may still be finishing — wait for
|
||||
`stream_end` before transitioning to a ready state. The client should clear
|
||||
any in-progress assistant rendering but not re-enable the send button until
|
||||
@@ -554,13 +522,7 @@ Each SSE connection to a workstream receives its own delivery queue. Events
|
||||
produced by the worker thread are fanned out to all registered listener queues,
|
||||
so multiple consumers (browser, console proxy, SDK) can connect
|
||||
simultaneously and each receives every event. On reconnect the client receives
|
||||
the kind-specific replay (`connected` + `status` + `history` + pending
|
||||
approval / plan for interactive; `connected` + `status` + pending for coord)
|
||||
followed by a `state_change` carrying the current worker state and an
|
||||
optional `in_progress_snapshot` carrying any partial content / reasoning
|
||||
buffered for the in-progress turn — so a mid-stream refresh restores both
|
||||
the busy-mode UI and the partial assistant text without waiting for the
|
||||
response to complete.
|
||||
a full history replay, so no catch-up mechanism is needed.
|
||||
|
||||
---
|
||||
|
||||
@@ -596,7 +558,7 @@ Possible `state` values:
|
||||
and copies each event to every client queue. If a client queue is full, the
|
||||
event is silently dropped for that client.
|
||||
|
||||
**Keepalive:** Same as `/v1/api/workstreams/{ws_id}/events` -- an SSE comment every 5 seconds.
|
||||
**Keepalive:** Same as `/v1/api/events` -- an SSE comment every 5 seconds.
|
||||
|
||||
---
|
||||
|
||||
@@ -609,8 +571,8 @@ Returns a list of all active workstreams.
|
||||
```json
|
||||
{
|
||||
"workstreams": [
|
||||
{"ws_id": "abc123", "name": "default", "state": "idle"},
|
||||
{"ws_id": "def456", "name": "hacker-news", "state": "thinking"}
|
||||
{"id": "abc123", "name": "default", "state": "idle"},
|
||||
{"id": "def456", "name": "hacker-news", "state": "thinking"}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -619,7 +581,7 @@ Each workstream object:
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|-------------|--------------------------------------------------------|
|
||||
| `ws_id` | string | Unique workstream routing identifier |
|
||||
| `id` | string | Unique workstream routing identifier |
|
||||
| `name` | string | Display name (alias if set, otherwise `ws-xxxx`) |
|
||||
| `state` | string | Current state (see state values above) |
|
||||
|
||||
@@ -692,26 +654,21 @@ Each skill summary:
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/workstreams/{ws_id}/send`
|
||||
### `POST /v1/api/send`
|
||||
|
||||
Sends a user message to a workstream. Spawns a daemon worker thread that calls
|
||||
`session.send()` and streams results back via the SSE channel.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|----------------------|
|
||||
| `ws_id` | string | yes | Target workstream ID |
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{"message": "Explain how the server works"}
|
||||
{"message": "Explain how the server works", "ws_id": "abc123"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------------------|
|
||||
| `message` | string | yes | The user's message text |
|
||||
| `ws_id` | string | yes | Target workstream ID |
|
||||
|
||||
**Response (success):**
|
||||
|
||||
@@ -735,21 +692,15 @@ from a previous request. Also pushes a `busy_error` event to the SSE stream.
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/workstreams/{ws_id}/approve`
|
||||
### `POST /v1/api/approve`
|
||||
|
||||
Responds to a tool approval request. The SSE stream must have previously sent
|
||||
an `approve_request` event for the given workstream.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|----------------------|
|
||||
| `ws_id` | string | yes | Target workstream ID |
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{"approved": true, "feedback": null, "always": false}
|
||||
{"approved": true, "feedback": null, "always": false, "ws_id": "abc123"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
@@ -757,6 +708,7 @@ an `approve_request` event for the given workstream.
|
||||
| `approved` | bool | yes | `true` to approve, `false` to deny |
|
||||
| `feedback` | string/null | no | Optional feedback text (sent as denial reason) |
|
||||
| `always` | bool | no | If `true` and `approved`, enables auto-approve |
|
||||
| `ws_id` | string | yes | Target workstream ID |
|
||||
|
||||
When `always` is `true` and `approved` is `true`, the workstream's WebUI
|
||||
instance sets `auto_approve = True`, causing all subsequent tool calls to be
|
||||
@@ -772,6 +724,36 @@ automatically approved without prompting.
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/plan`
|
||||
|
||||
Responds to a plan review dialog. The SSE stream must have previously sent a
|
||||
`plan_review` event for the given workstream.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{"feedback": "", "ws_id": "abc123"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------|--------|----------|---------------------------------------------------------|
|
||||
| `feedback` | string | yes | Feedback text; empty string means approval |
|
||||
| `ws_id` | string | yes | Target workstream ID |
|
||||
|
||||
To approve the plan, send an empty string for `feedback`. To reject or request
|
||||
changes, send a non-empty feedback string (e.g. `"reject"` or specific
|
||||
revision instructions).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
**Error:** `404` with `{"error": "Unknown workstream"}` if `ws_id` is invalid.
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/command`
|
||||
|
||||
Executes a slash command in the given workstream.
|
||||
@@ -807,7 +789,7 @@ containing the resumed session's messages.
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/workstreams/{ws_id}/cancel`
|
||||
### `POST /v1/api/cancel`
|
||||
|
||||
Cancels the active generation in a workstream. Sets a cooperative cancellation
|
||||
flag that is checked at multiple points in the generation loop (per streaming
|
||||
@@ -830,20 +812,15 @@ for the orphaned thread. Use force cancel when cooperative cancel has not
|
||||
resolved within a few seconds — the web UI offers this as a "Force Stop"
|
||||
button automatically.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|----------------------|
|
||||
| `ws_id` | string | yes | Target workstream ID |
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{"force": false}
|
||||
{"ws_id": "abc123", "force": false}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------|--------|----------|----------------------|
|
||||
| `ws_id`| string | yes | Target workstream ID |
|
||||
| `force`| bool | no | Abandon stuck worker immediately (default: `false`) |
|
||||
|
||||
**Response:**
|
||||
@@ -865,15 +842,6 @@ button automatically.
|
||||
|
||||
Creates a new workstream. The server supports up to 10 concurrent workstreams.
|
||||
|
||||
The endpoint accepts **either** `application/json` (legacy shape) **or**
|
||||
`multipart/form-data` when you want to upload attachments at creation
|
||||
time. Multipart requests carry one `meta` field containing the JSON body
|
||||
shown below plus zero-or-more `file` parts; each file is validated and
|
||||
reserved onto the new workstream's first turn before the dispatch worker
|
||||
runs, so queued multimodal turns cannot lose files to racing sends. If
|
||||
validation fails the fresh workstream is rolled back so no orphan rows
|
||||
leak.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
@@ -916,32 +884,20 @@ Status code: `400`
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/workstreams/{ws_id}/close`
|
||||
### `POST /v1/api/workstreams/close`
|
||||
|
||||
Closes and removes a workstream. The last remaining workstream cannot be
|
||||
closed.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|------------------------|
|
||||
| `ws_id` | string | yes | Workstream ID to close |
|
||||
|
||||
**Request body:**
|
||||
|
||||
The body must be valid JSON. If you are not supplying any optional
|
||||
fields, send `{}` — an empty / non-JSON body is rejected with a
|
||||
`400`.
|
||||
```json
|
||||
{"ws_id": "abc123"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|----------|--------|----------|----------------------------------------------------------|
|
||||
| `reason` | string | no | Optional close reason persisted to `workstream_config`. |
|
||||
|
||||
The `reason` is capped at **512 UTF-8 bytes** (multibyte-safe — the
|
||||
cap holds for CJK and emoji payloads), and the output guard's
|
||||
credential-redaction pass strips secrets before the value is
|
||||
persisted. A non-string `reason` is silently coerced to empty and
|
||||
the close proceeds without writing the field.
|
||||
| Field | Type | Required | Description |
|
||||
|---------|--------|----------|---------------------------|
|
||||
| `ws_id` | string | yes | Workstream ID to close |
|
||||
|
||||
**Response (success):**
|
||||
|
||||
@@ -959,100 +915,6 @@ Status code: `400`
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/workstreams/{ws_id}/attachments`
|
||||
|
||||
Upload an image or text document and attach it to the caller's next user
|
||||
turn on this workstream.
|
||||
|
||||
- Images (png/jpeg/gif/webp) are capped at **4 MiB** and validated via
|
||||
magic-byte sniff on upload.
|
||||
- Text documents (any `text/*` MIME, allow-listed application MIMEs, or
|
||||
known text extensions) are capped at **512 KiB** and must be UTF-8.
|
||||
- Per-(workstream, user) pending cap is **10** attachments.
|
||||
|
||||
The attachment moves through three states: `pending → reserved →
|
||||
consumed`. Reservation tokens are threaded through
|
||||
`POST /v1/api/workstreams/{ws_id}/send` so a queued multimodal turn cannot lose its file to
|
||||
an overlapping send.
|
||||
|
||||
Ownership failures are masked as `404` so non-owners cannot enumerate
|
||||
workstream existence.
|
||||
|
||||
**Content-Type:** `multipart/form-data` with a single `file` field.
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"attachment_id": "att_abc123",
|
||||
"kind": "image",
|
||||
"mime_type": "image/png",
|
||||
"size_bytes": 73240,
|
||||
"filename": "screenshot.png",
|
||||
"state": "pending"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------------------------------------------------------|
|
||||
| 400 | Missing/invalid form, unsupported MIME, not UTF-8, etc. |
|
||||
| 403 | Auth/scope failure |
|
||||
| 404 | Workstream not found / not owned by caller |
|
||||
| 409 | Pending-cap reached |
|
||||
| 413 | Payload exceeds size cap |
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/workstreams/{ws_id}/attachments`
|
||||
|
||||
List the caller's **pending** (unconsumed) attachments for this
|
||||
workstream. Ownership failures are masked as `404`.
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"attachments": [
|
||||
{
|
||||
"attachment_id": "att_abc123",
|
||||
"kind": "image",
|
||||
"mime_type": "image/png",
|
||||
"size_bytes": 73240,
|
||||
"filename": "screenshot.png",
|
||||
"state": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/workstreams/{ws_id}/attachments/{attachment_id}/content`
|
||||
|
||||
Returns the raw bytes of an attachment with its stored `Content-Type`.
|
||||
Useful for previewing an image or replaying a document. Ownership
|
||||
failures are masked as `404`.
|
||||
|
||||
**Response:** `200` — binary body, original `Content-Type`.
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /v1/api/workstreams/{ws_id}/attachments/{attachment_id}`
|
||||
|
||||
Remove a pending attachment. Consumed attachments return `404` (they
|
||||
are part of a committed conversation turn). Ownership failures are also
|
||||
masked as `404`.
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{"deleted": "att_abc123"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/workstreams/{ws_id}/delete`
|
||||
|
||||
Permanently delete a saved workstream and all its messages from storage.
|
||||
@@ -1646,7 +1508,7 @@ version. Requires the `admin.skills` permission.
|
||||
|
||||
```json
|
||||
{
|
||||
"risk_level": "medium",
|
||||
"scan_status": "medium",
|
||||
"scan_report": "{\"composite\": 1.75, \"details\": {...}}",
|
||||
"scan_version": "1"
|
||||
}
|
||||
@@ -1987,7 +1849,7 @@ Status code: `200` with an empty body.
|
||||
| Malformed or unparseable JSON body | Treated as an empty dict `{}`; missing fields use defaults |
|
||||
| Unknown `ws_id` | `404` with `{"error": "Unknown workstream"}` |
|
||||
| Unknown path (GET or POST) | `404` with plain-text body `Not found` |
|
||||
| Empty `message` on `/v1/api/workstreams/{ws_id}/send` | `400` with `{"error": "Empty message"}` |
|
||||
| Empty `message` on `/v1/api/send` | `400` with `{"error": "Empty message"}` |
|
||||
| Empty `command` on `/v1/api/command` | `400` with `{"error": "Empty command"}` |
|
||||
| Rate limit exceeded | `429` with `Retry-After` header (see below) |
|
||||
|
||||
@@ -2030,7 +1892,7 @@ reconnection:
|
||||
On reconnect, the server replays the full conversation history via the
|
||||
`history` event, so the client can rebuild its UI state without data loss. The
|
||||
same reconnection strategy applies to both the per-workstream SSE stream
|
||||
(`/v1/api/workstreams/{ws_id}/events`) and the global state stream (`/v1/api/events/global`).
|
||||
(`/v1/api/events`) and the global state stream (`/v1/api/events/global`).
|
||||
|
||||
---
|
||||
|
||||
@@ -2140,7 +2002,7 @@ turnstone_workstreams_active_total 1
|
||||
# TYPE turnstone_http_requests_total counter
|
||||
turnstone_http_requests_total{method="GET",endpoint="/health",status_code="200"} 42
|
||||
turnstone_http_requests_total{method="GET",endpoint="/metrics",status_code="200"} 7
|
||||
turnstone_http_requests_total{method="POST",endpoint="/v1/api/workstreams/{ws_id}/send",status_code="200"} 18
|
||||
turnstone_http_requests_total{method="POST",endpoint="/v1/api/send",status_code="200"} 18
|
||||
# HELP turnstone_tokens_total Total tokens consumed
|
||||
# TYPE turnstone_tokens_total counter
|
||||
turnstone_tokens_total{type="prompt"} 84320
|
||||
@@ -2156,15 +2018,15 @@ turnstone_tool_calls_total{tool="read_file"} 3
|
||||
## Console Routing Proxy Endpoints
|
||||
|
||||
These endpoints are served by the console (`turnstone-console`) and proxy
|
||||
requests to the correct server node via rendezvous (HRW) hashing over the
|
||||
live service registry. In multi-node deployments, clients (SDK, channel
|
||||
gateway) talk to the console instead of individual server nodes.
|
||||
requests to the correct server node via the hash ring bucket cache. In
|
||||
multi-node deployments, clients (SDK, channel gateway) talk to the console
|
||||
instead of individual server nodes.
|
||||
|
||||
### `POST /v1/api/route/workstreams/new`
|
||||
|
||||
Create a workstream via rendezvous routing. The console generates the `ws_id`,
|
||||
routes to the rendezvous-selected node, and includes `node_url` in the
|
||||
response for direct SSE connections.
|
||||
Create a workstream via hash-ring routing. The console generates the `ws_id`,
|
||||
routes to the assigned node, and includes `node_url` in the response for
|
||||
direct SSE connections.
|
||||
|
||||
### `POST /v1/api/route/send`
|
||||
|
||||
@@ -2199,4 +2061,5 @@ Used by channel adapters to open direct SSE connections to the correct server no
|
||||
|
||||
Prometheus metrics for the console routing layer. Includes:
|
||||
`turnstone_router_requests_total`, `turnstone_router_request_duration_seconds`,
|
||||
`turnstone_router_membership_size`, `turnstone_router_refresh_total`.
|
||||
`turnstone_ring_membership_size`, `turnstone_ring_version`,
|
||||
`turnstone_ring_rebalance_total`, `turnstone_ring_migrations_total`.
|
||||
|
||||
+100
-164
@@ -3,8 +3,8 @@
|
||||
Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent
|
||||
memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) or
|
||||
Anthropic's native Messages API via pluggable provider adapters, and gives the
|
||||
model 16 built-in tools plus external tools via MCP (Model Context Protocol) for
|
||||
reading, writing, searching, and executing code.
|
||||
model 19 built-in tools plus external tools via MCP (Model Context Protocol) for
|
||||
reading, writing, searching, planning, and executing code.
|
||||
|
||||
The core design principle is a **UI-agnostic engine with pluggable frontends**.
|
||||
The engine (`ChatSession`) drives the conversation loop -- streaming, tool
|
||||
@@ -21,8 +21,7 @@ plugs in.
|
||||
| `turnstone-console` | `turnstone.console.server` | ClusterCollector | Cluster dashboard (aggregates all nodes) |
|
||||
| `turnstone-eval` | `turnstone.eval` | `NullUI` | Headless evaluation and prompt optimization |
|
||||
| `turnstone-channel` | `turnstone.channels.cli` | ChannelAdapter | Channel gateway (Discord, Slack, etc.) |
|
||||
| `turnstone-admin` | `turnstone.admin` | — | Offline user and API token management |
|
||||
| `turnstone-bootstrap` | `turnstone.bootstrap` | — | LLM-guided setup wizard |
|
||||
| `turnstone-admin` | `turnstone.core.admin_cli` | — | Offline user and API token management |
|
||||
|
||||
---
|
||||
|
||||
@@ -37,10 +36,7 @@ turnstone/
|
||||
session.py ChatSession engine, SessionUI protocol, tool dispatch
|
||||
providers/ LLM provider adapters (pluggable backend layer)
|
||||
_protocol.py LLMProvider protocol, ModelCapabilities, StreamChunk, CompletionResult
|
||||
_openai.py OpenAIProvider facade (re-exports Chat/Responses providers)
|
||||
_openai_chat.py OpenAIChatCompletionsProvider — vLLM, llama.cpp, local compatible APIs
|
||||
_openai_responses.py OpenAIResponsesProvider — commercial OpenAI Responses API
|
||||
_openai_common.py Shared ModelCapabilities table + helpers
|
||||
_openai.py OpenAIProvider — OpenAI, vLLM, llama.cpp, any compatible API
|
||||
_anthropic.py AnthropicProvider — Anthropic Messages API, native streaming, thinking
|
||||
_google.py GoogleProvider — Google Gemini via OpenAI-compat endpoint
|
||||
__init__.py create_provider() + create_client() factory functions
|
||||
@@ -61,6 +57,7 @@ turnstone/
|
||||
ratelimit.py Per-IP token-bucket rate limiter (RateLimiter, TokenBucket)
|
||||
edit.py File edit utilities (find_occurrences, pick_nearest)
|
||||
safety.py Command safety validation (blocked patterns, sanitization)
|
||||
sandbox.py Math code sandboxing (AST validation, subprocess execution)
|
||||
web.py Web utilities (HTML stripping, SSRF prevention)
|
||||
api/
|
||||
schemas.py Shared Pydantic v2 models (auth, errors, WorkstreamState)
|
||||
@@ -84,13 +81,12 @@ turnstone/
|
||||
static/ Cluster dashboard web UI (page-specific HTML, CSS, JS)
|
||||
channels/
|
||||
cli.py Unified channel gateway entry point (turnstone-channel)
|
||||
_protocol.py ChannelAdapter protocol
|
||||
_protocol.py ChannelAdapter protocol, ChannelEvent dataclass
|
||||
_routing.py ChannelRouter — channel/thread ↔ workstream mapping via HTTP
|
||||
_config.py Base ChannelConfig dataclass
|
||||
discord/ Discord adapter (bot, cog, views, streaming, config)
|
||||
slack/ Slack adapter (Socket Mode bot, DM routing, approval buttons)
|
||||
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
|
||||
katex-0.17.0/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
katex-0.16.45/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
ui/
|
||||
colors.py ANSI color constants with NO_COLOR support
|
||||
markdown.py Streaming terminal markdown renderer (line-buffered)
|
||||
@@ -101,7 +97,7 @@ turnstone/
|
||||
renderer.js Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
|
||||
app.js Split-pane UI (Pane class, binary layout tree, SSE, tool approval)
|
||||
tools/
|
||||
*.json 16 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||||
*.json 15 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||||
```
|
||||
|
||||
Both UIs share a common design system extracted into `turnstone/shared_static/`: design tokens, login overlay, toast notifications, theme toggle, keyboard shortcuts, and utility functions. Each UI imports `base.css` and the shared JS modules at `/shared/`, then adds only page-specific code at `/static/`.
|
||||
@@ -189,6 +185,7 @@ Phase 3: EXECUTE (parallel)
|
||||
(cancel_event also checked per line — kills process group on cancel)
|
||||
Final output (stdout + stderr) delivered via ui.on_tool_result(call_id, name, output)
|
||||
call_id links tool_info items → streaming chunks → final result
|
||||
For plan tool: post-execution gate via ui.on_plan_review()
|
||||
```
|
||||
|
||||
### State Transitions
|
||||
@@ -207,7 +204,7 @@ The engine emits state changes via `_emit_state()` which calls
|
||||
"running" ---> tool execution
|
||||
|
|
||||
v
|
||||
"attention" ---> waiting for user approval
|
||||
"attention" ---> waiting for user approval / plan review
|
||||
|
|
||||
v
|
||||
"running" ---> executing approved tools
|
||||
@@ -229,13 +226,11 @@ The engine emits state changes via `_emit_state()` which calls
|
||||
|
||||
> See also: [Core Engine Classes diagram](diagrams/png/03-core-engine-classes.png)
|
||||
|
||||
Defined in `turnstone.core.session.SessionUI` as a `typing.Protocol` with 15
|
||||
Defined in `turnstone.core.session.SessionUI` as a `typing.Protocol` with 14
|
||||
methods. Every frontend must implement all of them.
|
||||
|
||||
```python
|
||||
class SessionUI(Protocol):
|
||||
def on_turn_start(self) -> None: ...
|
||||
def on_turn_committed(self) -> None: ...
|
||||
def on_thinking_start(self) -> None: ...
|
||||
def on_thinking_stop(self) -> None: ...
|
||||
def on_reasoning_token(self, text: str) -> None: ...
|
||||
@@ -245,20 +240,13 @@ class SessionUI(Protocol):
|
||||
def on_tool_result(self, call_id: str, name: str, output: str, *, is_error: bool = False) -> None: ...
|
||||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
|
||||
def on_status(self, usage: dict, context_window: int, effort: str) -> None: ...
|
||||
def on_plan_review(self, content: str) -> str: ...
|
||||
def on_info(self, message: str) -> None: ...
|
||||
def on_error(self, message: str) -> None: ...
|
||||
def on_state_change(self, state: str) -> None: ...
|
||||
def on_rename(self, name: str) -> None: ... # propagate alias to tab/UI label
|
||||
```
|
||||
|
||||
`on_turn_start` fires at the top of each iteration of the send-loop;
|
||||
`on_turn_committed` fires immediately after `messages.append(assistant_msg)`.
|
||||
`SessionUIBase` uses both to reset the per-turn inflight buffers
|
||||
(`_ws_inflight_content` / `_ws_inflight_reasoning` / `_ws_inflight_seq`)
|
||||
that fuel the SSE refresh-resume `in_progress_snapshot` event — see
|
||||
the per-workstream events stream in
|
||||
[`docs/api-reference.md`](api-reference.md#get-v1apiworkstreamsws_idevents).
|
||||
|
||||
`on_rename` is called by the `/name` command (on success) and after a successful `/resume` (if the resumed session has an alias or title). `WebUI.on_rename` broadcasts a `ws_rename` event on the global SSE channel and updates the in-memory `Workstream.name`; `TerminalUI.on_rename` is a no-op.
|
||||
|
||||
### Three Implementations
|
||||
@@ -266,7 +254,7 @@ the per-workstream events stream in
|
||||
| Class | Module | Notes |
|
||||
|-------|--------|-------|
|
||||
| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval |
|
||||
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval. `on_state_change` sends to both per-workstream and global SSE (the browser UI uses per-workstream `state_change` events to manage busy/idle transitions; `stream_end` only finalizes markdown rendering). |
|
||||
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval/plan. `on_state_change` sends to both per-workstream and global SSE (the browser UI uses per-workstream `state_change` events to manage busy/idle transitions; `stream_end` only finalizes markdown rendering). |
|
||||
| `NullUI` | `turnstone.eval` | Discards all output; `approve_tools` always returns `(True, None)` |
|
||||
|
||||
### WorkstreamTerminalUI
|
||||
@@ -278,9 +266,10 @@ awareness:
|
||||
are appended to `_output_buffer` instead of written to stdout. When the user
|
||||
switches to this workstream, `flush_buffer()` replays them.
|
||||
|
||||
- **Approval blocking**: `approve_tools()` calls `_fg_event.wait()` when in
|
||||
background, blocking the worker thread until the workstream is foregrounded.
|
||||
This ensures the user sees the approval prompt in the correct context.
|
||||
- **Approval blocking**: `approve_tools()` and `on_plan_review()` call
|
||||
`_fg_event.wait()` when in background, blocking the worker thread until the
|
||||
workstream is foregrounded. This ensures the user sees the approval prompt
|
||||
in the correct context.
|
||||
|
||||
- **Foreground/background toggle**: `set_foreground(bool)` sets or clears
|
||||
`_fg_event` (a `threading.Event`). The manager calls this during `/ws <N>`
|
||||
@@ -390,11 +379,11 @@ non-idle background workstreams above the input prompt.
|
||||
(`Ctrl+\`, `Ctrl+Shift+\`). Max 6 panes; no duplicate workstreams across panes.
|
||||
Layout persisted to `localStorage`.
|
||||
- **Per-pane SSE**: `Pane.connectSSE(wsId)` opens
|
||||
`/v1/api/workstreams/{ws_id}/events` for each pane's event stream independently.
|
||||
`/v1/api/events?ws_id=<id>` for each pane's event stream independently.
|
||||
- **Global SSE**: `connectGlobalSSE()` opens `/v1/api/events/global` which
|
||||
receives `ws_state` broadcasts from all workstreams, used to update tab
|
||||
indicators and pane headers without switching.
|
||||
- **New tab / close**: POST `/v1/api/workstreams/new`, POST `/v1/api/workstreams/{ws_id}/close`.
|
||||
- **New tab / close**: POST `/v1/api/workstreams/new`, POST `/v1/api/workstreams/close`.
|
||||
|
||||
### Thread Safety
|
||||
|
||||
@@ -417,6 +406,7 @@ turnstone metadata keys:
|
||||
|
||||
| Metadata Key | Type | Meaning |
|
||||
|-------------|------|---------|
|
||||
| `agent` | `bool` | Include this tool when running as a plan/task sub-agent |
|
||||
| `task_agent` | `bool` | Include this tool when running as a task sub-agent |
|
||||
| `auto_approve` | `bool` | Tool is read-only; skip user approval |
|
||||
| `primary_key` | `str` | Fallback argument name for bare-string JSON recovery |
|
||||
@@ -436,6 +426,7 @@ Example (`read_file.json`):
|
||||
},
|
||||
"required": ["path"]
|
||||
},
|
||||
"agent": true,
|
||||
"task_agent": true,
|
||||
"auto_approve": true,
|
||||
"primary_key": "path"
|
||||
@@ -446,39 +437,34 @@ At import time, `turnstone.core.tools._load_tools()` strips the metadata keys
|
||||
from each schema and builds:
|
||||
|
||||
- `TOOLS` -- list of `{"type": "function", "function": {...}}` dicts for the API
|
||||
- `AGENT_TOOLS` -- subset with `agent: true`
|
||||
- `TASK_AGENT_TOOLS` -- subset with `task_agent: true`
|
||||
- `TASK_AUTO_TOOLS` -- set of tool names with `auto_approve: true`
|
||||
- `AGENT_AUTO_TOOLS` / `TASK_AUTO_TOOLS` -- sets of tool names with `auto_approve: true`
|
||||
- `PRIMARY_KEY_MAP` -- `{name: primary_key}` for JSON fallback recovery
|
||||
- `merge_mcp_tools(builtin, mcp_tools)` -- merges built-in + MCP tools at session init
|
||||
|
||||
### 16 Tools by Category
|
||||
### 13 Tools by Category
|
||||
|
||||
**Read-only (auto-approve)**:
|
||||
- `read_file` -- read file contents with optional offset/limit
|
||||
- `diff_file` -- show diff between two files / versions
|
||||
- `search` -- ripgrep-based codebase search
|
||||
- `man` -- read man pages
|
||||
- `recall` -- search conversation history
|
||||
- `read_resource` -- read an MCP resource by URI
|
||||
|
||||
**Write (requires approval)**:
|
||||
- `bash` -- execute shell commands (with safety checks via `turnstone.core.safety`)
|
||||
- `write_file` -- create or overwrite a file
|
||||
- `edit_file` -- string replacement in an existing file (requires prior `read_file`)
|
||||
- `math` -- execute Python in sandboxed subprocess (via `turnstone.core.sandbox`)
|
||||
- `web_fetch` -- fetch a URL (with SSRF protection via `turnstone.core.web`)
|
||||
- `web_search` -- search the web (provider-native for Anthropic/OpenAI, self-hosted SearxNG fallback for local models)
|
||||
- `notify` -- send a user-facing notification (Discord/Slack, optional reply routing)
|
||||
- `watch` -- schedule a recurring poll with condition DSL
|
||||
- `web_search` -- search the web (provider-native for Anthropic/OpenAI, Tavily fallback for local models)
|
||||
|
||||
**Agent (delegated sub-sessions)**:
|
||||
- `task_agent` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
|
||||
- `task` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
|
||||
- `plan` -- explore codebase and write a structured plan (`AGENT_TOOLS`)
|
||||
|
||||
**Memory / skills / prompts**:
|
||||
**Memory (structured persistent store)**:
|
||||
- `memory` -- save, search, delete, or list memories (typed and scoped)
|
||||
- `skill` -- invoke a skill (governed, versioned procedure)
|
||||
- `use_prompt` -- fetch and apply a prompt template
|
||||
|
||||
The tool name uses the `_agent` suffix — bare `task` collides with
|
||||
chat-template channels on some local models.
|
||||
|
||||
### Prepare / Execute Pattern
|
||||
|
||||
@@ -497,11 +483,17 @@ separation allows the UI to show previews before any side effects occur.
|
||||
|
||||
### Agent Tools
|
||||
|
||||
`task_agent` invokes `_run_agent()`, which runs a multi-turn loop with a
|
||||
subset of tools and its own system prompt. The sub-agent runs independently,
|
||||
then returns the final content as the tool result.
|
||||
`task` and `plan` invoke `_run_agent()`, which runs a multi-turn loop with
|
||||
a subset of tools and its own system prompt. The sub-agent runs
|
||||
independently, then returns the final content as the tool result.
|
||||
|
||||
- **task_agent**: uses `self._task_tools` (`TASK_AGENT_TOOLS` + MCP tools)
|
||||
- **task**: uses `self._task_tools` (`TASK_AGENT_TOOLS` + MCP tools)
|
||||
- **plan**: uses `self._agent_tools` (`AGENT_TOOLS` + MCP tools). Writes output
|
||||
to `.plan-<ws_id>.md` — unique per `ChatSession` so concurrent workstreams
|
||||
don't collide. On repeat invocations the prior `plan` tool call and its result
|
||||
are forwarded from `self.messages` so the agent refines the existing plan rather
|
||||
than starting over. Planning instructions are injected as a developer message
|
||||
prepended to the agent's conversation.
|
||||
- **Turn limit**: controlled by `agent_max_turns` (default: `-1`, unlimited).
|
||||
When a limit is set and reached, the agent is forced to synthesize a final
|
||||
response without tools. When unlimited, the loop only exits when the model
|
||||
@@ -540,16 +532,18 @@ adds, removes, or reconnects servers as needed.
|
||||
6. `_exec_mcp_tool()` calls `call_tool_sync()` which dispatches to the async loop
|
||||
via `asyncio.run_coroutine_threadsafe()`
|
||||
|
||||
**Tool refresh:** Two mechanisms keep tools up-to-date without restart:
|
||||
**Tool refresh:** Three mechanisms keep tools up-to-date without restart:
|
||||
- **Push:** Servers declaring `tools.listChanged` send `ToolListChangedNotification`;
|
||||
the registered `message_handler` triggers immediate single-server refresh.
|
||||
- **Periodic:** Servers without push support are polled on a staggered interval
|
||||
(default 4 h, configurable via `[mcp] refresh_interval` or `--mcp-refresh-interval`).
|
||||
- **Manual:** `/mcp refresh [server]` calls `refresh_sync()` for on-demand refresh
|
||||
(also attempts reconnection for disconnected servers).
|
||||
|
||||
When tools change, `_rebuild_tools()` creates new `_tools`/`_tool_map` objects
|
||||
(copy-on-write for thread safety) and notifies listener callbacks. Each `ChatSession`
|
||||
rebuilds its `_tools` and `_task_tools` lists and reconstructs `ToolSearchManager`
|
||||
(preserving expanded tools).
|
||||
rebuilds its merged tool lists and reconstructs `ToolSearchManager` (preserving
|
||||
expanded tools).
|
||||
|
||||
**Tool naming:** `mcp__{server}__{tool}` — double underscore delimiter, validated
|
||||
at connection time (server names with `__` are rejected).
|
||||
@@ -564,11 +558,10 @@ from a healthy connection do not trip the breaker. When the cooldown expires
|
||||
(`call_tool_sync`, `read_resource_sync`, `get_prompt_sync`, `refresh_sync`)
|
||||
cancel orphaned futures on timeout to prevent coroutine accumulation on the
|
||||
background event loop. Push notification refreshes are debounced (5 s per
|
||||
server) to protect against notification storms. Operators can force a
|
||||
catalog refresh or full reconnect from the admin panel; reconnects clear
|
||||
the circuit breaker and run a fresh handshake. Transport stream references
|
||||
are pre-closed before stack teardown to work around the MCP SDK's anyio
|
||||
cancel-scope CPU busy-loop (SDK #2147).
|
||||
server) to protect against notification storms. The periodic refresh loop
|
||||
attempts reconnection for disconnected servers with exponential backoff
|
||||
(60 s–1 h). Transport stream references are pre-closed before stack teardown to
|
||||
work around the MCP SDK's anyio cancel-scope CPU busy-loop (SDK #2147).
|
||||
|
||||
**Error isolation:** Per-server connection/refresh failures are caught and logged; other
|
||||
servers are unaffected. Tool execution errors return error strings to the LLM
|
||||
@@ -613,15 +606,14 @@ LLMProvider (protocol)
|
||||
| `get_capabilities()` | Per-model flags (`ModelCapabilities`) |
|
||||
| `convert_tools()` | Translate OpenAI tool schemas to provider format |
|
||||
| `retryable_error_names` | Exception class names that trigger retry |
|
||||
| `extract_reasoning_text()` | Walk stored `provider_blocks`, return concatenated reasoning text for UI rehydration (per-provider block-type knowledge: Anthropic `thinking`, OpenAI Responses `reasoning`, OpenAI Chat synthetic `reasoning_text`) |
|
||||
|
||||
**Normalized data types:**
|
||||
|
||||
| Type | Fields |
|
||||
|------|--------|
|
||||
| `StreamChunk` | `content_delta`, `reasoning_delta`, `tool_call_deltas`, `info_delta`, `usage`, `finish_reason`, `provider_blocks` |
|
||||
| `CompletionResult` | `content`, `tool_calls`, `finish_reason`, `usage`, `provider_blocks` |
|
||||
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision`, `supports_reasoning_replay` |
|
||||
| `StreamChunk` | `content_delta`, `reasoning_delta`, `tool_call_deltas`, `info_delta`, `usage`, `finish_reason` |
|
||||
| `CompletionResult` | `content`, `tool_calls`, `finish_reason`, `usage` |
|
||||
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision` |
|
||||
| `UsageInfo` | `prompt_tokens`, `completion_tokens`, `total_tokens`, `cache_creation_tokens`, `cache_read_tokens` |
|
||||
|
||||
**OpenAIProvider** (`_openai.py`): passes messages through unchanged (they are
|
||||
@@ -634,7 +626,7 @@ annotations are formatted as footnotes. Extended prompt cache retention
|
||||
(`prompt_cache_retention: "24h"`) is enabled for GPT-5.x models at no
|
||||
additional cost. Cached token counts are extracted from
|
||||
`usage.prompt_tokens_details.cached_tokens`. Unknown models (local servers) get
|
||||
permissive defaults with `supports_vision=False` and use SearxNG for web search.
|
||||
permissive defaults with `supports_vision=False` and use Tavily for web search.
|
||||
|
||||
**AnthropicProvider** (`_anthropic.py`): converts OpenAI-format messages to
|
||||
Anthropic content blocks, maps `system`/`developer` roles to the `system`
|
||||
@@ -652,8 +644,9 @@ display). Automatic prompt caching is enabled via top-level `cache_control:
|
||||
cacheable block and advances it as conversations grow (90% input cost
|
||||
reduction on cache hits, 1.25x write on first turn). Cache metrics
|
||||
(`cache_creation_input_tokens`, `cache_read_input_tokens`) are extracted from
|
||||
both streaming and non-streaming responses. The `anthropic` SDK is a core
|
||||
dependency — the Anthropic provider is first-class alongside OpenAI.
|
||||
both streaming and non-streaming responses. The `anthropic` SDK is imported
|
||||
lazily so it remains an optional dependency (`pip install
|
||||
turnstone[anthropic]`).
|
||||
|
||||
**GoogleProvider** (`_google.py`): extends `OpenAIChatCompletionsProvider` for
|
||||
the Gemini `/v1beta/openai/` endpoint. Uses a single default
|
||||
@@ -703,55 +696,6 @@ agent_model = "claude"
|
||||
Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
|
||||
(default: `"openai"`). Supported values: `"openai"`, `"anthropic"`, `"google"`,
|
||||
and `"openai-compatible"`.
|
||||
|
||||
**Per-model sampling overrides:** Each model can specify `temperature`,
|
||||
`max_tokens`, and `reasoning_effort` to override the global defaults from
|
||||
ConfigStore. When unset (`NULL`), the global default is used.
|
||||
|
||||
**Per-model reasoning persistence:** Two booleans on `model_definitions`
|
||||
(migration 052) control how reasoning text round-trips:
|
||||
|
||||
* `surface_persisted_reasoning` (default `True`) — gates whether stored
|
||||
reasoning text is surfaced on `/history` payloads for UI rehydration.
|
||||
**Storage of reasoning bytes happens regardless of this flag** — they
|
||||
ride in `provider_data` independently. Phase-1 admin UI label "Surface
|
||||
persisted reasoning."
|
||||
* `replay_reasoning_to_model` (default `False`) — gates whether stored
|
||||
reasoning blocks are sent back to the provider on subsequent turns.
|
||||
Capability-gated: `ModelCapabilities.supports_reasoning_replay` must
|
||||
also be `True` for the wire path to actually replay (canonical OpenAI
|
||||
gpt-5*/o-series and Anthropic Claude entries set it; unknown / local-
|
||||
server models default to `False`).
|
||||
|
||||
Three reasoning paths are recognised:
|
||||
|
||||
| Path | Provider | Capture | Persist | Replay |
|
||||
|------|----------|---------|---------|--------|
|
||||
| 1 | Anthropic Messages API | `thinking_delta` | `provider_blocks` (`type="thinking"`) | Verbatim via `_provider_content` |
|
||||
| 2 | OpenAI Responses (gpt-5*, o-series) | `response.reasoning_text.delta` events | `provider_blocks` (`type="reasoning"`) — only when `include=["reasoning.encrypted_content"]` | `ResponseReasoningItemParam` input items |
|
||||
| 3 | OpenAI Chat Completions (vLLM, llama.cpp, Gemini-compat) | `delta.reasoning_content` Pydantic extras | Synthetic `{type: "reasoning_text", text, source}` block stamped at end-of-stream | None — no API surface for replay on Chat Completions |
|
||||
|
||||
Cross-provider safety is enforced by `ANTHROPIC_VALID_BLOCK_TYPES` (a
|
||||
shape filter in `_anthropic.py:_convert_messages`): foreign blocks
|
||||
(OpenAI `reasoning`, synthetic `reasoning_text`) fall through to the
|
||||
text+tool_calls rebuild path rather than reaching Anthropic's input
|
||||
boundary as malformed content.
|
||||
|
||||
```toml
|
||||
[models.local]
|
||||
base_url = "http://localhost:8000/v1"
|
||||
model = "qwen3-32b"
|
||||
temperature = 0.7
|
||||
max_tokens = 8192
|
||||
|
||||
[models.o3]
|
||||
base_url = "https://api.openai.com/v1"
|
||||
api_key = "sk-..."
|
||||
model = "o3"
|
||||
reasoning_effort = "high"
|
||||
# temperature omitted — uses global default
|
||||
```
|
||||
|
||||
An optional `[models.*.capabilities]` sub-table overrides per-model
|
||||
`ModelCapabilities` flags (useful for local models whose capabilities
|
||||
cannot be detected programmatically):
|
||||
@@ -765,15 +709,9 @@ model = "qwen-3.5-vl"
|
||||
supports_vision = true
|
||||
```
|
||||
|
||||
**Database model definitions:** On server entry points, models can also be
|
||||
defined in the `model_definitions` table (admin Models tab). DB models support
|
||||
the same per-model sampling overrides. Config.toml models override DB models
|
||||
with the same alias in-memory (the DB rows are never modified).
|
||||
|
||||
**Lifecycle:**
|
||||
1. `load_model_registry()` loads DB model definitions (if storage available),
|
||||
then overlays `[models.*]` from config.toml, then builds a `"default"` entry
|
||||
from CLI `--base-url`/`--model`/`--api-key` args
|
||||
1. `load_model_registry()` reads `[models.*]` sections from config.toml and
|
||||
builds a `"default"` entry from CLI `--base-url`/`--model`/`--api-key` args
|
||||
2. The registry is passed to the session factory closure in both `cli.py` and
|
||||
`server.py`; each workstream resolves its model on creation
|
||||
3. `ModelRegistry.get_client()` lazily creates SDK client instances via
|
||||
@@ -782,11 +720,10 @@ with the same alias in-memory (the DB rows are never modified).
|
||||
4. `ModelRegistry.get_provider()` lazily creates `LLMProvider` instances via
|
||||
`create_provider()` (also cached and thread-safe)
|
||||
5. `/model` command shows available models; `/model <alias>` switches the
|
||||
active workstream's client, model, context window, and per-model sampling
|
||||
parameters
|
||||
active workstream's client, model, and context window
|
||||
6. `_create_stream_with_retry()` tries the primary model, then each fallback
|
||||
alias in order if the primary is unreachable
|
||||
7. `_run_agent()` resolves `registry.agent_model` (if set) for task
|
||||
7. `_run_agent()` resolves `registry.agent_model` (if set) for plan/task
|
||||
sub-agents, allowing a cheaper model for autonomous loops
|
||||
|
||||
**Per-workstream selection:** `POST /v1/api/workstreams/new` accepts an optional
|
||||
@@ -795,7 +732,7 @@ which can override the model before workstream creation.
|
||||
|
||||
### Tool Output Truncation
|
||||
|
||||
Tool execution results (bash, read_file, search) are truncated by
|
||||
Tool execution results (bash, read_file, search, math, man) are truncated by
|
||||
`_truncate_output()` when they exceed `tool_truncation` characters. Truncation
|
||||
preserves the first half and last half of the output, with a message in
|
||||
between:
|
||||
@@ -906,7 +843,7 @@ and are the single source of truth for both backends and Alembic migrations.
|
||||
| `update_workstream_title(ws_id, title)` | Set/update LLM-generated title |
|
||||
| `update_workstream_state(ws_id, state)` | Update workstream state and bump timestamp |
|
||||
| `update_workstream_name(ws_id, name)` | Update workstream display name |
|
||||
| `list_workstreams(node_id, limit, *, parent_ws_id, kind, user_id)` | List workstreams, optionally filtered by node, parent, kind, or owning user |
|
||||
| `list_workstreams(node_id, limit)` | List workstreams, optionally by node |
|
||||
| `kv_get(key)` / `kv_set(key, value)` / `kv_delete(key)` | Generic key-value store (backs memories table) |
|
||||
| `kv_list()` / `kv_search(query)` | List or search key-value pairs |
|
||||
| `search_history(query, limit)` | Full-text search (FTS5 on SQLite, tsvector on PostgreSQL) |
|
||||
@@ -1041,12 +978,11 @@ warns if the summary was truncated.
|
||||
unhandled promise rejections
|
||||
- **Pending approval across tab switches**: `WebUI._pending_approval` stores
|
||||
the `approve_request` event payload while the session is blocked waiting
|
||||
for user response. On tab switch / reconnect the pane reloads history via
|
||||
REST `GET /history` and then reconnects SSE; the live approval event is
|
||||
re-injected. The server-side `project_history_messages` projection marks
|
||||
the trailing orphan tool-call turn `"pending": true` so `replayHistory`
|
||||
skips the false `✓ approved` badge; the live approval UI is rendered by
|
||||
the re-injected event instead.
|
||||
for user response. On SSE reconnect (e.g., switching back to the tab),
|
||||
the event is re-injected after history replay. `_build_history` marks the
|
||||
pending tool call as `"pending": true` so `replayHistory` skips the
|
||||
false `✓ approved` badge; the live approval UI is rendered by the
|
||||
re-injected event instead.
|
||||
- **Browser history integration**: `history.pushState` is called in
|
||||
`switchTab()` with `{turnstone: 'workstream', wsId}`. The initial state is
|
||||
seeded with `history.replaceState({turnstone: 'dashboard'})` on load. The
|
||||
@@ -1123,8 +1059,8 @@ Three hierarchical scopes control endpoint access:
|
||||
| Scope | Grants | Endpoints |
|
||||
|-------|--------|-----------|
|
||||
| `read` | SSE streams, workstream listing, history | GET endpoints |
|
||||
| `write` | `read` + send, command, workstream create/close | POST to `/api/workstreams/{ws_id}/send`, `/api/command`, etc. |
|
||||
| `approve` | `write` + tool approval, admin operations | POST to `/api/workstreams/{ws_id}/approve`, `/api/admin/*` |
|
||||
| `write` | `read` + send, command, workstream create/close | POST to `/api/send`, `/api/command`, etc. |
|
||||
| `approve` | `write` + tool approval, admin operations | POST to `/api/approve`, `/api/admin/*` |
|
||||
|
||||
### Middleware Flow
|
||||
|
||||
@@ -1148,9 +1084,8 @@ Three hierarchical scopes control endpoint access:
|
||||
- **Console** is the auth management hub — it hosts the admin endpoints for
|
||||
creating users, issuing API tokens, and managing channel mappings. User
|
||||
records and token hashes live in the shared storage backend. The console
|
||||
dashboard includes an **admin panel** (18 tabs) for managing
|
||||
credentials, governance, MCP servers, models, node metadata, and runtime
|
||||
settings through the browser.
|
||||
dashboard includes an **admin panel** (14 tabs) for managing
|
||||
credentials, governance, MCP servers, and runtime settings through the browser.
|
||||
- **Server** is a JWT validator only — it validates tokens on each request but
|
||||
never creates users or tokens. Both processes share the same `jwt_secret`
|
||||
(via `TURNSTONE_JWT_SECRET` env var or `[auth].jwt_secret` config).
|
||||
@@ -1221,18 +1156,19 @@ stderr so it does not interfere with readline. Tool execution may use a
|
||||
Starlette ASGI app (served by uvicorn)
|
||||
|
|
||||
+-- Async request handlers (all under /v1/ prefix)
|
||||
| POST /v1/api/workstreams/{ws_id}/send -> starts worker thread per workstream
|
||||
| POST /v1/api/workstreams/{ws_id}/approve -> unblocks WebUI._approval_event
|
||||
| POST /v1/api/workstreams/new -> creates workstream + worker
|
||||
| GET /v1/api/workstreams/{ws_id}/events -> SSE via EventSourceResponse (per workstream)
|
||||
| GET /v1/api/events/global -> SSE via EventSourceResponse (fan-out)
|
||||
| POST /v1/api/send -> starts worker thread per workstream
|
||||
| POST /v1/api/approve -> unblocks WebUI._approval_event
|
||||
| POST /v1/api/plan -> unblocks WebUI._plan_event
|
||||
| POST /v1/api/workstreams/new -> creates workstream + worker
|
||||
| GET /v1/api/events -> SSE via EventSourceResponse (per workstream)
|
||||
| GET /v1/api/events/global -> SSE via EventSourceResponse (fan-out)
|
||||
|
|
||||
+-- ASGI middleware stack
|
||||
| MetricsMiddleware -> CORSMiddleware -> AuthMiddleware -> RateLimitMiddleware
|
||||
|
|
||||
+-- Worker thread per workstream (daemon)
|
||||
| Runs session.send() synchronously -- ChatSession is fully blocking
|
||||
| Blocks on WebUI._approval_event (threading.Event)
|
||||
| Blocks on WebUI._approval_event / _plan_event (threading.Event)
|
||||
|
|
||||
+-- Background daemon threads
|
||||
Global SSE fan-out: reads global_queue, copies to per-client queues
|
||||
@@ -1256,7 +1192,7 @@ registry).
|
||||
|
||||
Each workstream's `WebUI` has:
|
||||
- `_listeners` (per-client SSE queues, fan-out on `_enqueue()`)
|
||||
- `_approval_event` (`threading.Event` for blocking)
|
||||
- `_approval_event` / `_plan_event` (`threading.Event` for blocking)
|
||||
- `_global_queue` (class variable, shared, for state broadcasts)
|
||||
|
||||
The SSE handlers bridge these sync queues to async via
|
||||
@@ -1299,10 +1235,10 @@ Monitoring (2 daemon threads) Control + Proxy (async Starlette)
|
||||
| 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/workstreams/{ws_id}/events |
|
||||
| snapshot+deltas | | → SSE stream proxy |
|
||||
+------------------+ | POST /node/{id}/v1/api/workstreams/{ws_id}/send |
|
||||
| → forwarded to server |
|
||||
| /events/global | | GET /node/{id}/v1/api/events |
|
||||
| snapshot+deltas | | → SSE stream proxy |
|
||||
+------------------+ | POST /node/{id}/v1/api/send |
|
||||
| → forwarded to server |
|
||||
+----------------------------+
|
||||
```
|
||||
|
||||
@@ -1384,10 +1320,9 @@ setup, auth headers, `_request()` (REST) and `_stream_sse()` (SSE). Sync
|
||||
clients delegate through `_SyncRunner` which maintains a persistent background
|
||||
event loop on a daemon thread.
|
||||
|
||||
**Event types**: 38 standalone dataclasses in `events.py` with a type-registry
|
||||
dispatch (`from_json()` on each event). Events are decoupled from server
|
||||
internals — the SDK parses SSE frames directly from the `/v1/api/events`
|
||||
streams.
|
||||
**Event types**: 27 standalone dataclasses in `events.py` with a type-registry
|
||||
pattern matching `OutboundEvent.from_json()` from `mq/protocol.py`. Events are
|
||||
decoupled from server internals.
|
||||
|
||||
**TypeScript SDK**: `sdk/typescript/` — separate npm package with the same API
|
||||
surface. Zero browser dependencies, SSE via `fetch` + `ReadableStream` parsing.
|
||||
@@ -1409,8 +1344,7 @@ with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
> See also: [Channel Integrations guide](channels.md)
|
||||
|
||||
The `turnstone-channel` gateway connects external messaging platforms
|
||||
(Discord and Slack today, with an adapter protocol for future platforms) to
|
||||
the turnstone cluster via HTTP. Each
|
||||
(Discord, Slack, Teams) to the turnstone cluster via HTTP. Each
|
||||
platform adapter implements the `ChannelAdapter` protocol and translates
|
||||
between platform-native events and turnstone server API calls.
|
||||
|
||||
@@ -1423,7 +1357,7 @@ workstream is reactivated, the router uses atomic resume via the
|
||||
the old workstream's conversation during creation in a single HTTP
|
||||
request, eliminating ordering fragility.
|
||||
|
||||
Discord and Slack adapters ship today. See [channels.md](channels.md) for
|
||||
Discord ships as the first adapter. See [channels.md](channels.md) for
|
||||
setup instructions, configuration reference, and the adapter development
|
||||
guide.
|
||||
|
||||
@@ -1444,11 +1378,11 @@ retries up to 3 times with backoff, re-querying the service registry on
|
||||
each attempt. See [Notification Flow diagram](diagrams/png/17-notify-flow.png).
|
||||
|
||||
**Bidirectional replies:** When a user replies to a notification DM, the
|
||||
channel adapter (Discord or Slack) looks up the originating `ws_id` from the
|
||||
tracked message ID, verifies the replying user matches the notification
|
||||
recipient, and routes the reply to the workstream via `router.send_message()`.
|
||||
The workstream's response is forwarded back to the DM via a temporary entry
|
||||
in `_notify_reply_channels`. On `TurnCompleteEvent`, the response message is
|
||||
Discord bot looks up the originating `ws_id` from the tracked message ID,
|
||||
verifies the replying user matches the notification recipient, and routes
|
||||
the reply to the workstream via `router.send_message()`. The workstream's
|
||||
response is forwarded back to the DM via a temporary entry in
|
||||
`_notify_reply_channels`. On `TurnCompleteEvent`, the response message is
|
||||
itself tracked for further replies, enabling multi-turn DM conversations
|
||||
without requiring the user to open the web UI. Tracking entries are capped
|
||||
at 100 (FIFO eviction) and cleaned up on workstream close.
|
||||
@@ -1481,10 +1415,11 @@ and workstreams record which skill and version spawned them. Token budget
|
||||
enforcement tracks consumption in `session.send()` with 80% warning and
|
||||
100% approval gate via the `__budget_override__` synthetic tool name.
|
||||
|
||||
The console admin panel exposes these capabilities as 18 permission-gated
|
||||
tabs: Users, API Tokens, Channels, Schedules, Watches, Roles, Policies,
|
||||
Prompts, Judge, Skills, MCP Servers, Usage, Audit, Memories, Models, Nodes,
|
||||
Settings, and TLS.
|
||||
The console admin panel adds 5 governance tabs (Roles, Policies, Skills,
|
||||
Usage, Audit), a Memories tab, a Settings tab (form-based editor for all
|
||||
ConfigStore settings), and an MCP Servers tab (database-backed server
|
||||
definitions with live connection status and cluster-wide reload) for a
|
||||
total of 13 tabs, all permission-gated.
|
||||
Both Python and TypeScript SDKs expose governance methods on the console
|
||||
client.
|
||||
|
||||
@@ -1512,7 +1447,8 @@ implemented in `turnstone/core/judge.py`:
|
||||
The judge is session-scoped (`IntentJudge`), lazy-initialized on first
|
||||
approval, and configured via the `[judge]` config section or `--judge` CLI
|
||||
flags. By default it uses self-consistency (same model), but supports
|
||||
cross-model and cross-provider configurations. Task sub-agents are exempt. All verdicts are persisted to the `intent_verdicts` table
|
||||
cross-model and cross-provider configurations. Sub-agents (plan, task)
|
||||
are exempt. All verdicts are persisted to the `intent_verdicts` table
|
||||
(migration 012) with the user's final decision, enabling future calibration.
|
||||
The console exposes `GET /v1/api/admin/verdicts` for audit queries
|
||||
(requires `admin.judge` permission).
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5d500479d3be2363d4f594042a27e2ef5e2974750f580f6c4037a1fe85868ed9
|
||||
size 251904
|
||||
oid sha256:75c1832b6079e8628f4bbf4ce98d37880c4de133636b7555e3869990b046ddc6
|
||||
size 567704
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
# Bulk endpoint shape contract
|
||||
|
||||
Turnstone exposes several endpoints and tool calls that take multiple
|
||||
ids and return a per-id outcome. Over the last few phases two
|
||||
**distinct** response shapes have settled, one per semantic category.
|
||||
This doc codifies both so a future endpoint author can pick the right
|
||||
shape by semantics instead of by coin-flip.
|
||||
|
||||
Existing bulk endpoints at time of writing:
|
||||
|
||||
| Endpoint / tool | Category | Response shape |
|
||||
|---------------------------------------------------------|--------------------------|------------------------------------------|
|
||||
| `GET /v1/api/cluster/ws/live?ids=a,b,c` | bulk read | `{results, denied, truncated}` |
|
||||
| model tool `spawn_batch` | bulk create (per-item) | `{results, denied}` |
|
||||
| `POST /v1/api/workstreams/{ws_id}/stop_cascade` | cascade mutation | `{cancelled, failed, skipped}` |
|
||||
| `POST /v1/api/workstreams/{ws_id}/close_all_children` | cascade mutation | `{closed, failed, skipped}` |
|
||||
|
||||
---
|
||||
|
||||
## Why two shapes
|
||||
|
||||
The ask-to-outcome mapping is fundamentally different between the
|
||||
two categories, and a one-size-fits-all envelope ends up papering
|
||||
over distinctions the caller genuinely needs to branch on.
|
||||
|
||||
**Bulk read / bulk create-with-payload.** Each input id (or batch
|
||||
index) carries a *request-side* concept — "give me the live block
|
||||
for this ws_id" or "spawn a child with this spec" — and each
|
||||
successful output carries a *payload* — the live block, or the new
|
||||
workstream's identifying triple. The interesting distinction on
|
||||
failure is *ownership / validation* (caller can't see that id, spec
|
||||
was malformed) — independent of the storage state.
|
||||
|
||||
**Cascade mutation.** The action is uniform across every id (cancel
|
||||
this subtree, close this child). The interesting distinctions on
|
||||
outcome are *did it reach the terminal state?* (succeeded / already
|
||||
was there / the dispatch itself failed) — driven by the storage
|
||||
state plus transport reliability, not by the caller's input.
|
||||
|
||||
Trying to unify these forces either:
|
||||
|
||||
- a stateless `denied` bucket that has to carry "already gone"
|
||||
*and* "you don't have permission" *and* "transport failed" with a
|
||||
separate reason string — reviewers end up string-matching to branch.
|
||||
- or a per-item-payload map for cascade mutations where every
|
||||
successful value is the same sentinel — carrier with no payload.
|
||||
|
||||
So: two shapes, one per category. The rest of this doc spells out
|
||||
each.
|
||||
|
||||
---
|
||||
|
||||
## Shape A — bulk read / bulk create-with-payload
|
||||
|
||||
```json
|
||||
{
|
||||
"results": { "<key>": <value-or-null>, ... },
|
||||
"denied": [ "<key>", ... ],
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
**`results`** is a key-indexed map of the positive-path payload.
|
||||
The key is the input id for read endpoints (`cluster/ws/live` uses
|
||||
the ws_id), or the input-array index (stringified) for create
|
||||
endpoints that want ordering preserved (`spawn_batch` uses `"0"`,
|
||||
`"1"`, ...). The value is whatever the endpoint produces per
|
||||
success — a live block, a `{ws_id, name, node_id, status}` triple,
|
||||
etc. A `null` value (read endpoints only) means "the id existed and
|
||||
you own it, but the live block wasn't available" — distinct from
|
||||
"denied".
|
||||
|
||||
**`denied`** is the negative-path list. For read endpoints it's a
|
||||
flat list of ids (preserves input order so callers can re-zip
|
||||
against their input). For create endpoints with per-item payloads
|
||||
it's a list of `{idx, reason}` objects (`spawn_batch`'s validation
|
||||
and spawn-error rows; also the operator-reject surface when per-item
|
||||
selective-deny ships). Include every reason that's *not* the
|
||||
positive path — authz, ownership, validation, already-consumed,
|
||||
spawn failure — so callers don't branch on status codes.
|
||||
|
||||
**`truncated`** is a boolean set to `true` when the server's
|
||||
per-endpoint input cap was exceeded and the tail was dropped. The
|
||||
endpoint docs each spell out the cap (50 for `cluster/ws/live`).
|
||||
`spawn_batch` hard-errors on overflow instead of silently
|
||||
truncating — it omits the field entirely rather than carry a
|
||||
permanently-false flag.
|
||||
|
||||
### Example — `cluster/ws/live`
|
||||
|
||||
```http
|
||||
GET /v1/api/cluster/ws/live?ids=a1b2,c3d4,nonexistent,foreign HTTP/1.1
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"results": {
|
||||
"a1b2": {"state": "running", "tokens": 12843, "activity": "..."},
|
||||
"c3d4": null
|
||||
},
|
||||
"denied": ["nonexistent", "foreign"],
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
Callers that need ordered output zip their original id list against
|
||||
this map; ids in `denied` drop out of the zip cleanly. A live-block
|
||||
`null` doesn't route to `denied` — the row exists and the caller
|
||||
owns it; the node is just currently unreachable.
|
||||
|
||||
### Example — `spawn_batch`
|
||||
|
||||
This is the coordinator-tool result shape (the JSON the LLM receives),
|
||||
not an HTTP API response — the table above keys it under "model tool"
|
||||
to distinguish it from the `/v1/api/...` endpoints in the same table.
|
||||
The underlying HTTP spawn endpoint still returns `ws_id`; the tool
|
||||
result re-keys it to `child_ws_id` to defuse a coordinator-LLM recency
|
||||
bias (see `docs/coordinator-skills.md`).
|
||||
|
||||
```json
|
||||
{
|
||||
"results": {
|
||||
"0": {"child_ws_id": "d4e5f6...", "name": "csrf-audit", "node_id": "gpu-3"},
|
||||
"2": {"child_ws_id": "f1a2b3...", "name": "xss-audit", "node_id": "gpu-1"}
|
||||
},
|
||||
"denied": [
|
||||
{"idx": 1, "reason": "skill not found: nonexistent-skill"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Indexes are stringified to keep the envelope JSON-safe and
|
||||
consistently-typed across the read and create cases.
|
||||
|
||||
---
|
||||
|
||||
## Shape B — cascade mutation
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"<bucket>": [ "<ws_id>", ... ],
|
||||
"failed": [ "<ws_id>", ... ],
|
||||
"skipped": [ "<ws_id>", ... ]
|
||||
}
|
||||
```
|
||||
|
||||
Where `<bucket>` is the endpoint-specific name for "succeeded" —
|
||||
`cancelled` for `stop_cascade`, `closed` for `close_all_children`.
|
||||
The three buckets partition the input set exactly once:
|
||||
|
||||
| Bucket | Meaning |
|
||||
|---------------|-------------------------------------------------------------------------------|
|
||||
| `<bucket>` | Action dispatch accepted; target reached the intended terminal state. |
|
||||
| `failed` | Dispatch returned a non-404 error (transport issue, upstream 5xx, exception). |
|
||||
| `skipped` | Upstream 404 — stale registry entry, row already deleted, or peer gone. |
|
||||
|
||||
The split between `failed` and `skipped` is load-bearing. `failed`
|
||||
is actionable — the operator may want to retry, or the cascade may
|
||||
be partial. `skipped` is pre-resolved — the target is already in
|
||||
the terminal state the cascade was aiming at, so it's neither a
|
||||
win to report nor a fault to fix.
|
||||
|
||||
### Example — `stop_cascade`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"cancelled": ["child-1", "child-3"],
|
||||
"failed": [],
|
||||
"skipped": ["child-2"]
|
||||
}
|
||||
```
|
||||
|
||||
A subsequent retry would target only `failed` ids, not `skipped`
|
||||
ones — the latter are already done.
|
||||
|
||||
### Example — `close_all_children`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"closed": ["child-1", "child-3"],
|
||||
"failed": ["child-2"],
|
||||
"skipped": []
|
||||
}
|
||||
```
|
||||
|
||||
Same partition, different success-bucket name. When `coord_client`
|
||||
is unavailable (session loaded but no HTTP client attached — a
|
||||
construction bug) every id goes to `failed` so the operator notices
|
||||
rather than getting a silent all-skipped response.
|
||||
|
||||
---
|
||||
|
||||
## Guidance for future bulk endpoints
|
||||
|
||||
1. **Pick by semantics, not by "what shape is nearby."**
|
||||
- Mutation that's uniform across ids + terminal-state outcome? →
|
||||
**Shape B** (cascade mutation).
|
||||
- Read or create where the input id carries payload, or where the
|
||||
denial axis is independent of storage state? → **Shape A**
|
||||
(bulk read / bulk create-with-payload).
|
||||
|
||||
2. **Cap the input.** Both shapes assume a bounded input — the
|
||||
server rejects or silently truncates past the cap. Document the
|
||||
cap in the endpoint's OpenAPI description. Shape A uses
|
||||
`truncated: true` on quiet truncation; Shape B hard-errors on
|
||||
overflow.
|
||||
|
||||
3. **Match existing bucket names for the same semantic.** Use
|
||||
`failed` and `skipped` verbatim in Shape B — the per-endpoint
|
||||
success bucket is the only slot that varies. Use `results` and
|
||||
`denied` verbatim in Shape A; the per-endpoint `<key>` /
|
||||
`<value>` types vary.
|
||||
|
||||
4. **Audit the verbose shape.** Both endpoints emit a corresponding
|
||||
audit event with the full before/after bucket lists — the SSE
|
||||
stream and the in-process response give live feedback, but a
|
||||
postmortem operator will read the audit row. Use
|
||||
`_emit_coord_audit` (coordinator-scoped) or `record_audit`
|
||||
directly; don't inline.
|
||||
|
||||
5. **Don't mix shapes within one endpoint.** If a bulk endpoint
|
||||
wants both partial-success creation AND per-item failure reasons
|
||||
(like `spawn_batch` with its `{idx, reason}` denial rows), that's
|
||||
Shape A with a richer denial element — not a blend with Shape B.
|
||||
|
||||
---
|
||||
|
||||
## History
|
||||
|
||||
- **Phase 6** shipped `cluster/ws/live` as the first Shape A endpoint
|
||||
(`{results, denied, truncated}`).
|
||||
- **Phase 7** shipped `stop_cascade` as the first Shape B endpoint
|
||||
(`{cancelled, failed, skipped}`).
|
||||
- **Phase 8 PR A** shipped `spawn_batch` (Shape A, keyed by idx) and
|
||||
`close_all_children` (Shape B, twin of `stop_cascade`), which
|
||||
crystallised the two-shape-per-semantic-category policy codified
|
||||
here.
|
||||
|
||||
Before adding a third shape, read this doc and argue for why the
|
||||
new surface doesn't fit either A or B. Two idioms in the cluster
|
||||
API is a finite operator tax; three is one too many.
|
||||
+32
-96
@@ -7,35 +7,31 @@ platform-native events (messages, button clicks, slash commands) into
|
||||
turnstone API calls, and renders workstream output back into the
|
||||
platform's UI.
|
||||
|
||||
Discord and Slack adapters ship today. The adapter protocol is designed
|
||||
so new platforms can be added with only a new package under
|
||||
`turnstone/channels/<platform>/`.
|
||||
Discord ships as the first adapter. The adapter protocol is designed for
|
||||
future Slack and Teams integrations.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Discord Gateway Slack (Socket Mode WebSocket)
|
||||
\ /
|
||||
v v
|
||||
turnstone-channel (one or more adapters)
|
||||
|
|
||||
v
|
||||
turnstone-server (direct HTTP)
|
||||
or
|
||||
turnstone-console (routing proxy, multi-node)
|
||||
Discord Gateway
|
||||
|
|
||||
v
|
||||
turnstone-channel (Discord adapter)
|
||||
|
|
||||
v
|
||||
turnstone-server (direct HTTP)
|
||||
or
|
||||
turnstone-console (routing proxy, multi-node)
|
||||
```
|
||||
|
||||
A single `turnstone-channel` process can run multiple adapters
|
||||
simultaneously (e.g. Discord + Slack) — pass the tokens for each
|
||||
platform you want to enable.
|
||||
|
||||
Key components:
|
||||
|
||||
- **ChannelAdapter protocol** (`turnstone/channels/_protocol.py`) — generic
|
||||
interface for any messaging platform. Defines `start()`, `stop()`,
|
||||
`send()`, and `send_notification()`.
|
||||
`send()`, `send_notification()`, `edit_message()`,
|
||||
`send_approval_request()`, `send_plan_review()`, and `create_thread()`.
|
||||
- **ChannelRouter** (`turnstone/channels/_routing.py`) — maps
|
||||
channel/thread IDs to turnstone workstream IDs. Handles workstream
|
||||
creation via HTTP, stale route detection, and user identity resolution.
|
||||
@@ -99,10 +95,10 @@ TURNSTONE_DISCORD_GUILD=123456789
|
||||
Then start the stack:
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
docker compose --profile production up
|
||||
```
|
||||
|
||||
The `channel` gateway runs by default; the Discord adapter activates once
|
||||
The `channel` service starts automatically when
|
||||
`TURNSTONE_DISCORD_TOKEN` is set.
|
||||
|
||||
### 3. Link User Accounts
|
||||
@@ -124,67 +120,6 @@ An admin can also force-link or unlink users via the console admin panel
|
||||
|
||||
---
|
||||
|
||||
## Slack Setup
|
||||
|
||||
Slack uses **Socket Mode**, so no public URL or API Gateway is required — Slack
|
||||
connects outbound to the bot via a WebSocket. Install with:
|
||||
|
||||
```bash
|
||||
pip install 'turnstone[slack]'
|
||||
```
|
||||
|
||||
### 1. Create a Slack App
|
||||
|
||||
1. Go to https://api.slack.com/apps and click **Create New App**
|
||||
2. Under **Settings > Socket Mode**, enable Socket Mode. This generates an
|
||||
**App-Level Token** (prefix `xapp-`) — copy it.
|
||||
3. Under **OAuth & Permissions**, add these **Bot Token Scopes**:
|
||||
`chat:write`, `chat:write.public`, `channels:history`, `im:history`,
|
||||
`groups:history`, `mpim:history`, `reactions:write`, `commands`
|
||||
4. Under **Event Subscriptions** (Socket Mode delivers events), subscribe
|
||||
to bot events: `message.channels`, `message.im`, `message.groups`
|
||||
5. Under **Slash Commands**, create a command (default `/turnstone`)
|
||||
6. Install the app to your workspace to generate the **Bot User OAuth
|
||||
Token** (prefix `xoxb-`).
|
||||
|
||||
### 2. Configure Turnstone
|
||||
|
||||
**Environment variables** (recommended for Docker):
|
||||
|
||||
```bash
|
||||
TURNSTONE_SLACK_TOKEN=xoxb-... # Bot User OAuth Token
|
||||
TURNSTONE_SLACK_APP_TOKEN=xapp-... # App-Level Token (Socket Mode)
|
||||
TURNSTONE_SLACK_CHANNELS= # optional, comma-separated channel IDs
|
||||
TURNSTONE_SLACK_SLASH_COMMAND=/turnstone
|
||||
```
|
||||
|
||||
**CLI flags** (bare-metal):
|
||||
|
||||
```bash
|
||||
turnstone-channel \
|
||||
--slack-token "xoxb-..." \
|
||||
--slack-app-token "xapp-..." \
|
||||
--slack-slash-command /turnstone \
|
||||
--server-url http://localhost:8080
|
||||
```
|
||||
|
||||
The Slack and Discord adapters can be enabled together — pass tokens for
|
||||
both and the gateway hosts both adapters in one process.
|
||||
|
||||
### 3. Usage
|
||||
|
||||
- **DM the bot**: messages sent directly to the bot create a workstream
|
||||
scoped to that DM; the slash command is not required.
|
||||
- **Slash command**: `/turnstone <message>` in any channel the bot can
|
||||
see starts a per-user channel session.
|
||||
- Tool approvals render as Slack **Block Kit** buttons; only the user
|
||||
who owns the workstream can approve/reject.
|
||||
- Notifications and reply routing work identically to Discord.
|
||||
- Session recovery: persisted channel routes are re-subscribed when the
|
||||
bot restarts, so existing Slack conversations keep flowing.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Conversations
|
||||
@@ -234,19 +169,24 @@ config, the bot auto-responds with approval and posts a
|
||||
field (useful for allowing specific tools like `bash` or `read_file` while
|
||||
still requiring manual approval for others).
|
||||
|
||||
### Plan Reviews
|
||||
|
||||
Plan review requests are displayed as a blue embed with:
|
||||
|
||||
- **Approve Plan** (green) button — approves the plan with empty feedback
|
||||
- **Request Changes** (gray) button — opens a modal for feedback text
|
||||
(up to 2000 characters)
|
||||
- Feedback is forwarded to the server via HTTP
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| CLI Flag | Env Var | Default | Description |
|
||||
|----------|---------|---------|-------------|
|
||||
| `--discord-token` | `TURNSTONE_DISCORD_TOKEN` | — | Discord bot token (required to enable Discord) |
|
||||
| `--discord-token` | `TURNSTONE_DISCORD_TOKEN` | — | Bot token (required to enable Discord) |
|
||||
| `--discord-guild` | — | `0` (all guilds) | Restrict to a single Discord guild |
|
||||
| `--discord-channels` | — | empty (all) | Comma-separated Discord channel IDs to allow |
|
||||
| `--slack-token` | `TURNSTONE_SLACK_TOKEN` | — | Slack Bot User OAuth token (`xoxb-…`, required to enable Slack) |
|
||||
| `--slack-app-token` | `TURNSTONE_SLACK_APP_TOKEN` | — | Slack App-Level token (`xapp-…`, required with `--slack-token`) |
|
||||
| `--slack-channels` | `TURNSTONE_SLACK_CHANNELS` | empty (all) | Comma-separated Slack channel IDs to allow |
|
||||
| `--slack-slash-command` | `TURNSTONE_SLACK_SLASH_COMMAND` | `/turnstone` | Slash command name registered in the Slack app |
|
||||
| `--discord-channels` | — | empty (all) | Comma-separated channel IDs to allow |
|
||||
| `--server-url` | `TURNSTONE_SERVER_URL` | `http://localhost:8080` | Server URL (single-node) |
|
||||
| `--console-url` | `TURNSTONE_CONSOLE_URL` | — | Console URL (multi-node routing proxy) |
|
||||
| `--model` | — | server default | Default model for new workstreams |
|
||||
@@ -256,9 +196,6 @@ still requiring manual approval for others).
|
||||
| `--log-level` | `TURNSTONE_LOG_LEVEL` | `INFO` | Log level |
|
||||
| `--log-format` | `TURNSTONE_LOG_FORMAT` | `auto` | Log format (`auto`/`json`/`text`) |
|
||||
|
||||
At least one of `--discord-token` or `--slack-token` must be supplied.
|
||||
Passing both starts both adapters in the same process.
|
||||
|
||||
---
|
||||
|
||||
## User Identity
|
||||
@@ -312,8 +249,8 @@ waiting for them to check in.
|
||||
Two modes:
|
||||
|
||||
- **Username** — provide a turnstone `username`. The gateway resolves
|
||||
it via the `channel_users` table and sends to every linked platform
|
||||
the user has (e.g. Discord + Slack).
|
||||
it via the `channel_users` table and sends to all linked channels
|
||||
(e.g. Discord + future Slack).
|
||||
- **Direct** — provide `channel_type` + `channel_id` to target a
|
||||
specific platform channel or user DM.
|
||||
|
||||
@@ -414,6 +351,10 @@ class ChannelAdapter(Protocol):
|
||||
async def stop(self) -> None: ...
|
||||
async def send(self, channel_id: str, content: str) -> str: ...
|
||||
async def send_notification(self, channel_id: str, content: str, ws_id: str) -> str: ...
|
||||
async def edit_message(self, channel_id: str, message_id: str, content: str) -> None: ...
|
||||
async def send_approval_request(self, channel_id: str, ws_id: str, correlation_id: str, items: list[dict]) -> None: ...
|
||||
async def send_plan_review(self, channel_id: str, ws_id: str, correlation_id: str, content: str) -> None: ...
|
||||
async def create_thread(self, parent_channel_id: str, name: str, message_id: str = "") -> str: ...
|
||||
```
|
||||
|
||||
`send_notification()` is like `send()` but associates the outgoing
|
||||
@@ -421,11 +362,6 @@ message with a `ws_id` so that user replies can be routed back to the
|
||||
originating workstream. Adapters must track the mapping from outgoing
|
||||
message ID to `(ws_id, target_user_id)` and handle DM replies.
|
||||
|
||||
Platform-specific concerns — approval prompts, message edits, thread
|
||||
creation — live inside the adapter implementation and are not part of
|
||||
the protocol surface. Each adapter drives those via its
|
||||
own `_on_ws_event` dispatcher using SDK-native APIs.
|
||||
|
||||
To add a new platform:
|
||||
|
||||
1. Create `turnstone/channels/<platform>/` package
|
||||
|
||||
+6
-15
@@ -334,7 +334,7 @@ The console reverse-proxies each node's server UI at `/node/{node_id}/`. This al
|
||||
|
||||
### URL Rewriting
|
||||
|
||||
The server UI uses root-relative URLs (`/v1/api/workstreams/{ws_id}/send`, `/static/app.js`, `/shared/base.css`, etc.). Since `<base>` tags cannot rewrite root-relative URLs, the console uses a JS shim approach:
|
||||
The server UI uses root-relative URLs (`/v1/api/send`, `/static/app.js`, `/shared/base.css`, etc.). Since `<base>` tags cannot rewrite root-relative URLs, the console uses a JS shim approach:
|
||||
|
||||
1. **HTML rewriting** — when serving `index.html`, replaces `href=` and `src=` references to both `/static/` and `/shared/` with the proxy prefix (`/node/{node_id}/static/` and `/node/{node_id}/shared/` respectively).
|
||||
|
||||
@@ -344,7 +344,7 @@ The server UI uses root-relative URLs (`/v1/api/workstreams/{ws_id}/send`, `/sta
|
||||
|
||||
### SSE Proxy
|
||||
|
||||
SSE streams (`/v1/api/workstreams/{ws_id}/events`, `/v1/api/events/global`) are proxied as raw byte passthrough — the console opens an `httpx.AsyncClient.stream()` to the upstream server (with `read=None` and `pool=None` timeouts since SSE connections are long-lived) and relays every byte via `StreamingResponse`. This preserves server-side ping comments, event framing, and keepalives verbatim without parsing or re-encoding.
|
||||
SSE streams (`/v1/api/events`, `/v1/api/events/global`) are proxied as raw byte passthrough — the console opens an `httpx.AsyncClient.stream()` to the upstream server (with `read=None` and `pool=None` timeouts since SSE connections are long-lived) and relays every byte via `StreamingResponse`. This preserves server-side ping comments, event framing, and keepalives verbatim without parsing or re-encoding.
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -396,19 +396,10 @@ The browser maintains a local `clusterState` object that mirrors the cluster sna
|
||||
|
||||
Accessed via the "admin" button in the header (visible when authenticated
|
||||
with `approve` scope). Provides user, API token, channel link, MCP server,
|
||||
and skill management with 18 tabs (Users, API Tokens, Channels, Schedules,
|
||||
Watches, Roles, Policies, Prompts, Judge, Skills, MCP Servers, Usage,
|
||||
Audit, Memories, Models, Nodes, Settings, TLS). See also
|
||||
[Governance](governance.md) for the Roles, Policies, Skills, Usage, and
|
||||
Audit tabs, and [Settings](settings.md) for the database-backed
|
||||
configuration editor.
|
||||
|
||||
The **Channels** tab links users to either a Discord or Slack account
|
||||
via a per-row channel-type selector. The **Models** tab is a CRUD
|
||||
editor for `model_definitions`, the **Nodes** tab edits per-node
|
||||
metadata, and the **TLS** tab manages CA and leaf certificates for the
|
||||
internal mTLS fabric. The **Settings** tab edits ConfigStore values
|
||||
live; edits apply without restart.
|
||||
and skill management with 13 tabs (see also
|
||||
[Governance](governance.md) for
|
||||
the Roles, Policies, Skills, Usage, and Audit tabs, and
|
||||
[Settings](settings.md) for the database-backed configuration editor):
|
||||
|
||||
**Users tab:**
|
||||
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
# Coordinator API tour
|
||||
|
||||
Turnstone's **coordinator workstream** is a session hosted on the
|
||||
console whose job is to orchestrate other workstreams. It runs an LLM
|
||||
that can spawn child workstreams on any node, watch their progress,
|
||||
wait for them to finish, steer them mid-flight, and tear them down.
|
||||
This doc walks the full lifecycle — one request, one response, and the
|
||||
relevant SSE events at each step.
|
||||
|
||||
Aimed at integrators driving a coordinator from a custom UI or SDK
|
||||
without reverse-engineering the built-in console page. The shapes
|
||||
here match the live OpenAPI spec served at `/openapi.json` and
|
||||
rendered at `/docs` on every `turnstone-console` process. Every
|
||||
step references the operation id from that spec so doc updates track
|
||||
schema changes.
|
||||
|
||||
> **Auth throughout.** Every endpoint below sits behind bearer-token
|
||||
> auth and the `admin.coordinator` permission. A session-scoped JWT
|
||||
> is minted per login (see [docs/oidc.md](oidc.md) / [docs/security.md](security.md));
|
||||
> a service token may call the read paths but destructive governance
|
||||
> paths (`/restrict`, `/stop_cascade`, `/close_all_children`) require
|
||||
> the explicit `admin.coordinator` grant — a service-token owner
|
||||
> match isn't enough.
|
||||
|
||||
---
|
||||
|
||||
## The 9 steps
|
||||
|
||||
> **URL convergence (1.5.0).** Pre-1.5 coord-only endpoints lived
|
||||
> under `/v1/api/coordinator/...`. The Stage 2 verb-shape lift
|
||||
> consolidated coord and interactive onto the unified
|
||||
> `/v1/api/workstreams/{ws_id}/<verb>` tree; coord still distinguishes
|
||||
> itself via the `kind=coordinator` row classifier rather than a
|
||||
> separate URL space. The endpoints below reflect the post-lift
|
||||
> surface served by `turnstone-console`.
|
||||
|
||||
| # | Action | Operation |
|
||||
|---|------------------------------|-------------------------------------------------------------|
|
||||
| 1 | Create | `POST /v1/api/workstreams/new` |
|
||||
| 2 | Subscribe to events | `GET /v1/api/workstreams/{ws_id}/events` (SSE) |
|
||||
| 3 | Send a user message | `POST /v1/api/workstreams/{ws_id}/send` |
|
||||
| 4 | Inspect children | `GET /v1/api/workstreams/{ws_id}/children` |
|
||||
| 5 | Inspect one workstream | `GET /v1/api/cluster/ws/{ws_id}/detail` |
|
||||
| 6 | Wait for fan-out | model-side tool `wait_for_workstream` |
|
||||
| 7 | Govern | `POST /v1/api/workstreams/{ws_id}/trust` |
|
||||
| | | `POST /v1/api/workstreams/{ws_id}/restrict` |
|
||||
| | | `POST /v1/api/workstreams/{ws_id}/stop_cascade` |
|
||||
| | | `POST /v1/api/workstreams/{ws_id}/close_all_children` |
|
||||
| 8 | Approve / cancel | `POST /v1/api/workstreams/{ws_id}/approve` |
|
||||
| | | `POST /v1/api/workstreams/{ws_id}/cancel` |
|
||||
| 9 | Close | `POST /v1/api/workstreams/{ws_id}/close` |
|
||||
|
||||
Refer to `/openapi.json` (Swagger UI at `/docs`) on any
|
||||
`turnstone-console` process for the authoritative operation ids and
|
||||
schemas. Coordinator-only verbs (`/children`, `/trust`, `/restrict`,
|
||||
`/stop_cascade`, `/close_all_children`) 404 against `kind=interactive`
|
||||
rows; the shared verbs (`/send`, `/approve`, `/cancel`, `/events`,
|
||||
`/history`, `/open`, `/close`, etc.) work on both kinds.
|
||||
|
||||
---
|
||||
|
||||
## 1. Create a coordinator
|
||||
|
||||
```http
|
||||
POST /v1/api/workstreams/new
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <token>
|
||||
|
||||
{
|
||||
"name": "release-coord",
|
||||
"skill": "engineer-orchestrator",
|
||||
"initial_message": "audit /auth for CSRF handling across all active routes"
|
||||
}
|
||||
```
|
||||
|
||||
```http
|
||||
HTTP/1.1 201 Created
|
||||
Content-Type: application/json
|
||||
|
||||
{"ws_id": "a1b2c3d4e5f6...", "name": "release-coord"}
|
||||
```
|
||||
|
||||
All three body fields are optional — an empty body still creates a
|
||||
coordinator with an auto-generated name and no initial message.
|
||||
Returns **503** with a remediation message when the cluster isn't
|
||||
configured with a coordinator model; see
|
||||
[`coordinator.model_alias`](settings.md) to set one.
|
||||
|
||||
**SSE implication:** the `ws_created` event fires on the cluster-wide
|
||||
stream (`/v1/api/cluster/events`) once the row is committed. Per-ws
|
||||
subscribers (step 2) see the session warm up as token traffic starts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Subscribe to the per-coordinator event stream
|
||||
|
||||
```http
|
||||
GET /v1/api/workstreams/{ws_id}/events HTTP/1.1
|
||||
Accept: text/event-stream
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
One persistent SSE connection per browser tab / SDK caller — the
|
||||
console fans each event out to every listener queue (cap 500 events
|
||||
per queue, put_nowait drop on overflow). Events come in flat JSON
|
||||
with a `type` field. The recurring shapes a UI has to handle:
|
||||
|
||||
| `type` | Emitted when | Payload highlights |
|
||||
|---------------------|--------------------------------------------------------------------------------------------|--------------------|
|
||||
| `thinking_start` / `thinking_stop` | Model has entered / exited a reasoning block | — |
|
||||
| `reasoning` | Reasoning-token stream chunk (when the model exposes it) | `text` |
|
||||
| `content` | Assistant-content stream chunk | `text` |
|
||||
| `stream_end` | End of a single provider stream | — |
|
||||
| `tool_result` | A tool call completed (success or error) | `call_id`, `name`, `output`, `is_error?` |
|
||||
| `tool_output_chunk` | Streaming tool output (e.g. long bash command) | `call_id`, `chunk` |
|
||||
| `approve_request` | One or more tool calls need operator approval | `items: [{call_id, header, preview, func_name, approval_label, needs_approval}]` |
|
||||
| `approval_resolved` | Operator answered the approval prompt | `approved`, `feedback` |
|
||||
| `state_change` | Worker-thread state transition (also re-emitted with the current state on every fresh subscribe so refresh-mid-stream restores composer mode) | `state` ∈ `running`, `thinking`, `attention`, `idle`, `error` |
|
||||
| `in_progress_snapshot` | One-shot replay of the in-progress turn's content + reasoning when this client connects mid-stream | `content`, `reasoning` |
|
||||
| `status` | Token usage + context-window snapshot (fires on every streaming tick) | `prompt_tokens`, `completion_tokens`, `total_tokens`, `context_window`, `pct`, `effort`, `cache_creation_tokens`, `cache_read_tokens` |
|
||||
| `rename` | Session's display name changed | `name` |
|
||||
| `intent_verdict` | Intent judge produced a verdict on a pending tool call | `risk_level`, `recommendation`, `reasons` |
|
||||
| `output_warning` | Output guard flagged a tool result | `call_id`, `risk_level`, `flags` |
|
||||
| `child_ws_created` | A direct child of this coord was just created (fan-out from the cluster bus) | `child_ws_id`, `node_id`, `name`, `parent_ws_id` (`ws_id` in the envelope is always the coord's own id) |
|
||||
| `child_ws_state` | A direct child transitioned state | `child_ws_id`, `state` |
|
||||
| `child_ws_closed` | A direct child closed | `child_ws_id` |
|
||||
| `child_ws_rename` | A direct child's name changed | `child_ws_id`, `name` |
|
||||
| `wait_started` / `wait_progress` / `wait_ended` | `wait_for_workstream` tool lifecycle (see §6) | `call_id`, `ws_ids`, `elapsed`, `results`, `complete` |
|
||||
| `batch_started` / `batch_ended` | `spawn_batch` / `close_all_children` tool lifecycle | `call_id`, `op`, `total`/`succeeded`/`denied`/`closed`/`failed`/`skipped` |
|
||||
| `info` / `error` | Operational messages | `message` |
|
||||
|
||||
**Reconnection contract:** a freshly-opened SSE connection receives
|
||||
the current snapshot of any pending tool approval (`approve_request`
|
||||
is re-sent if unresolved), any in-flight `wait_*` / `batch_*`
|
||||
indicator, the worker's current `state_change`, and an
|
||||
`in_progress_snapshot` carrying any partial content / reasoning the
|
||||
model has produced for the in-progress turn — so a tab refresh
|
||||
mid-approval, mid-tool-execution, or mid-stream restores both the
|
||||
correct composer mode and the partial assistant text without waiting
|
||||
for the response to complete.
|
||||
|
||||
---
|
||||
|
||||
## 3. Send the first user message
|
||||
|
||||
```http
|
||||
POST /v1/api/workstreams/{ws_id}/send
|
||||
Content-Type: application/json
|
||||
|
||||
{"message": "audit /auth for CSRF handling across all active routes"}
|
||||
```
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
The message is queued for the worker thread at its next tool-result
|
||||
seam (so you can send follow-ups mid-conversation without corrupting
|
||||
the in-progress turn). On the SSE stream you'll see `state_change`
|
||||
→ `thinking_start` → streaming `reasoning` / `content` / `tool_result`
|
||||
events, finishing with `state_change → idle` or an
|
||||
`approve_request` when the model invokes a gated tool.
|
||||
|
||||
---
|
||||
|
||||
## 4. Inspect direct children
|
||||
|
||||
```http
|
||||
GET /v1/api/workstreams/{ws_id}/children HTTP/1.1
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"ws_id": "d4e5f6...", "name": "csrf-audit", "state": "running", "node_id": "gpu-3"},
|
||||
{"ws_id": "e1f2a3...", "name": "xss-audit", "state": "idle", "node_id": "gpu-1"}
|
||||
],
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
The response key is `items`, not `children` — the endpoint shape
|
||||
follows the cluster-wide workstream-list idiom rather than the
|
||||
coordinator `list_workstreams` tool's (which uses `children`).
|
||||
Rows include every state stored for the parent (`running`, `idle`,
|
||||
`closed`, ...); the endpoint does not accept a state query param,
|
||||
so clients should inspect each row's `state` field and filter
|
||||
locally if they want to hide closed/deleted children. Nested
|
||||
coordinator rows are dropped server-side so only interactive
|
||||
descendants appear.
|
||||
|
||||
---
|
||||
|
||||
## 5. Inspect one workstream (storage + live block + tail)
|
||||
|
||||
```http
|
||||
GET /v1/api/cluster/ws/{ws_id}/detail?message_limit=20 HTTP/1.1
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"persisted": { "ws_id": "...", "state": "running", "parent_ws_id": "...", "kind": "interactive", ... },
|
||||
"live": { "state": "thinking", "tokens": 12843, "activity": "...", "pending_approval": null },
|
||||
"tail": [ {"role": "assistant", "content": "...", "tokens": 128}, ... ]
|
||||
}
|
||||
```
|
||||
|
||||
Works for any workstream the caller has `admin.cluster.inspect` on,
|
||||
not just children of a single coordinator — useful for a cluster
|
||||
admin panel watching multiple coordinators at once. `live` is
|
||||
`null` when the owning node is unreachable or has dropped the row
|
||||
from its dashboard cache; callers should degrade gracefully, not
|
||||
treat it as an error.
|
||||
|
||||
For fan-out views, prefer
|
||||
[`GET /v1/api/cluster/ws/live?ids=a,b,c`](bulk-endpoints.md) — it
|
||||
collapses N per-row round-trips into one, returning the live block
|
||||
for every id in a `{results, denied, truncated}` envelope.
|
||||
|
||||
---
|
||||
|
||||
## 6. Wait for fan-out (`wait_for_workstream`)
|
||||
|
||||
`wait_for_workstream` is a **model-side tool**, not an HTTP endpoint
|
||||
— the coordinator's LLM invokes it with a list of child ws_ids, the
|
||||
session's worker thread blocks inside the tool, and a sequence of
|
||||
`wait_started` / `wait_progress` / `wait_ended` SSE events is emitted
|
||||
for the UI to drive a "waiting on N children" indicator.
|
||||
|
||||

|
||||
|
||||
Key properties:
|
||||
|
||||
- **Caps** — up to 32 ws_ids per call, up to 600 seconds per call.
|
||||
A coordinator that needs to wait on more children re-invokes the
|
||||
tool with a fresh timeout.
|
||||
- **Modes** — `mode="any"` returns as soon as one child reaches a
|
||||
real terminal state (`idle` / `error` / `closed` / `deleted`);
|
||||
`mode="all"` waits for every polled child to reach a real
|
||||
terminal state.
|
||||
- **Progress throttling** — the poll loop runs every 500 ms but the
|
||||
SSE emission is diff-on-state-change plus a 5-second heartbeat. A
|
||||
600 s wait generates O(dozens) of progress events, not 1200.
|
||||
- **Unresolvable ids** — ws_ids are validated up front (exactly
|
||||
32 hex chars; copy them verbatim): a malformed id fails the call
|
||||
immediately with did-you-mean suggestions and a roster of the
|
||||
coord's children. An id the caller doesn't own, a missing row, or
|
||||
a child hard-deleted mid-wait is reported as `state="not_found"`
|
||||
and aborts the wait on the tick that observes it (top-level
|
||||
`error` / `not_found` / `children` fields, `complete=false`) — the
|
||||
LLM should fix the id and re-issue, not conclude the child died.
|
||||
Foreign and missing collapse into one shape, so the wait can't be
|
||||
used as an existence oracle.
|
||||
|
||||
Prefer `wait_for_workstream` over polling `inspect_workstream` in a
|
||||
loop — a wait consumes one assistant turn regardless of how long the
|
||||
children take, whereas each `inspect_workstream` poll costs a full
|
||||
turn (plus judge, plus tokens). On a fan-out of 3+ children this
|
||||
rounds to a 10× token-efficiency win.
|
||||
|
||||
---
|
||||
|
||||
## 7. Governance — trust, restrict, stop_cascade, close_all_children
|
||||
|
||||
These four endpoints let an operator steer a live coordinator session
|
||||
mid-flight. All four emit an audit event tagged
|
||||
`coordinator.<action>` via the dedicated audit executor so a cascade
|
||||
burst can't starve audit writes.
|
||||
|
||||
### `POST /trust` — auto-approve own-subtree sends
|
||||
|
||||
```http
|
||||
POST /v1/api/workstreams/{ws_id}/trust
|
||||
{"send": true}
|
||||
```
|
||||
|
||||
Flips `trust_send=true` on the live session. Subsequent
|
||||
`send_to_workstream` calls that target a ws_id in the coordinator's
|
||||
own subtree skip the approval prompt; foreign ws_ids and other tool
|
||||
calls still go through the normal flow. Requires both
|
||||
`admin.coordinator` AND `coordinator.trust.send` permissions (the
|
||||
second grants a service token the opt-in it otherwise wouldn't get).
|
||||
|
||||
### `POST /restrict` — revoke tool access mid-session
|
||||
|
||||
```http
|
||||
POST /v1/api/workstreams/{ws_id}/restrict
|
||||
{"revoke": ["spawn_workstream", "delete_workstream"]}
|
||||
```
|
||||
|
||||
Unions the names into the session's revoked-tools set. Additive and
|
||||
idempotent — calling twice with overlapping lists converges to the
|
||||
union. Revocations don't survive a session close/reopen; operators
|
||||
opt in per session. Cap 256 tool names per request, 128 chars each.
|
||||
|
||||
### `POST /stop_cascade` — cancel the subtree
|
||||
|
||||
```http
|
||||
POST /v1/api/workstreams/{ws_id}/stop_cascade
|
||||
{}
|
||||
```
|
||||
|
||||
Cancels the coordinator's in-flight generation AND dispatches
|
||||
`cancel_workstream` through the routing proxy for every direct
|
||||
child in the in-memory registry. Returns:
|
||||
|
||||
```json
|
||||
{"status": "ok", "cancelled": ["child-1", "child-3"], "failed": [], "skipped": ["child-2"]}
|
||||
```
|
||||
|
||||
Response uses the [cascade-mutation bulk shape](bulk-endpoints.md):
|
||||
`cancelled` = accepted, `failed` = dispatch error worth retrying,
|
||||
`skipped` = upstream 404 (already gone — stale registry entry or
|
||||
the row was deleted between snapshot and dispatch). Grandchildren
|
||||
aren't touched directly; they sit behind their parent's cancel and
|
||||
propagate via the child's SSE stream.
|
||||
|
||||
### `POST /close_all_children` — soft-close the direct fan-out
|
||||
|
||||
```http
|
||||
POST /v1/api/workstreams/{ws_id}/close_all_children
|
||||
{"reason": "audit round complete"}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{"status": "ok", "closed": ["c-1", "c-2"], "failed": [], "skipped": []}
|
||||
```
|
||||
|
||||
Soft-close cascade bounded by the same semaphore as `stop_cascade`.
|
||||
The `reason` (up to 512 chars) propagates into each closed child's
|
||||
audit + `workstream_config` for postmortem. Unlike `stop_cascade`
|
||||
this does NOT recurse into grandchildren — the model-facing tool
|
||||
that pairs with this endpoint asks for a bounded teardown of the
|
||||
coordinator's own fan-out. For a full-subtree teardown, use
|
||||
`stop_cascade`.
|
||||
|
||||
See [bulk-endpoints.md](bulk-endpoints.md) for why both endpoints
|
||||
share the cascade-mutation shape and how it differs from the
|
||||
`spawn_batch` / `cluster/ws/live` shape.
|
||||
|
||||
---
|
||||
|
||||
## 8. Approve / cancel
|
||||
|
||||
The `approve` endpoint is what resolves an `approve_request` SSE
|
||||
event. The coordinator's worker thread is blocked inside
|
||||
`ui.approve_tools` waiting for this POST.
|
||||
|
||||
```http
|
||||
POST /v1/api/workstreams/{ws_id}/approve
|
||||
{"approved": true, "feedback": null, "always": false}
|
||||
{"approved": false, "feedback": "spawn count looks too high — try 3 not 10"}
|
||||
{"approved": true, "feedback": null, "always": true} // always-approve this tool name
|
||||
```
|
||||
|
||||
`cancel` drops the in-flight generation but leaves the coordinator
|
||||
idle and open for a fresh `send`:
|
||||
|
||||
```http
|
||||
POST /v1/api/workstreams/{ws_id}/cancel
|
||||
{}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Close
|
||||
|
||||
```http
|
||||
POST /v1/api/workstreams/{ws_id}/close
|
||||
{}
|
||||
```
|
||||
|
||||
Soft-closes the session — state persists, children keep running (use
|
||||
`close_all_children` or `stop_cascade` first to wind them down), the
|
||||
worker thread exits, SSE streams send a final `stream_end` and
|
||||
disconnect. The row is reopenable via
|
||||
`POST /v1/api/workstreams/{ws_id}/open` so long as it hasn't been
|
||||
deleted.
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [coordinator-skills.md](coordinator-skills.md) — writing a skill
|
||||
that runs on a coordinator session (orchestrator persona,
|
||||
workflow patterns, `SkillKind` classifier).
|
||||
- [bulk-endpoints.md](bulk-endpoints.md) — the two bulk-shape
|
||||
idioms (`{results, denied, truncated}` vs
|
||||
`{<bucket>, failed, skipped}`) used by `cluster/ws/live`,
|
||||
`spawn_batch`, `stop_cascade`, and `close_all_children`.
|
||||
- [architecture.md](architecture.md) — cluster-wide architecture
|
||||
including how coordinator sessions fit next to node-hosted
|
||||
interactive workstreams.
|
||||
- The live OpenAPI spec (`/openapi.json` on any console process)
|
||||
and Swagger UI (`/docs`) — authoritative schemas for every
|
||||
endpoint above.
|
||||
@@ -1,358 +0,0 @@
|
||||
# Writing a coordinator-specific skill
|
||||
|
||||
Skills are prompt-level personas that steer a Turnstone session
|
||||
toward a narrow task. Most skills target **interactive** sessions —
|
||||
the single-workstream "do this thing" surface where the model wields
|
||||
`bash`, `edit_file`, `web_fetch`, and the rest of the maker toolset.
|
||||
|
||||
A **coordinator skill** is different. It runs on a session whose job
|
||||
is to orchestrate other sessions. The toolset is smaller and
|
||||
narrower, the persona is an orchestrator instead of a maker, and the
|
||||
success metric is "did the plan resolve" instead of "did the code
|
||||
compile". This doc covers the differences a skill author has to
|
||||
care about.
|
||||
|
||||
---
|
||||
|
||||
## `kind` — authored audience metadata
|
||||
|
||||
A row in `prompt_templates` carries a `kind` column (see
|
||||
[`turnstone/core/skill_kind.py`](../turnstone/core/skill_kind.py);
|
||||
migration 044 added the column). Three values:
|
||||
|
||||
| `SkillKind` enum | Stored as | Meaning |
|
||||
|-------------------------|-----------------|----------------------------------------------------------------------------|
|
||||
| `SkillKind.INTERACTIVE` | `"interactive"` | Authored for the interactive maker persona (single-workstream "do this"). |
|
||||
| `SkillKind.COORDINATOR` | `"coordinator"` | Authored for the orchestrator persona (delegate, monitor, synthesise). |
|
||||
| `SkillKind.ANY` | `"any"` | Either surface (or audience-neutral). Default on create. |
|
||||
|
||||
The `kind` field is a `StrEnum` — drop-in `str` compatible — so DB
|
||||
rows, JSON payloads, and `==` comparisons all work without translation
|
||||
at the edge.
|
||||
|
||||
**`kind` is metadata, not an enforcement boundary.** The model can
|
||||
`skills(action='find')` across every kind from any session, `get` any
|
||||
row by name, and `load` any visible skill regardless of session kind.
|
||||
Actual runtime capability is gated by `allowed_tools` + `auto_approve`
|
||||
on the skill and the operator's approval card on every `load` /
|
||||
`spawn_workstream(skill=...)` decision — `kind` doesn't add or remove
|
||||
any of that. It's a sorting / grouping / search-narrowing hint.
|
||||
|
||||
The opt-in filter is on `skills(action='find', kind='coordinator')`
|
||||
(or `'interactive'`) — pass it when you want to narrow a catalog
|
||||
browse to a specific authored audience. Omitting it (or passing
|
||||
`kind='any'`) returns the full catalog. When supplied, the storage
|
||||
filter widens to `[<kind>, 'any']` so audience-neutral rows remain
|
||||
visible inside the narrowed view.
|
||||
|
||||
**Tagging a new skill as coordinator-targeted** — set `kind` to
|
||||
`SkillKind.COORDINATOR` (or the literal `"coordinator"`) when you
|
||||
`skills(action='create', kind='coordinator', ...)` or POST to
|
||||
`/v1/api/admin/skills`. Use this to signal intent to other skill
|
||||
authors and to make the orchestrator-targeted catalog easy to
|
||||
browse — not to hide the skill from interactive sessions. Existing
|
||||
rows default to `SkillKind.ANY`; bump them to `COORDINATOR` if
|
||||
you've rewritten the prompt around the orchestrator toolset and
|
||||
want the kind filter to surface them as such.
|
||||
|
||||
---
|
||||
|
||||
## Tool surface differences
|
||||
|
||||
Coordinator sessions receive a **fixed** tool set, defined in
|
||||
`turnstone/core/tools.py` as `COORDINATOR_TOOLS`. Nothing a skill
|
||||
or MCP config can do adds to it. Current members:
|
||||
|
||||
| Tool | Category | Notes |
|
||||
|---------------------------|-----------------|---------------------------------------------------------------------|
|
||||
| `spawn_workstream` | delegate | Create one child. Requires approval. |
|
||||
| `spawn_batch` | delegate | Create up to 10 children in one approval. Partial-success shape. |
|
||||
| `inspect_workstream` | observe | Read state + tail of one child. Auto-approved (no mutation). |
|
||||
| `list_workstreams` | observe | List the direct children (same shape as `/children` endpoint). |
|
||||
| `wait_for_workstream` | block | Block until one/all listed children hit a terminal state. |
|
||||
| `send_to_workstream` | steer | Queue a follow-up message to a running child. |
|
||||
| `close_workstream` | wind-down | Soft-close one child. Requires approval. |
|
||||
| `close_all_children` | wind-down | Soft-close every direct child in one approval. Partial-success shape. |
|
||||
| `cancel_workstream` | wind-down | Drop the in-flight generation; leaves child idle for a fresh send. |
|
||||
| `delete_workstream` | wind-down | Hard-delete one child. Requires approval. |
|
||||
| `list_nodes` | discover | Enumerate live cluster nodes + capabilities. |
|
||||
| `skills` (action=find) | discover | Browse the skill catalog; opt-in `kind` filter narrows by audience. |
|
||||
| `memory` | persist | Orchestration scratchpad keyed by the `coordinator` scope. |
|
||||
| `notify` | broadcast | Post a status update to a human channel at a narrative beat. |
|
||||
| `tasks` | plan | Orchestrator-only scratchpad. Children don't see it. |
|
||||
|
||||
Explicitly **not** in the coordinator set:
|
||||
|
||||
- `bash` / `edit_file` / `write_file` / `append_file` / `diff_file` — no local FS.
|
||||
- `read_file` / `search` — no local FS reads.
|
||||
- `web_fetch` / `web_search` — no direct web access.
|
||||
- `task_agent` — sub-agent tool is zeroed on coord sessions.
|
||||
- `recall` / `watch` / `read_resource` / `use_prompt` — UX / persistence tools that belong to interactive sessions. The dual-kind `memory` / `skills` / `notify` tools are available on both kinds (see the table above).
|
||||
|
||||
If your skill needs a coordinator to "run a command" or "read a
|
||||
file", write the delegate pattern instead: spawn a child with an
|
||||
appropriate skill, `wait_for_workstream`, then `inspect_workstream`
|
||||
for the output. The coordinator stays the orchestrator.
|
||||
|
||||
---
|
||||
|
||||
## Persona differences
|
||||
|
||||
Interactive skills compose on top of `base_interactive.md` — a
|
||||
"maker" persona: get the work done, use the tools, edit the code,
|
||||
close the loop.
|
||||
|
||||
Coordinator skills compose on top of
|
||||
[`base_coordinator.md`](../turnstone/prompts/base_coordinator.md) —
|
||||
an "orchestrator" persona: decompose, delegate, monitor, synthesise.
|
||||
The base text is short but sets the tone every coordinator skill
|
||||
inherits:
|
||||
|
||||
> You are a coordinator on a small, focused infrastructure team.
|
||||
> Your role is to orchestrate work across the cluster... You do
|
||||
> not edit files, run shell commands, browse the web, or manipulate
|
||||
> the codebase directly. Children do that.
|
||||
|
||||
Write your skill's system prompt to *add* task-specific orchestration
|
||||
hints on top — don't re-explain the role, don't paste tool JSON,
|
||||
don't try to override the "no direct action" contract. Keep the
|
||||
additions to: (a) the specific kind of work this skill delegates;
|
||||
(b) the preferred skill tags for children; (c) the synthesis shape
|
||||
the skill should end on.
|
||||
|
||||
---
|
||||
|
||||
## `tasks` integration
|
||||
|
||||
`tasks` is the coordinator's scratchpad — a persisted, ordered
|
||||
list of rows with fields `{id, title, status, child_ws_id, created,
|
||||
updated}` that only this coordinator sees. Children don't see it;
|
||||
the user does via the sidebar. Five actions: `add`, `update`,
|
||||
`remove`, `reorder`, `list` (only `list` is auto-approved; the
|
||||
mutators go through the approval flow).
|
||||
|
||||
The input schema refers to rows by `task_id`; the persisted row
|
||||
object exposes the same id as `id`. The `child_ws_id` field is a
|
||||
free-form label the skill sets to link a task to a spawned
|
||||
workstream — it is NOT validated against the workstreams table, so
|
||||
a skill can set it to a placeholder before `spawn_workstream`
|
||||
returns or keep it pointing at a closed child for later audit.
|
||||
|
||||
A skill's initial prompt can seed the task list by calling
|
||||
`tasks(action="add", title=...)` as its very first tool calls —
|
||||
the user gets a visible plan before any child is spawned, and the
|
||||
coordinator's future self has something concrete to iterate on.
|
||||
Status transitions (`pending` → `in_progress` → `done` / `blocked`)
|
||||
are the skill's main feedback loop: mutate the task when the child
|
||||
covering it finishes, not when the child starts. Use
|
||||
`tasks(action="update", task_id=..., child_ws_id=<ws_id>)` to
|
||||
link a task to the child that owns it once spawn returns.
|
||||
|
||||
A final gotcha: parallel tool dispatch does NOT serialise reads
|
||||
after writes in the same batch. If a skill issues an `update` and
|
||||
a `list` in one parallel tool batch, the `list` response may reflect
|
||||
the pre-update state. Dispatch mutate and list serially (one
|
||||
tool_use turn each) when the list must observe the mutation.
|
||||
|
||||
Keep the tasks coarse-grained — one per child, roughly. A 20-task
|
||||
list for a 3-child fan-out is noise; a 1-task list for a 5-child
|
||||
fan-out loses the plan. The sidebar renders tasks as the operator's
|
||||
mental model of "what the coord thinks it's doing".
|
||||
|
||||
---
|
||||
|
||||
## Referencing children by `ws_id`
|
||||
|
||||
Every ws_id returned by `spawn_workstream` / `spawn_batch` is a
|
||||
**full 32-char hex string**. The skill's system prompt must not
|
||||
invent ws_ids — a model that hallucinates `"child-1"` or `"ws-abc"`
|
||||
hits the tenant guard in `CoordinatorClient._is_own_subtree`, which
|
||||
validates ws_id against `parent_ws_id=coord_ws_id` AND
|
||||
`user_id=owner` in storage. The rejection shape is uniform and
|
||||
recovery-oriented:
|
||||
|
||||
- **Mutating ops** (`send_to_workstream`, `close_workstream`,
|
||||
`cancel_workstream`, `delete_workstream`) and
|
||||
**`inspect_workstream`** return
|
||||
`{"error": "no workstream matching '<ref>' among your children; …",
|
||||
"status": 404, "ws_id": "<ref>", "did_you_mean": [...],
|
||||
"children": [...], "children_truncated": bool}` — a did-you-mean
|
||||
(edit distance ≤ 3 against the coord's own children, which catches
|
||||
the garbled-hex incident class: a 32-char id whose `aaa` run
|
||||
collapsed to `a`) plus a roster of the coord's children. A ref
|
||||
that matches a child's display NAME is called out explicitly with
|
||||
the right id (names are mutable labels, not addresses). Foreign
|
||||
and nonexistent ids produce the same payload (no existence
|
||||
oracle), every hint references only the coord's own children, and
|
||||
near-miss ids are never auto-resolved — the skill should fix the
|
||||
id and re-issue, not treat the child as dead.
|
||||
- **`wait_for_workstream`** validates ids before waiting: a
|
||||
malformed id fails the whole call immediately (`invalid_ws_ids`
|
||||
carries the per-id payloads above, `elapsed=0`); a well-formed id
|
||||
that is foreign, nonexistent, or hard-deleted mid-wait surfaces as
|
||||
`state="not_found"` and aborts the wait on that tick with
|
||||
top-level `error` / `not_found` / `children` fields.
|
||||
`complete=true` therefore means every polled lane really finished
|
||||
— an unobservable id can neither burn the timeout nor ride along
|
||||
to a "complete" result.
|
||||
|
||||
Pattern: capture each spawn result in the next tool call's input.
|
||||
The JSON tool-result carries `{"child_ws_id": "...", "name": "...",
|
||||
"node_id": "...", "routing_strategy": "..."}`; the model should
|
||||
extract the `child_ws_id` and pass it as `ws_id` (or in the `ws_ids`
|
||||
list) to `inspect_workstream` / `wait_for_workstream` /
|
||||
`send_to_workstream` / `close_workstream` verbatim. The asymmetry
|
||||
— spawn returns `child_ws_id` but the other tools accept `ws_id` /
|
||||
`ws_ids` — is intentional: it defuses a coordinator-LLM recency
|
||||
bias where seeing `ws_id` in a spawn return primed re-spawn loops
|
||||
instead of progression to the wait phase.
|
||||
|
||||
A UI that wants human-readable identifiers should render the `name`
|
||||
field and keep the workstream id as the click-through key — note
|
||||
that the id *value* is the same regardless of whether it arrived
|
||||
under the `child_ws_id` key (spawn return) or the `ws_id` key
|
||||
(every other tool's input/output); only the field name differs.
|
||||
|
||||
---
|
||||
|
||||
## `wait_for_workstream` vs `inspect_workstream`
|
||||
|
||||
Two distinct semantics, different cost profiles:
|
||||
|
||||
- **`wait_for_workstream(ws_ids=[...], timeout=60, mode="any")`** —
|
||||
blocks inside a single tool call until one (or all, for `mode="all"`)
|
||||
of the listed children reaches a terminal state (`idle`, `error`,
|
||||
`closed`, `deleted`). The worker thread blocks up to `timeout`
|
||||
seconds; the assistant turn remains a single round-trip regardless
|
||||
of how long the wait actually takes. Prefer this for "the plan
|
||||
needs child X to finish before the next step."
|
||||
- **`inspect_workstream(ws_id=...)`** — single read of the child's
|
||||
state + tail. Costs a full assistant turn (judge, tokens, stream).
|
||||
Prefer this for "what does the final message say?" after the child
|
||||
has already resolved (via `wait_for_workstream` or a known
|
||||
transition).
|
||||
|
||||
Rule of thumb: wait once for a fan-out, then inspect once per
|
||||
child for the content. A loop of inspect-every-few-seconds is a
|
||||
token-burning antipattern — on 3+ children it rounds to a 10×
|
||||
efficiency hit over a wait+inspect pair.
|
||||
|
||||
---
|
||||
|
||||
## Common coordinator patterns
|
||||
|
||||
Three patterns cover most coordinator skills. Pick the one that
|
||||
matches the task, or combine them deliberately.
|
||||
|
||||
### Pattern 1 — delegate-and-summarise
|
||||
|
||||
One specialist child, one focused brief, one synthesis message back
|
||||
to the user. Appropriate when the user's request is "run the thing
|
||||
and tell me what happened" and the work fits in one workstream.
|
||||
|
||||
```
|
||||
tasks(action='add', title='audit /auth for CSRF')
|
||||
spawn_workstream(skill='engineer', initial_message='audit /auth ...')
|
||||
wait_for_workstream(ws_ids=[<child>], timeout=300)
|
||||
inspect_workstream(ws_id=<child>)
|
||||
→ synthesise the final message into a user-facing response
|
||||
tasks(action='update', task_id='t_01', status='done')
|
||||
close_workstream(ws_id=<child>, reason='audit complete')
|
||||
```
|
||||
|
||||
### Pattern 2 — fan-out-and-synthesise
|
||||
|
||||
N children running in parallel, each with a distinct brief, all
|
||||
waited-on together, then synthesised. Appropriate when the user's
|
||||
request naturally decomposes into independent subtasks.
|
||||
|
||||
```
|
||||
tasks seeds:
|
||||
t_01 benchmark Anthropic 4.7 latency on summarisation
|
||||
t_02 benchmark OpenAI GPT-5.2 latency on summarisation
|
||||
t_03 benchmark Gemini 2.5 latency on summarisation
|
||||
spawn_batch(children=[...3 briefs...])
|
||||
wait_for_workstream(ws_ids=[c1, c2, c3], mode='all', timeout=600)
|
||||
inspect_workstream(ws_id=c1); ...(c2); ...(c3)
|
||||
→ synthesise head-to-head comparison
|
||||
tasks → all done
|
||||
close_all_children(reason='benchmark complete')
|
||||
```
|
||||
|
||||
Prefer `spawn_batch` over 3 individual `spawn_workstream` calls —
|
||||
one approval instead of three, one audit trail, deterministic
|
||||
sibling ordering. Pair with `wait_for_workstream(mode='all')` and
|
||||
`close_all_children(reason=...)` to wind the fan-out down in one
|
||||
approval each.
|
||||
|
||||
### Pattern 3 — plan-then-delegate
|
||||
|
||||
The coordinator first uses its own reasoning to carve the plan,
|
||||
records it in `tasks`, then spawns children that each own one
|
||||
task. Appropriate when the user's request is "figure out how to X"
|
||||
and the coordinator's planning step is itself valuable.
|
||||
|
||||
```
|
||||
→ coord reasons about the shape of the work
|
||||
tasks(action='add', title='...') × N # the plan, visible in the sidebar
|
||||
for task in tasks:
|
||||
spawn_workstream(skill=..., initial_message=task.brief)
|
||||
tasks(action='update', task_id=task.id, notes='ws=<child_ws_id>')
|
||||
wait_for_workstream(ws_ids=[...], mode='all', timeout=...)
|
||||
for child in children:
|
||||
inspect_workstream(ws_id=child)
|
||||
tasks(action='update', task_id=..., status='done', notes='result summary')
|
||||
→ synthesise
|
||||
```
|
||||
|
||||
The key distinction from Pattern 2: the plan is an artifact the user
|
||||
can see and interact with (via the sidebar). If the coordinator's
|
||||
reasoning-pass was wrong about the decomposition, the user can
|
||||
course-correct before any child runs.
|
||||
|
||||
---
|
||||
|
||||
## Testing a coordinator skill
|
||||
|
||||
Coordinator sessions are hosted on the console, not on a node.
|
||||
Integration tests that drive a real coord session live under
|
||||
`tests/test_coordinator_end_to_end.py` — they spin a console with
|
||||
an in-memory SQLite backend and a fake upstream node, then drive
|
||||
the session through its HTTP surface.
|
||||
|
||||
For a new coordinator skill:
|
||||
|
||||
1. Write the skill prompt as a string and pass it to the
|
||||
`coord_session` fixture's `skill=` kwarg (see
|
||||
`tests/test_coordinator_tools.py` for the pattern).
|
||||
2. Build a small fake cluster: one node + two children via
|
||||
the `_seed_children` helper in `tests/_coord_test_helpers.py`
|
||||
(``_seed_children(mgr._adapter, coord.id, ["child-1", "child-2"])``).
|
||||
3. Drive the session with seeded tool_call dicts matching the
|
||||
provider layer's shape. The unit-level tests in
|
||||
`tests/test_coordinator_tools.py` show the helper (`_tc(name,
|
||||
args, call_id)`).
|
||||
4. Assert the skill's decision shape — which tools fire in what
|
||||
order, what the tasks looks like at the end, which
|
||||
`_error` reasons appear on the denied-path.
|
||||
|
||||
A full end-to-end test isn't required for every skill; a
|
||||
prepare-step unit test that asserts "given this initial message, the
|
||||
first tool call is X with Y args" is usually sufficient to catch
|
||||
persona drift without a real LLM in the loop.
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [coordinator-api-tour.md](coordinator-api-tour.md) — the HTTP
|
||||
surface every coordinator skill indirectly drives.
|
||||
- [bulk-endpoints.md](bulk-endpoints.md) — the response shape
|
||||
`spawn_batch` and `close_all_children` use, so your skill can
|
||||
parse results / denied arrays correctly.
|
||||
- [governance.md](governance.md) — the broader governance surface
|
||||
(`/trust`, `/restrict`, `/stop_cascade`, role-based permissions)
|
||||
that wraps every coord session.
|
||||
- [settings.md](settings.md) — `coordinator.model_alias` and
|
||||
`coordinator.reasoning_effort` settings that gate which LLM runs
|
||||
the coordinator session at all.
|
||||
@@ -1,27 +1,34 @@
|
||||
# Consistent Hash Ring — Reference Design
|
||||
|
||||
**Status**: Reference — alternative routing strategy
|
||||
**Status**: Reference (not currently in the hot path)
|
||||
**Date**: 2026-03-30
|
||||
|
||||
Live routing uses **rendezvous (HRW) hashing** in
|
||||
`turnstone/core/rendezvous.py` and `turnstone/console/router.py`. This
|
||||
document captures a vnode-ring approach as a reference for future
|
||||
evaluation if the cluster outgrows rendezvous's O(N)-per-route
|
||||
characteristic.
|
||||
## Overview
|
||||
|
||||
The FNV-1a-32 hash function specified below is bit-identical to the
|
||||
hash used by the live rendezvous implementation; cross-language clients
|
||||
can rely on these test vectors.
|
||||
This document describes a consistent hash ring algorithm evaluated during
|
||||
the design of the direct HTTP transport routing system. The current
|
||||
implementation uses weight-proportional bucket assignment with a
|
||||
donor/recipient rebalancing algorithm (see `direct-http-transport.md`).
|
||||
The consistent hash ring is documented here as a reference for future
|
||||
scalability work — if the cluster grows beyond the point where the
|
||||
weight-proportional approach is sufficient, the ring provides a
|
||||
proven alternative with stronger stability guarantees.
|
||||
|
||||
## When the ring approach becomes interesting
|
||||
## When to consider the ring approach
|
||||
|
||||
The vnode ring becomes preferable to rendezvous hashing when:
|
||||
The current weight-proportional seeding + donor/recipient rebalancer works
|
||||
well when:
|
||||
- Cluster size is moderate (< 50 nodes)
|
||||
- Nodes join/leave infrequently
|
||||
- The rebalancer runs centrally (in the console)
|
||||
|
||||
- Cluster size grows large (50+ nodes) and the per-route O(N) hash
|
||||
computation becomes visible against downstream HTTP cost.
|
||||
- Decentralised routing is needed (each node computes the ring locally,
|
||||
no central console required).
|
||||
- A precomputed flat-array lookup is desired so the routing hot path
|
||||
avoids hashing entirely.
|
||||
The consistent hash ring becomes advantageous when:
|
||||
- Cluster size grows large (50+ nodes) and frequent membership changes
|
||||
cause the donor/recipient algorithm to churn
|
||||
- Decentralized routing is needed (each node computes the ring locally,
|
||||
no central console required)
|
||||
- Cross-language determinism is important (multiple implementations must
|
||||
agree on the same assignment without sharing state)
|
||||
|
||||
## Algorithm
|
||||
|
||||
@@ -126,17 +133,16 @@ class HashRing:
|
||||
# Precompute all 65536 bucket assignments
|
||||
```
|
||||
|
||||
## Comparison with rendezvous (HRW) hashing
|
||||
## Comparison with current approach
|
||||
|
||||
| Aspect | Rendezvous (live) | Consistent hash ring (this doc) |
|
||||
|--------|-------------------|---------------------------------|
|
||||
| Per-route cost | O(N) hash computes | O(log V) bisect against precomputed array |
|
||||
| Seeding | None — pure function | Build vnode array on every membership change |
|
||||
| Node addition | Pure function moves ~1/N keys | Ring moves ~1/N buckets |
|
||||
| Node removal | Surviving nodes' keys unchanged | Surviving nodes' buckets unchanged |
|
||||
| Decentralised | Yes — pure function over services | Yes — each node computes locally |
|
||||
| Persistent state | None | None on the hot path; precomputed array in memory |
|
||||
| Complexity | ~20 LOC | Virtual-node construction + bisect |
|
||||
| Aspect | Weight-proportional (current) | Consistent hash ring |
|
||||
|--------|------------------------------|---------------------|
|
||||
| Seeding | Exact weight split, deterministic | Hash-based, ~3% variance |
|
||||
| Node addition | Donor/recipient moves only excess | Ring moves ~1/N buckets |
|
||||
| Node removal | Dead buckets → most underloaded | Ring redistributes to clockwise neighbors |
|
||||
| Cross-node churn | Zero (only donor→recipient) | Zero (ring stability guarantee) |
|
||||
| Decentralized | No (needs central rebalancer) | Yes (each node computes locally) |
|
||||
| Complexity | Simple weight arithmetic | Virtual node construction + bisect |
|
||||
|
||||
## Test vectors
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ eval --> sqlite : SQLite
|
||||
|
||||
console --> server : HTTP proxy\n(hash-ring bucket lookup,\nproxy /node/{id}/* traffic)
|
||||
|
||||
channel --> server : HTTP + SSE\n(POST /v1/api/workstreams/{ws_id}/send,\nGET /v1/api/workstreams/{ws_id}/events)
|
||||
channel --> server : HTTP + SSE\n(POST /v1/api/send,\nGET /v1/api/events)
|
||||
|
||||
' Notes
|
||||
note right of console
|
||||
|
||||
@@ -19,8 +19,7 @@ package "Entry Points" <<Rectangle>> {
|
||||
component [cli.py\nturnstone] as cli <<entry>>
|
||||
component [server.py\nturnstone-server] as server <<entry>>
|
||||
component [eval.py\nturnstone-eval] as eval <<entry>>
|
||||
component [admin.py\nturnstone-admin] as admin <<entry>>
|
||||
component [bootstrap.py\nturnstone-bootstrap] as bootstrap <<entry>>
|
||||
component [chat.py\n(re-exports)] as chat <<entry>>
|
||||
}
|
||||
|
||||
' Core engine
|
||||
@@ -34,12 +33,13 @@ package "turnstone/core/" <<Rectangle>> {
|
||||
component [metrics.py\nPrometheus metrics] as metrics <<core>>
|
||||
component [config.py\nTOML config] as config <<core>>
|
||||
component [safety.py\nPath validation] as safety <<core>>
|
||||
component [sandbox.py\nCommand sandbox] as sandbox <<core>>
|
||||
component [edit.py\nFile editing] as edit <<core>>
|
||||
component [web.py\nWeb helpers] as web <<core>>
|
||||
component [auth.py\nAuthentication] as auth <<core>>
|
||||
component [healthcheck.py\nBackendHealthMonitor] as healthcheck <<core>>
|
||||
component [ratelimit.py\nRateLimiter] as ratelimit <<core>>
|
||||
component [mcp_client.py\nMCPClientManager\n(push + manual refresh)] as mcp <<core>>
|
||||
component [mcp_client.py\nMCPClientManager\n(push + periodic refresh)] as mcp <<core>>
|
||||
component [tool_search.py\nToolSearchManager, BM25] as toolsearch <<core>>
|
||||
component [model_registry.py\nModelRegistry] as registry <<core>>
|
||||
}
|
||||
@@ -48,8 +48,7 @@ package "turnstone/core/" <<Rectangle>> {
|
||||
package "turnstone/channels/" <<Rectangle>> {
|
||||
component [_routing.py\nChannelRouter] as router <<channel>>
|
||||
component [discord/bot.py\nDiscordBot] as discordbot <<channel>>
|
||||
component [slack/bot.py\nSlackBot (Socket Mode)] as slackbot <<channel>>
|
||||
component [cli.py\nturnstone-channel] as gateway <<channel>>
|
||||
component [gateway.py\nturnstone-channel] as gateway <<channel>>
|
||||
}
|
||||
|
||||
' Console
|
||||
@@ -113,8 +112,7 @@ eval --> memory
|
||||
eval --> config
|
||||
eval --> tools
|
||||
|
||||
admin --> auth
|
||||
bootstrap --> providers
|
||||
chat --> session
|
||||
|
||||
' Core internal deps
|
||||
session --> providers
|
||||
@@ -122,6 +120,7 @@ session --> tools
|
||||
session --> memory
|
||||
memory --> storage
|
||||
session --> safety
|
||||
session --> sandbox
|
||||
session --> edit
|
||||
session --> web
|
||||
session --> healthcheck
|
||||
@@ -136,10 +135,8 @@ tools --> schemas
|
||||
|
||||
' Channel dependencies
|
||||
gateway --> discordbot
|
||||
gateway --> slackbot
|
||||
gateway --> router
|
||||
discordbot --> sdkserver : HTTP + SSE
|
||||
slackbot --> sdkserver : HTTP + SSE
|
||||
router --> storage : channel_routes
|
||||
|
||||
' Console dependencies
|
||||
|
||||
@@ -15,6 +15,7 @@ interface "SessionUI" as SessionUI <<Protocol>> {
|
||||
+ on_tool_result(call_id: str, name: str, output: str, *, is_error: bool = False)
|
||||
+ on_tool_output_chunk(call_id: str, chunk: str)
|
||||
+ on_status(usage: dict, ctx_window: int, effort: str)
|
||||
+ on_plan_review(content: str) → str
|
||||
+ on_info(message: str)
|
||||
+ on_error(message: str)
|
||||
+ on_state_change(state: str)
|
||||
@@ -42,13 +43,15 @@ class "WorkstreamTerminalUI" as WsTermUI {
|
||||
class "WebUI" as WebUI {
|
||||
- _listeners: list[Queue]
|
||||
- _approval_event: Event
|
||||
- _plan_event: Event
|
||||
- _ws_prompt_tokens: int
|
||||
- _ws_tool_calls: dict
|
||||
+ resolve_approval(approved, feedback)
|
||||
+ resolve_plan(feedback)
|
||||
--
|
||||
Enqueues JSON events for SSE.
|
||||
Blocks on threading.Event for
|
||||
approval.
|
||||
approval/plan review.
|
||||
SSE handlers bridge Queue to
|
||||
async via run_in_executor().
|
||||
--
|
||||
@@ -66,10 +69,9 @@ class "NullUI" as NullUI {
|
||||
interface "LLMProvider" as LLMProvider <<Protocol>> {
|
||||
+ provider_name: str {property}
|
||||
+ get_capabilities(model) → ModelCapabilities
|
||||
+ create_streaming(client, model, messages, ..., replay_reasoning_to_model) → Iterator[StreamChunk]
|
||||
+ create_completion(client, model, messages, ..., replay_reasoning_to_model) → CompletionResult
|
||||
+ create_streaming(client, model, messages, ...) → Iterator[StreamChunk]
|
||||
+ create_completion(client, model, messages, ...) → CompletionResult
|
||||
+ convert_tools(tools) → list[dict]
|
||||
+ extract_reasoning_text(provider_blocks) → str
|
||||
+ retryable_error_names: frozenset[str] {property}
|
||||
--
|
||||
core/providers/_protocol.py
|
||||
@@ -124,7 +126,6 @@ class "ModelCapabilities" as ModelCaps <<frozen>> {
|
||||
+ supports_web_search: bool
|
||||
+ supports_tool_search: bool
|
||||
+ supports_vision: bool
|
||||
+ supports_reasoning_replay: bool
|
||||
}
|
||||
|
||||
' ChatSession
|
||||
@@ -142,6 +143,7 @@ class "ChatSession" as ChatSession {
|
||||
+ model_alias: str | None {property}
|
||||
- _tools: list[dict]
|
||||
- _task_tools: list[dict]
|
||||
- _agent_tools: list[dict]
|
||||
- _read_files: set[str]
|
||||
- system_messages: list[dict]
|
||||
--
|
||||
@@ -251,7 +253,7 @@ class "MCPClientManager" as MCPMgr {
|
||||
Background asyncio event loop
|
||||
bridges async MCP SDK to
|
||||
sync ChatSession dispatch.
|
||||
Push + manual refresh.
|
||||
Push + periodic + manual refresh.
|
||||
Resources + prompts discovered
|
||||
alongside tools at startup.
|
||||
--
|
||||
@@ -293,7 +295,7 @@ class "ModelRegistry" as ModelReg {
|
||||
--
|
||||
Thread-safe lazy client + provider
|
||||
creation. Loaded by load_model_registry()
|
||||
from DB + [models.*] config + CLI args.
|
||||
from CLI args + [models.*] config.
|
||||
--
|
||||
core/model_registry.py
|
||||
}
|
||||
@@ -304,9 +306,6 @@ class "ModelConfig" as ModelCfg <<frozen>> {
|
||||
+ base_url: str
|
||||
+ model: str
|
||||
+ context_window: int
|
||||
+ temperature: float | None
|
||||
+ max_tokens: int | None
|
||||
+ reasoning_effort: str | None
|
||||
}
|
||||
|
||||
' Circuit breaker state
|
||||
|
||||
@@ -24,14 +24,6 @@ CS -> DB : save_message(ws_id, "user", input)
|
||||
|
||||
group loop [while tool_calls present]
|
||||
|
||||
CS -> UI : on_turn_start()
|
||||
note right of UI
|
||||
SessionUIBase resets the per-turn inflight
|
||||
buffers (_ws_inflight_content / reasoning /
|
||||
seq) that fuel the SSE in_progress_snapshot
|
||||
event for mid-stream refresh resume.
|
||||
end note
|
||||
|
||||
CS -> UI : on_state_change("thinking")
|
||||
CS -> UI : on_thinking_start()
|
||||
|
||||
@@ -81,14 +73,6 @@ group loop [while tool_calls present]
|
||||
|
||||
CS -> CS : _update_token_table()\ncalibrate chars_per_token ratio
|
||||
CS -> CS : messages.append(assistant_msg)
|
||||
CS -> UI : on_turn_committed()
|
||||
note right of UI
|
||||
Drops the per-turn inflight buffers — the
|
||||
assistant message is now in the history
|
||||
list, so the in_progress_snapshot must
|
||||
not re-render it during the next tool-
|
||||
execution window or the next streaming turn.
|
||||
end note
|
||||
CS -> DB : save_message(ws_id, "assistant", content)
|
||||
CS -> DB : save_message(ws_id, "tool_call", ...) ×N
|
||||
|
||||
@@ -139,9 +123,10 @@ group loop [while tool_calls present]
|
||||
read_file → open().read() or base64 image
|
||||
search → grep subprocess
|
||||
edit_file → string replace
|
||||
task → _run_agent() sub-loop
|
||||
task/plan → _run_agent() sub-loop
|
||||
math → sandboxed subprocess
|
||||
web_fetch → httpx + LLM summarize
|
||||
web_search → provider-native or SearxNG fallback
|
||||
web_search → provider-native or Tavily fallback
|
||||
memory/recall → SQLite
|
||||
end note
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
||||
:Dispatch to _prepare_{func_name}();
|
||||
|
||||
note right
|
||||
**Dispatch table (16 built-in + tool_search):**
|
||||
**Dispatch table (19 built-in + tool_search):**
|
||||
┌───────────────┬──────────────────┐
|
||||
│ Tool │ Needs Approval? │
|
||||
├───────────────┼──────────────────┤
|
||||
@@ -34,10 +34,13 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
||||
│ edit_file │ ✓ Yes │
|
||||
│ search │ ✗ Auto-approve │
|
||||
│ diff_file │ ✗ Auto-approve │
|
||||
│ math │ ✗ Auto-approve │
|
||||
│ man │ ✗ Auto-approve │
|
||||
│ web_fetch │ ✗ Auto-approve │
|
||||
│ web_search │ ✗ Auto-approve │
|
||||
│ tool_search │ ✗ Auto-approve │
|
||||
│ task_agent │ ✓ Yes │
|
||||
│ plan_agent │ ✓ Yes │
|
||||
│ memory │ ✗ Auto-approve │
|
||||
│ recall │ ✗ Auto-approve │
|
||||
│ notify │ ✗ Auto-approve │
|
||||
@@ -107,10 +110,13 @@ partition "Phase 3: Execute" #E3F2FD {
|
||||
├─ _exec_write_file: makedirs + write
|
||||
├─ _exec_edit_file: find_occurrences + replace
|
||||
├─ _exec_search: grep subprocess
|
||||
├─ _exec_math: sandboxed subprocess
|
||||
├─ _exec_man: man/info subprocess
|
||||
├─ _exec_web_fetch: httpx.get + LLM summary
|
||||
├─ _exec_web_search: SearxNG JSON GET (fallback for local models)
|
||||
├─ _exec_web_search: Tavily API POST (fallback for local models)
|
||||
├─ _exec_tool_search: BM25 search + expand_visible()
|
||||
├─ _exec_task: _run_agent(TASK_AGENT_TOOLS)
|
||||
├─ _exec_plan: _run_agent(AGENT_TOOLS, read-only)
|
||||
├─ _exec_notify: HTTP POST to channel gateway
|
||||
├─ _exec_memory: structured memory save/search/delete/list
|
||||
├─ _exec_recall: conversation history FTS5 search
|
||||
@@ -125,6 +131,11 @@ partition "Phase 3: Execute" #E3F2FD {
|
||||
|
||||
:bash: ui.on_tool_output_chunk(call_id, line) per stdout line;
|
||||
:ui.on_tool_result(call_id, name, output, is_error) for each;
|
||||
|
||||
if (plan tool was executed?) then (yes)
|
||||
:ui.on_plan_review(output);
|
||||
:Block for user review/feedback;
|
||||
endif
|
||||
}
|
||||
|
||||
:Return (results, user_feedback);
|
||||
|
||||
@@ -13,7 +13,7 @@ skinparam state {
|
||||
state "IDLE" as idle <<idle>> : Waiting for user input.\nNo active LLM call or tool execution.
|
||||
state "THINKING" as thinking <<thinking>> : LLM streaming response.\nTokens flowing (reasoning + content).
|
||||
state "RUNNING" as running <<running>> : Tools executing.\nThreadPoolExecutor active.
|
||||
state "ATTENTION" as attention <<attention>> : Blocked on user action.\nTool approval needed.
|
||||
state "ATTENTION" as attention <<attention>> : Blocked on user action.\nTool approval or plan review needed.
|
||||
state "ERROR" as error <<error>> : Exception occurred.\nRecoverable on next send().
|
||||
|
||||
[*] --> idle : Session created
|
||||
@@ -34,6 +34,8 @@ attention --> running : User denies\n(denial recorded)\n_emit_state("running")
|
||||
|
||||
running --> thinking : Tool results appended,\nnext LLM call\n_emit_state("thinking")
|
||||
|
||||
running --> attention : Plan tool complete,\non_plan_review()\n_emit_state("attention")
|
||||
|
||||
running --> error : Exception during\ntool execution
|
||||
|
||||
error --> thinking : New send() call\n_emit_state("thinking")
|
||||
@@ -42,7 +44,7 @@ thinking --> idle : cancel() called\nstream aborted\n_emit_state("idle")
|
||||
|
||||
running --> idle : cancel() called\n_emit_state("idle")
|
||||
|
||||
attention --> idle : cancel() unblocks\napproval wait\n_emit_state("idle")
|
||||
attention --> idle : cancel() unblocks\napproval/plan wait\n_emit_state("idle")
|
||||
|
||||
note left of idle
|
||||
**Cancel escalation:**
|
||||
|
||||
@@ -170,15 +170,15 @@ Server --> Browser : Shimmed app.js
|
||||
deactivate Server
|
||||
|
||||
note right of Browser
|
||||
All fetch("/v1/api/workstreams/{ws_id}/send") calls in the
|
||||
server UI now become fetch("/node/nodeA/v1/api/workstreams/{ws_id}/send"),
|
||||
All fetch("/v1/api/send") calls in the
|
||||
server UI now become fetch("/node/nodeA/v1/api/send"),
|
||||
routed through the console proxy.
|
||||
end note
|
||||
|
||||
Browser -> Server : GET /node/nodeA/v1/api/workstreams/ws789/events
|
||||
Browser -> Server : GET /node/nodeA/v1/api/events?ws_id=ws789
|
||||
activate Server #FFF9C4
|
||||
|
||||
Server -> NodeA : GET http://10.0.1.1:8080/v1/api/workstreams/ws789/events\n(SSE stream via httpx.AsyncClient timeout=None)
|
||||
Server -> NodeA : GET http://10.0.1.1:8080/v1/api/events?ws_id=ws789\n(SSE stream via httpx.AsyncClient timeout=None)
|
||||
activate NodeA
|
||||
|
||||
loop SSE streaming
|
||||
@@ -189,10 +189,10 @@ end
|
||||
deactivate NodeA
|
||||
deactivate Server
|
||||
|
||||
Browser -> Server : POST /node/nodeA/v1/api/workstreams/ws789/send\n{message:"hello"}
|
||||
Browser -> Server : POST /node/nodeA/v1/api/send\n{message:"hello", ws_id:"ws789"}
|
||||
activate Server #FFF9C4
|
||||
|
||||
Server -> NodeA : POST http://10.0.1.1:8080/v1/api/workstreams/ws789/send\n(body forwarded)
|
||||
Server -> NodeA : POST http://10.0.1.1:8080/v1/api/send\n(body forwarded)
|
||||
activate NodeA
|
||||
NodeA --> Server : {status:"ok"}
|
||||
deactivate NodeA
|
||||
|
||||
@@ -30,6 +30,7 @@ package "turnstone/sdk/ (Python)" {
|
||||
+ close_workstream()
|
||||
+ send(message, ws_id)
|
||||
+ approve()
|
||||
+ plan_feedback()
|
||||
+ command()
|
||||
+ cancel(ws_id)
|
||||
+ stream_events(ws_id)
|
||||
|
||||
@@ -23,7 +23,7 @@ interface "StorageBackend" as SB <<protocol>> {
|
||||
+resolve_workstream(alias_or_id) → str | None
|
||||
+delete_workstream(ws_id) → bool
|
||||
+prune_workstreams(retention_days) → (int, int)
|
||||
+list_workstreams(node_id, limit, *, parent_ws_id, kind, user_id) → list
|
||||
+list_workstreams(node_id, limit) → list
|
||||
+save_workstream_config(ws_id, config)
|
||||
+load_workstream_config(ws_id) → dict
|
||||
+kv_get(key) → str | None
|
||||
|
||||
@@ -79,7 +79,7 @@ class "Scope Hierarchy" as SH <<scope>> {
|
||||
--
|
||||
GET → read
|
||||
POST write paths → write
|
||||
POST /api/workstreams/{ws_id}/approve → approve
|
||||
POST /api/approve → approve
|
||||
/api/admin/* → approve
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,11 @@ class "Discord" as Discord <<platform>> {
|
||||
asyncio event loop
|
||||
}
|
||||
|
||||
class "Slack" as Slack <<platform>> {
|
||||
Socket Mode WebSocket
|
||||
class "Slack (future)" as Slack <<platform>> {
|
||||
Socket Mode / Events API
|
||||
Block Kit messages
|
||||
Slash command (default /turnstone)
|
||||
DM + channel events
|
||||
--
|
||||
slack-bolt (Python)
|
||||
asyncio event loop
|
||||
Planned integration
|
||||
}
|
||||
|
||||
class "Teams (future)" as Teams <<platform>> {
|
||||
@@ -41,7 +38,7 @@ class "Teams (future)" as Teams <<platform>> {
|
||||
class "turnstone-channel" as ChannelService <<service>> {
|
||||
entry point: turnstone-channel
|
||||
--
|
||||
One process — hosts one or more adapters
|
||||
One process per platform
|
||||
asyncio event loop
|
||||
Structured logging (structlog)
|
||||
--log-level, --log-format
|
||||
@@ -50,19 +47,6 @@ class "turnstone-channel" as ChannelService <<service>> {
|
||||
GET /health
|
||||
}
|
||||
|
||||
class "SlackBot" as SlackBot <<service>> {
|
||||
+on_message(event)
|
||||
+on_action(action) (Block Kit buttons)
|
||||
+send(channel_id, content)
|
||||
+send_notification(channel_id, content, ws_id)
|
||||
+run(bot_token, app_token)
|
||||
--
|
||||
slack-bolt AsyncApp
|
||||
Socket Mode client
|
||||
Per-user channel sessions via slash command
|
||||
DM routing without slash command
|
||||
}
|
||||
|
||||
class "DiscordBot" as Bot <<service>> {
|
||||
+on_message(msg)
|
||||
+on_interaction(interaction)
|
||||
@@ -95,10 +79,10 @@ class "ChannelRouter" as Router <<service>> {
|
||||
|
||||
' -- Server --
|
||||
class "turnstone-server" as Server <<server>> {
|
||||
POST /v1/api/workstreams/{ws_id}/send
|
||||
POST /v1/api/workstreams/{ws_id}/approve
|
||||
POST /v1/api/send
|
||||
POST /v1/api/approve
|
||||
POST /v1/api/workstreams/new
|
||||
GET /v1/api/workstreams/{ws_id}/events
|
||||
GET /v1/api/events?ws_id=
|
||||
--
|
||||
LLM execution + tool use
|
||||
SSE event stream
|
||||
@@ -148,21 +132,16 @@ Bot --> Router : on_message\non_interaction
|
||||
Router --> CU : resolve identity
|
||||
Router --> CR : resolve / register route
|
||||
|
||||
Router --> Server : POST /v1/api/workstreams/{ws_id}/send\nPOST /v1/api/workstreams/{ws_id}/approve\nPOST /v1/api/workstreams/new
|
||||
Bot --> Server : GET /v1/api/workstreams/{ws_id}/events\n(SSE via httpx-sse)
|
||||
Router --> Server : POST /v1/api/send\nPOST /v1/api/approve\nPOST /v1/api/workstreams/new
|
||||
Bot --> Server : GET /v1/api/events?ws_id=\n(SSE via httpx-sse)
|
||||
Server --> Bot : SSE event stream
|
||||
|
||||
Bot --> Discord : reply / embed\nbutton callback
|
||||
|
||||
Slack --> SlackBot : socket-mode\nevents
|
||||
SlackBot --> Router : on_message / on_action
|
||||
SlackBot --> Server : POST /v1/api/workstreams/{ws_id}/send\nGET /v1/api/workstreams/{ws_id}/events
|
||||
SlackBot --> Slack : post / update\nBlock Kit button callbacks
|
||||
|
||||
Slack .[hidden]. Discord
|
||||
Teams .[hidden]. Slack
|
||||
|
||||
ChannelService --> Bot : creates + runs
|
||||
ChannelService --> SlackBot : creates + runs
|
||||
ChannelService --> Router : creates
|
||||
ChannelService --> SVC : register / heartbeat /\nderegister
|
||||
|
||||
@@ -179,7 +158,7 @@ note right of Bot
|
||||
(or creates new workstream)
|
||||
4. ChannelRouter resolves platform user -> user_id
|
||||
via channel_users table
|
||||
5. Router sends POST /v1/api/workstreams/{ws_id}/send to server
|
||||
5. Router sends POST /v1/api/send to server
|
||||
|
||||
**Workstream Resume (evicted workstreams)**
|
||||
1. Stale route detected (no active SSE listener)
|
||||
@@ -193,7 +172,7 @@ end note
|
||||
note right of Server
|
||||
**Outbound Flow**
|
||||
1. Server emits SSE events on
|
||||
GET /v1/api/workstreams/{ws_id}/events
|
||||
GET /v1/api/events?ws_id=
|
||||
2. Bot subscribes via httpx-sse
|
||||
3. Bot formats and sends to Discord thread
|
||||
end note
|
||||
@@ -204,7 +183,7 @@ note bottom of CR
|
||||
2. Bot renders Discord buttons (Approve / Deny)
|
||||
3. User clicks button -> on_interaction()
|
||||
4. Router builds ApproveMessage
|
||||
5. Router sends POST /v1/api/workstreams/{ws_id}/approve to server
|
||||
5. Router sends POST /v1/api/approve to server
|
||||
end note
|
||||
|
||||
note bottom of CU
|
||||
|
||||
@@ -190,25 +190,21 @@ group Push Notifications (debounced 5s per server)
|
||||
MCPMgr -> Storage : sync_prompts_to_storage()
|
||||
end
|
||||
|
||||
group Manual Refresh
|
||||
Session -> MCPMgr : refresh_sync()
|
||||
group Periodic Polling (default 4h)
|
||||
MCPMgr -> MCPMgr : _periodic_refresh()
|
||||
note right
|
||||
/mcp refresh [server] —
|
||||
re-fetches catalog and
|
||||
attempts reconnect for
|
||||
disconnected servers.
|
||||
Only polls capabilities
|
||||
without push support.
|
||||
Staggered per-server.
|
||||
Disconnected servers get
|
||||
reconnect attempts with
|
||||
exponential backoff (60s-1h).
|
||||
end note
|
||||
end
|
||||
|
||||
group Manual Reconnect
|
||||
Session -> MCPMgr : reconnect_sync(name)
|
||||
note right
|
||||
Operator-driven via the
|
||||
console admin panel —
|
||||
tears down session, clears
|
||||
circuit breaker, runs a
|
||||
fresh handshake.
|
||||
end note
|
||||
group Manual Refresh
|
||||
Session -> MCPMgr : refresh_sync()
|
||||
note right: /mcp refresh [server]
|
||||
end
|
||||
|
||||
== Policy Evaluation ==
|
||||
|
||||
@@ -202,7 +202,7 @@ note over Session, Judge
|
||||
Cross-model: separate provider/client from [judge] config.
|
||||
|
||||
**Sub-agent exemption:**
|
||||
Task sub-agents skip intent validation entirely.
|
||||
Plan agent and task agent skip intent validation entirely.
|
||||
|
||||
**Output guard:**
|
||||
Runs when judge_config.output_guard is true (default).
|
||||
@@ -211,7 +211,7 @@ note over Session, Judge
|
||||
**Storage:**
|
||||
intent_verdicts table (migration 012), output_assessments table
|
||||
(migration 022). Both queryable via admin API endpoints
|
||||
(requires admin.judge permission). Skills store risk_level,
|
||||
(requires admin.judge permission). Skills store scan_status,
|
||||
scan_report, scan_version for install-time risk assessment.
|
||||
end note
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
@startuml
|
||||
title Turnstone - coordinator wait_for_workstream lifecycle
|
||||
|
||||
skinparam sequenceArrowThickness 1.5
|
||||
skinparam noteBackgroundColor #FDF6E3
|
||||
|
||||
participant "Coordinator\nLLM" as LLM
|
||||
participant "ChatSession\n(worker thread)" as CS
|
||||
participant "CoordinatorClient" as CC
|
||||
participant "SessionUI\n(SSE fanout)" as UI
|
||||
participant "Console routing\nproxy" as RP
|
||||
database "Storage\n(workstreams row)" as DB
|
||||
participant "Child\nnode" as NODE
|
||||
|
||||
== Spawn ==
|
||||
|
||||
LLM -> CS : tool_call spawn_workstream(...)
|
||||
activate CS
|
||||
CS -> CC : spawn(initial_message=...,\nparent_ws_id=coord, user_id=...)
|
||||
CC -> RP : POST /v1/api/route/workstreams/new
|
||||
RP -> NODE : dispatch (rendezvous)
|
||||
NODE -> DB : insert workstreams row\nstate='running'
|
||||
RP --> CC : {ws_id, node_id, name, status: 200}
|
||||
CC --> CS : {ws_id, ...}
|
||||
CS -> UI : on_tool_result\n("spawn_workstream", ws_id)
|
||||
deactivate CS
|
||||
|
||||
note right of LLM
|
||||
Model now knows the child ws_id.
|
||||
It can inspect / send / wait, and
|
||||
the parent registry tracks it.
|
||||
end note
|
||||
|
||||
== Wait (blocking) ==
|
||||
|
||||
LLM -> CS : tool_call wait_for_workstream\n(ws_ids=[child], mode="any", timeout=60)
|
||||
activate CS
|
||||
CS -> CS : _prepare_wait_for_workstream\n(validate ws_ids, timeout, mode)
|
||||
CS -> UI : emit wait_started\n{call_id, ws_ids, mode, timeout}
|
||||
|
||||
CS -> CC : wait_for_workstream(ws_ids, timeout,\nmode, progress_callback)
|
||||
activate CC
|
||||
|
||||
loop every 500ms up to timeout
|
||||
CC -> DB : read workstreams row(s)
|
||||
DB --> CC : {state, updated, tokens, ...}
|
||||
alt state in {idle, error, closed, deleted}
|
||||
note over CC
|
||||
real-terminal state ->
|
||||
completion condition met
|
||||
end note
|
||||
else still running / thinking / attention
|
||||
CC -> CS : progress_callback(snap)\n(diff-on-change or 5s heartbeat)
|
||||
CS -> UI : emit wait_progress\n{call_id, elapsed, results?}
|
||||
end
|
||||
end
|
||||
|
||||
CC --> CS : {complete, elapsed,\nresults: {ws_id: snap}}
|
||||
deactivate CC
|
||||
|
||||
CS -> UI : emit wait_ended\n{call_id, complete, elapsed, results}
|
||||
CS -> UI : on_tool_result\n("wait_for_workstream",\n"complete after Ns (R/N resolved)")
|
||||
CS --> LLM : tool_result (full results dict)
|
||||
deactivate CS
|
||||
|
||||
note left of UI
|
||||
Sidebar "waiting on N children" indicator
|
||||
keys on call_id - started / progress / ended
|
||||
scope to a single wait invocation so
|
||||
nested waits render independent badges.
|
||||
end note
|
||||
|
||||
== After wait: inspect + close ==
|
||||
|
||||
LLM -> CS : tool_call inspect_workstream(ws_id=child)
|
||||
CS -> CC : inspect(ws_id)
|
||||
CC -> DB : read row + tail
|
||||
CC --> CS : {state, messages, tokens, ...}
|
||||
CS --> LLM : tool_result (serialised)
|
||||
|
||||
LLM -> CS : tool_call close_workstream\n(ws_id=child, reason="...")
|
||||
CS -> CC : close_workstream(ws_id, reason)
|
||||
CC -> RP : POST /v1/api/route/workstreams/close
|
||||
RP -> NODE : dispatch
|
||||
NODE -> DB : state='closed',\nclose_reason='...'
|
||||
RP --> CC : {status: 200}
|
||||
CC --> CS : {closed: true, status: 200, reason: ...}
|
||||
CS --> LLM : tool_result
|
||||
@enduml
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:95dd5ebc899a1261d516686a5aa3319a7f45015d411302825fa28afbfc82e1ce
|
||||
size 326766
|
||||
oid sha256:75da80e6bd205e45b9fe48aa2f87197110c908f44e1d98c52eee69f956274711
|
||||
size 400402
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9857db23fe3c4316d492073aac69c7e7558b1abe3b95ad7756d4a5933bd0ece7
|
||||
size 620214
|
||||
oid sha256:6471e611beebf647f3a191eb16588571a404cc52a43067883a2b6f06dd936376
|
||||
size 594676
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d9c7769a600c38e6387390e6c42db8152e0f80c31d17b2218f7f636b71c7b868
|
||||
size 355459
|
||||
oid sha256:3aa8d972bba40d78152f9f0c762b9f5ec616d8052c45fa52b7dd1c679ed81d61
|
||||
size 325245
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:23ca090b5656baaf70820cbe4ab6c27f0a3a02e18b4db0695614cf9489c23980
|
||||
size 281440
|
||||
oid sha256:674712a0563f51837383184652efeb28b7bec13378be636e89d2959bfba39d1e
|
||||
size 281519
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ae4f79fb22600106f8cb0af4ba5586bb26ea5d57e27ef382fdc59b6549fdbd21
|
||||
size 415473
|
||||
oid sha256:8a5957b71822656098cbe779ca619b7e56e9b11b0f1c4eac4fc880ed62b71a2a
|
||||
size 358670
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d6aff446a062aa08f316985d00c2183148694f786d7f22172bc50b30046c728b
|
||||
size 379259
|
||||
oid sha256:7623df33be9baf7647ca1c2450640df57e1cd73e8be1f8168aae16e546ad683c
|
||||
size 459941
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:aa12d81dc578f7e65bf4df3152b3de1736289c422f83d0b0cd32107726722357
|
||||
size 172028
|
||||
+96
-212
@@ -1,266 +1,150 @@
|
||||
# Docker Deployment
|
||||
|
||||
Turnstone ships two Docker Compose stacks:
|
||||
Docker Compose stack for running the full turnstone platform.
|
||||
|
||||
| Stack | File | Use it for |
|
||||
|-------|------|------------|
|
||||
| **Dev cluster** | `compose.yaml` (repo root) | Clone-and-run. Builds locally, zero config, full 10-node cluster. |
|
||||
| **Production** | `turnstone/deploy/compose.yaml` | Pip/pipx installs. Pulls released images from ghcr.io, requires real secrets. |
|
||||
|
||||
## Quick start — local cluster
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/turnstonelabs/turnstone
|
||||
cd turnstone
|
||||
# Copy and edit environment config
|
||||
cp .env.example .env
|
||||
|
||||
# Full stack (needs an LLM API on the host)
|
||||
docker compose up
|
||||
```
|
||||
|
||||
That builds one image and brings up the whole stack: PostgreSQL, the console,
|
||||
Caddy, the channel gateway, and **10 server nodes** (`node-1`…`node-10`). No
|
||||
`.env` is required — it ships with insecure dev defaults so it just works.
|
||||
Console dashboard: http://localhost:8090
|
||||
|
||||
Open the dashboard at **https://localhost:8443**. It's served by Caddy with its
|
||||
own local CA, so trust the root certificate once (or click through the browser
|
||||
warning):
|
||||
> See also: [Deployment diagram](diagrams/png/12-deployment.png)
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Port | Profile | Description |
|
||||
|---------|------|---------|-------------|
|
||||
| `server` | 8080 | default | Web UI + chat workstreams + LLM |
|
||||
| `console` | 8090 | default | Cluster dashboard |
|
||||
| `channel` | — | production | Channel gateway (Discord, Slack, etc.) |
|
||||
| `server-1`…`server-10` | — | cluster | 10-node server fleet (PostgreSQL required) |
|
||||
|
||||
## Profiles
|
||||
|
||||
**Default** (no flag) — starts `server` and `console`. Requires an OpenAI-compatible LLM API running on the host (default: `http://localhost:8000/v1`).
|
||||
|
||||
```bash
|
||||
docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt
|
||||
docker compose up
|
||||
```
|
||||
|
||||
Create your first admin user (any node works — they share one database):
|
||||
**Production** — adds PostgreSQL and the channel gateway. Requires `POSTGRES_PASSWORD` and (for Discord) `TURNSTONE_DISCORD_TOKEN`:
|
||||
|
||||
```bash
|
||||
docker compose exec node-1 turnstone-admin create-user --username admin --name "Admin"
|
||||
docker compose --profile production up
|
||||
```
|
||||
|
||||
### Bring your own LLM
|
||||
|
||||
Nodes boot **without** an LLM and appear in the console immediately. Add real
|
||||
model backends (OpenAI, Anthropic, or a local/vLLM endpoint) from the console
|
||||
UI's **Models** tab. To set a node's bootstrap default instead, point
|
||||
`LLM_BASE_URL` / `OPENAI_API_KEY` at an OpenAI-compatible endpoint in `.env`.
|
||||
|
||||
### Fewer nodes
|
||||
|
||||
Ten nodes is heavy on a laptop. Start a subset by naming the services (always
|
||||
include `postgres`, `console`, and `caddy`):
|
||||
**Cluster** — 10-node server fleet sharing PostgreSQL. Access all nodes via the console at `:8090`. Requires `POSTGRES_PASSWORD`:
|
||||
|
||||
```bash
|
||||
docker compose up postgres console caddy channel node-1 node-2 node-3
|
||||
docker compose --profile cluster up
|
||||
```
|
||||
|
||||
## Why HTTPS-only?
|
||||
|
||||
The console's plain-HTTP port (8090) is **not** published to the host. A plain
|
||||
HTTP/1.1 origin caps the browser at 6 connections, which starves the
|
||||
dashboard's per-pane SSE streams. Caddy serves the browser over HTTP/2
|
||||
(multiplexed) and proxies to `console:8090` on the internal network, so the cap
|
||||
is gone. Everything goes through `https://localhost:8443`.
|
||||
|
||||
## Join a bare-metal host
|
||||
|
||||
PostgreSQL is published on `127.0.0.1:5432`, so a `turnstone-server` running
|
||||
directly on the same machine — for example to use a local GPU — can join the
|
||||
same cluster and show up in the console alongside the containerized nodes.
|
||||
|
||||
Put the secret and connection settings in `~/.config/turnstone/config.toml`
|
||||
(secrets belong in this file, not the process environment — keep it `0600`,
|
||||
the loader warns otherwise):
|
||||
|
||||
```toml
|
||||
[auth]
|
||||
jwt_secret = "dev-only-insecure-jwt-secret-change-me-for-real-deployments"
|
||||
|
||||
[database]
|
||||
backend = "postgresql"
|
||||
url = "postgresql+psycopg://turnstone:turnstone@localhost:5432/turnstone"
|
||||
|
||||
[api]
|
||||
base_url = "http://localhost:8000/v1" # your local model endpoint
|
||||
api_key = "dummy"
|
||||
```
|
||||
|
||||
Then start the server. The node identity isn't a secret, so it stays on the
|
||||
command line:
|
||||
|
||||
```bash
|
||||
chmod 600 ~/.config/turnstone/config.toml
|
||||
TURNSTONE_NODE_ID=host-1 TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
|
||||
turnstone-server --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
The host server registers itself in PostgreSQL; the console reaches it back via
|
||||
`host.docker.internal`. The `jwt_secret` and DB credentials above are the
|
||||
dev-stack defaults — match whatever you set in `.env` if you changed them. To
|
||||
let a **different** machine join, start the stack with `POSTGRES_BIND=0.0.0.0`
|
||||
and use the host's routable IP in the `url` and `TURNSTONE_ADVERTISE_URL` —
|
||||
but **set a strong `POSTGRES_PASSWORD` first**, or you'll expose a database with
|
||||
the insecure default password (and every user account + API-token hash in it) to
|
||||
your network.
|
||||
|
||||
## Production stack
|
||||
|
||||
For a real deployment use the bundled stack, which pulls released images
|
||||
instead of building:
|
||||
|
||||
```bash
|
||||
docker compose -f turnstone/deploy/compose.yaml up
|
||||
```
|
||||
|
||||
It's the same shape as the dev stack — Caddy-fronted console, channel, and a
|
||||
PostgreSQL all share one database so the console discovers the node — but it
|
||||
pulls released images, runs a single server node, and has **no baked-in
|
||||
secrets**. Set these in `.env` first (`turnstone-bootstrap` generates them):
|
||||
|
||||
```bash
|
||||
TURNSTONE_JWT_SECRET=<python -c "import secrets; print(secrets.token_hex(32))">
|
||||
POSTGRES_PASSWORD=<a strong password>
|
||||
```
|
||||
|
||||
The dashboard is at **https://localhost:8443** (Caddy, same as the dev stack);
|
||||
the console's HTTP port isn't published. For a real domain and a publicly
|
||||
trusted cert, edit `turnstone/deploy/Caddyfile` to point Caddy at Let's Encrypt
|
||||
(see [tls.md](tls.md)). Pin the image with `TURNSTONE_IMAGE_TAG` (default:
|
||||
`latest`).
|
||||
|
||||
### mTLS
|
||||
|
||||
Layer the TLS overlay on the production stack to enable mutual TLS between
|
||||
services. A bootstrap container creates a CA and every service auto-provisions
|
||||
certs via the console's ACME endpoint:
|
||||
|
||||
```bash
|
||||
docker compose -f turnstone/deploy/compose.yaml -f deploy/docker-compose.tls.yml up
|
||||
```
|
||||
|
||||
See [tls.md](tls.md) for details.
|
||||
|
||||
## Configuration
|
||||
|
||||
Everything is configured with environment variables in `.env` (copy from
|
||||
[`.env.example`](../.env.example)). The dev stack needs none of them — they're
|
||||
overrides.
|
||||
All configuration is via environment variables in `.env` (copy from `.env.example`):
|
||||
|
||||
### LLM backend
|
||||
### LLM Backend
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LLM_BASE_URL` | `http://host.docker.internal:8000/v1` | Bootstrap OpenAI-compatible API URL (real backends go in the UI) |
|
||||
| `LLM_BASE_URL` | `http://host.docker.internal:8000/v1` | OpenAI-compatible API URL |
|
||||
| `OPENAI_API_KEY` | `dummy` | API key (`dummy` for local servers) |
|
||||
| `TURNSTONE_SEARXNG_URL` | `http://searxng:8080` | SearxNG URL for the `web_search` tool (local/vLLM models only; Anthropic/OpenAI use native search). Defaults to the bundled `searxng` service; set to an external instance's URL. To turn web search off, clear `tools.searxng_url` in the admin Settings tab. |
|
||||
| `SEARXNG_IMAGE_TAG` | `latest` | Tag for the bundled `searxng/searxng` image |
|
||||
| `MODEL` | — | Override the default model alias |
|
||||
| `TAVILY_API_KEY` | — | Web search API key (only needed for local/vLLM models; Anthropic and OpenAI search models use native search) |
|
||||
|
||||
### Auth & database
|
||||
|
||||
| Variable | Default (dev / prod) | Description |
|
||||
|----------|----------------------|-------------|
|
||||
| `TURNSTONE_JWT_SECRET` | insecure default / **required** | JWT signing secret. Every service must share one value. |
|
||||
| `TURNSTONE_DB_BACKEND` | `postgresql` | `sqlite` or `postgresql`. Multi-node discovery requires `postgresql`. |
|
||||
| `TURNSTONE_DB_URL` | bundled Postgres | SQLAlchemy URL. Override to use an external database. |
|
||||
| `POSTGRES_USER` | `turnstone` | PostgreSQL username |
|
||||
| `POSTGRES_PASSWORD` | `turnstone` / **required** | PostgreSQL password |
|
||||
| `POSTGRES_MAX_CONNECTIONS` | `300` | `max_connections` for the bundled Postgres |
|
||||
|
||||
> **Discovery needs a shared database.** Each server registers and heartbeats
|
||||
> into a `services` table that the console polls. All services in these stacks
|
||||
> point at the same PostgreSQL by default; SQLite-per-container can't see other
|
||||
> containers.
|
||||
|
||||
> **Large clusters:** each process keeps a small pool (5 max). Beyond ~50 nodes,
|
||||
> put [PgBouncer](pgbouncer.md) (transaction pooling) between turnstone and
|
||||
> PostgreSQL.
|
||||
|
||||
### Ports
|
||||
|
||||
Both stacks publish Caddy (dashboard) and PostgreSQL; the dev stack additionally
|
||||
publishes the SearxNG UI on localhost. Everything else is reached through Caddy or
|
||||
proxied by the console:
|
||||
### Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CONSOLE_HTTPS_PORT` | `8443` | Host port for Caddy (dashboard HTTPS) |
|
||||
| `SEARXNG_HTTPS_PORT` | `8444` | Host port for the SearxNG UI via Caddy (dev: localhost-only; prod: opt-in) |
|
||||
| `POSTGRES_PORT` | `5432` | Host port for PostgreSQL (for bare-metal joins) |
|
||||
| `POSTGRES_BIND` | `127.0.0.1` | Interface PostgreSQL binds on; set `0.0.0.0` for LAN access |
|
||||
| `SERVER_PORT` | `8080` | Host port mapping |
|
||||
| `SKIP_PERMISSIONS` | — | Set to any value to auto-approve all tools |
|
||||
|
||||
### Channel gateway
|
||||
### Console
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_DISCORD_TOKEN` | — | Discord bot token (enables the Discord adapter) |
|
||||
| `TURNSTONE_DISCORD_GUILD` | `0` | Restrict to one guild (0 = all) |
|
||||
| `TURNSTONE_SLACK_TOKEN` | — | Slack Bot User OAuth token `xoxb-…` |
|
||||
| `TURNSTONE_SLACK_APP_TOKEN` | — | Slack App-Level token `xapp-…` (with the Slack token) |
|
||||
| `CONSOLE_PORT` | `8090` | Host port mapping |
|
||||
|
||||
The channel runs HTTP-only with no adapters until a token is set, so it's safe
|
||||
to leave running. See [Channel Integrations](channels.md) for app setup.
|
||||
### Auth
|
||||
|
||||
### Web search (SearxNG)
|
||||
|
||||
The `web_search` tool for local/vLLM models is backed by a self-hosted
|
||||
[SearxNG](https://searxng.org) metasearch service, bundled into both stacks as the
|
||||
`searxng` service. The Turnstone nodes reach it over the internal docker network at
|
||||
`http://searxng:8080` — its API port is **not** published. Its config —
|
||||
[`turnstone/deploy/searxng/settings.yml`](../turnstone/deploy/searxng/settings.yml),
|
||||
mounted read-only — enables the JSON API and leaves the rate limiter off (the
|
||||
limiter would need a separate Valkey/Redis instance). A `searxng-cache` volume
|
||||
persists its favicon + internal cache across restarts. Commercial providers
|
||||
(Anthropic, OpenAI) use their own native search and never touch this service.
|
||||
|
||||
Point at an existing SearxNG instead of the bundled one with `TURNSTONE_SEARXNG_URL`,
|
||||
or narrow the engines via `tools.searxng_engines` in the admin Settings tab (e.g.
|
||||
`duckduckgo,wikipedia`).
|
||||
|
||||
**SearxNG web UI.** Caddy can also serve SearxNG's own search/Preferences UI on a
|
||||
dedicated port. The dev stack publishes it at **`https://localhost:8444`** bound to
|
||||
localhost only; the production stack does **not** publish it by default (uncomment
|
||||
the `8444` port on the `caddy` service to opt in). Change the port with
|
||||
`SEARXNG_HTTPS_PORT`. **SearxNG has no authentication** — never bind this to a public
|
||||
interface, or anyone who can reach it can search through your instance.
|
||||
|
||||
> **AGPL note for operators.** SearxNG is licensed AGPL-3.0. Kept on the internal
|
||||
> network (or bound to localhost), no external user interacts with it — so the AGPL
|
||||
> §13 (remote network interaction) source-offer obligation does not attach. If you
|
||||
> publish SearxNG to remote users (bind its port to a public interface, or front it
|
||||
> with your own reverse proxy) you become the operator of a network-reachable AGPL
|
||||
> service and must offer its corresponding source; that is trivially satisfied by
|
||||
> linking to upstream <https://github.com/searxng/searxng>. Turnstone's own license is
|
||||
> unaffected: it talks to SearxNG over HTTP as a separate process (mere aggregation),
|
||||
> not by linking.
|
||||
|
||||
### Other
|
||||
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `WORKSPACE_MOUNT` | empty volume | Host directory bind-mounted at `/workspace` for the model to read/write |
|
||||
| `SKIP_PERMISSIONS` | — | Set to any value to auto-approve all tool calls (dev only) |
|
||||
| `MCP_CONFIG` | — | Path to an MCP server config file |
|
||||
| `TURNSTONE_IMAGE_TAG` | `latest` | ghcr.io image tag — production stack |
|
||||
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required) |
|
||||
|
||||
## Building
|
||||
### Database
|
||||
|
||||
Both stacks install all entry points into a single image (`turnstone`,
|
||||
`turnstone-server`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`,
|
||||
`turnstone-eval`, `turnstone-bootstrap`):
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_DB_BACKEND` | `sqlite` | Storage backend: `sqlite` or `postgresql` |
|
||||
| `TURNSTONE_DB_URL` | — | Database URL (e.g. `postgresql://user:pass@db:5432/turnstone`). For SQLite, defaults to `/data/.turnstone.db` |
|
||||
| `TURNSTONE_DB_POOL_SIZE` | `2` | PostgreSQL connection pool size per process (default: 2 base + 3 overflow = 5 max) |
|
||||
|
||||
The database stores workstream history, user accounts, and API tokens. When using JWT auth, a database backend is required for user storage.
|
||||
|
||||
> **Large clusters:** Each turnstone process maintains a small connection pool (5 max). At hundreds of nodes this adds up — use [PgBouncer](pgbouncer.md) in transaction pooling mode between turnstone and PostgreSQL.
|
||||
|
||||
> **First-time setup:** After deploying with auth enabled, create an initial admin user by running `turnstone-admin create-user` inside the container:
|
||||
>
|
||||
> ```bash
|
||||
> docker compose exec server turnstone-admin create-user --username admin --name "Admin"
|
||||
> ```
|
||||
>
|
||||
> You will be prompted to set a password. Use it to log in via the UI or SDK, then create additional users through the admin API. Pass `--token --scopes read,write,approve` to also generate an initial API token.
|
||||
|
||||
### Channel Gateway
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_DISCORD_TOKEN` | — | Discord bot token (required to enable Discord adapter) |
|
||||
| `TURNSTONE_DISCORD_GUILD` | `0` | Restrict to a single Discord guild (0 = all guilds) |
|
||||
|
||||
The channel service runs in the `production` profile. When `TURNSTONE_DISCORD_TOKEN` is set, the Discord adapter connects to the Discord Gateway and routes messages to the server via HTTP. See [Channel Integrations](channels.md) for full setup instructions including Discord application creation and user account linking.
|
||||
|
||||
## Scaling
|
||||
|
||||
For multi-node testing, use the `cluster` profile which provides 10 server instances with unique node IDs (`node-1` through `node-10`), resource limits, and shared PostgreSQL:
|
||||
|
||||
```bash
|
||||
docker compose build # build the dev image
|
||||
docker compose build --no-cache # rebuild from scratch
|
||||
POSTGRES_PASSWORD=secret docker compose --profile cluster up
|
||||
```
|
||||
|
||||
The default `server` also runs alongside the cluster nodes (11 total). All nodes are accessible via the console dashboard at `:8090`.
|
||||
|
||||
For production clusters beyond ~50 nodes, add PgBouncer between turnstone services and PostgreSQL. See [PgBouncer Connection Pooling](pgbouncer.md) for Docker Compose and Helm configuration.
|
||||
|
||||
## Volumes
|
||||
|
||||
| Volume | Purpose |
|
||||
|--------|---------|
|
||||
| `postgres-data` | PostgreSQL data directory |
|
||||
| `turnstone-data` | `/data` per node (SQLite fallback, local state) |
|
||||
| `workspace` | `/workspace` (unless `WORKSPACE_MOUNT` is set) |
|
||||
| `caddy-data` / `caddy-config` | Caddy's local CA and config (dev stack) |
|
||||
| Volume | Mount | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `turnstone-data` | `/data` | SQLite database (`.turnstone.db`) |
|
||||
|
||||
## Building
|
||||
|
||||
The image uses a multi-stage Dockerfile:
|
||||
|
||||
```bash
|
||||
# Build all services
|
||||
docker compose build
|
||||
|
||||
# Rebuild without cache
|
||||
docker compose build --no-cache
|
||||
```
|
||||
|
||||
All entry points are installed in a single image: `turnstone-server`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`, `turnstone-eval`.
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
docker compose down # stop and remove containers
|
||||
docker compose down -v # also remove volumes (database, certs)
|
||||
# Stop and remove containers
|
||||
docker compose down
|
||||
|
||||
# Stop, remove containers and volumes
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
+2
-1
@@ -274,7 +274,8 @@ for iteration in 0..max_iterations:
|
||||
|
||||
### Phase 1: Analyst (`_run_analyst`)
|
||||
|
||||
A multi-turn agent with a `bash` tool for computing statistics. It receives per-case results with failure classifications and
|
||||
A multi-turn agent with `math` (Python) and `bash` tools for computing
|
||||
statistics. It receives per-case results with failure classifications and
|
||||
produces a structured diagnosis:
|
||||
|
||||
- **Failure patterns**: Shared root causes across failing cases
|
||||
|
||||
+6
-12
@@ -62,14 +62,14 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
|
||||
- **Default skills**: All `is_default=true` skills auto-apply to new
|
||||
workstreams, concatenated in alphabetical order by name. Use name prefixes
|
||||
(e.g. `01-safety`, `02-style`) to control ordering.
|
||||
- **Explicit selection**: `--skill <name>` CLI flag, `skill` field on
|
||||
- **Explicit selection**: `--template <name>` CLI flag, `template` field on
|
||||
`POST /v1/api/workstreams/new`, console creation modal dropdown, scheduled task
|
||||
config, and channel adapter config. An explicit skill *replaces* defaults.
|
||||
- **Variables**: Three built-in placeholders resolved at load time:
|
||||
`{{model}}` (active model name), `{{ws_id}}` (workstream ID),
|
||||
`{{node_id}}` (server node ID). Unrecognized placeholders are kept as-is.
|
||||
- **Runtime switching**: `/skill <name>` to switch, `/skill clear` to revert
|
||||
to defaults, `/skill` to show current. Persisted across resume.
|
||||
- **Runtime switching**: `/template <name>` to switch, `/template clear` to revert
|
||||
to defaults, `/template` to show current. Persisted across resume.
|
||||
- **Model-driven loading**: The `skill` built-in tool lets the model
|
||||
discover and activate skills mid-conversation. `search` action finds skills
|
||||
by query (auto-approved); `load` action activates by name (requires user
|
||||
@@ -91,7 +91,7 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
|
||||
time. The scanner evaluates four risk axes: content risk (command execution,
|
||||
data exfiltration), supply chain risk (pipe-to-shell, transitive installs),
|
||||
vulnerability risk (prompt injection, insecure credentials), and declared
|
||||
capability risk (from `allowed-tools` in SKILL.md). Results populate the `risk_level`
|
||||
capability risk (from `allowed-tools` in SKILL.md). Results populate the `scan_status`
|
||||
(safe/low/medium/high/critical) and `scan_report` (JSON breakdown) columns.
|
||||
These fields are system-managed and cannot be overwritten via the admin API.
|
||||
- **Discovery**: External skills can be discovered and installed from registries:
|
||||
@@ -186,21 +186,15 @@ Full OpenAPI spec at `/openapi.json` and Swagger UI at `/docs`.
|
||||
|
||||
## Admin Console UI
|
||||
|
||||
Governance-related tabs within the 18-tab admin panel:
|
||||
6 new tabs added to the admin panel (11 total):
|
||||
|
||||
- **Roles** — CRUD roles, permission checkbox grid, user role assignment modal
|
||||
- **Policies** — CRUD tool policies with colored action badges (green/red/amber)
|
||||
- **Prompts** — Prompt-policy editor (heuristics for admin guardrails)
|
||||
- **Skills** — CRUD skills with wide modal, textarea editor; Discover pill for
|
||||
installing from skills.sh / GitHub; per-row scan badges (safe/low/med/high/critical)
|
||||
- **Judge** — Intent validation configuration and verdict history
|
||||
- **Skills** — CRUD skills with wide modal, textarea editor
|
||||
- **Usage** — Summary readouts + CSS bar chart, time range + group-by selectors
|
||||
- **Audit** — Filterable log with relative timestamps, load-more pagination
|
||||
|
||||
Tabs are permission-gated: hidden if the user lacks the required permission.
|
||||
See [docs/console.md](console.md) for the full tab list and
|
||||
[docs/settings.md](settings.md) for the Settings tab that edits live
|
||||
ConfigStore values.
|
||||
|
||||
## SDK
|
||||
|
||||
|
||||
+17
-120
@@ -37,31 +37,13 @@ model = "" # empty = same as session model
|
||||
provider = "" # empty = same as session provider
|
||||
base_url = ""
|
||||
api_key = ""
|
||||
smart_approvals = false # auto-approve high-confidence "approve" LLM verdicts (opt-in)
|
||||
confidence_threshold = 0.95 # Smart Approvals auto-approve bar (LLM recommendation=approve)
|
||||
confidence_threshold = 0.7 # reserved for v2 smart approvals (not used in v1)
|
||||
max_context_ratio = 0.5 # max % of judge context window for history
|
||||
timeout = 60.0 # seconds (generous for local models)
|
||||
read_only_tools = true # judge can use read_file/list_directory
|
||||
cancel_on_approval = false # stop judging remaining tool calls once user decides
|
||||
```
|
||||
|
||||
### Smart Approvals
|
||||
|
||||
With `smart_approvals = true` (off by default) a tool call is approved
|
||||
automatically — no operator prompt — when the intent judge's **LLM** verdict
|
||||
recommends `approve` with confidence at or above `confidence_threshold`. Every
|
||||
other outcome still reaches a human: `review` / `deny` recommendations,
|
||||
confidence below the threshold, judge errors or timeouts (`llm_fallback`), and
|
||||
any call the deterministic heuristic rules explicitly flagged `deny` or
|
||||
`critical`. That heuristic floor blocks only those explicit danger verdicts — it
|
||||
is **not** a general "never lower the heuristic" rule: the heuristic's default
|
||||
for an unmatched tool is `review`, and letting a confident LLM `approve` upgrade
|
||||
a `review` is exactly what Smart Approvals is for. Only `deny` / `critical`
|
||||
findings are off-limits to auto-approval. Requires the judge to be enabled;
|
||||
auto-approved calls are tagged `smart_approval` in the dashboard and audit trail.
|
||||
Smart Approvals applies to the web and coordinator surfaces, not the interactive
|
||||
CLI.
|
||||
|
||||
All fields are optional. The judge is enabled by default; use `enabled = false`
|
||||
(or `--no-judge` on the command line) to disable it.
|
||||
|
||||
@@ -72,12 +54,9 @@ All fields are optional. The judge is enabled by default; use `enabled = false`
|
||||
--judge-model MODEL Model for judge
|
||||
--judge-provider PROVIDER Provider for judge
|
||||
--judge-timeout SECONDS LLM judge timeout (default: 60)
|
||||
--judge-confidence FLOAT Confidence threshold, 0-1 (default: 0.95)
|
||||
--judge-confidence FLOAT Confidence threshold (default: 0.7)
|
||||
```
|
||||
|
||||
(Smart Approvals is configured via `[judge] smart_approvals` / the admin Judge
|
||||
settings, not a CLI flag — the interactive CLI prompts for approval directly.)
|
||||
|
||||
CLI flags override `config.toml` values.
|
||||
|
||||
---
|
||||
@@ -125,7 +104,7 @@ last) and returns the first matching rule. Each rule has:
|
||||
| Critical | 0.90 | deny | `rm -rf /`, `mkfs`, `dd if=`, pipe-to-shell, chmod 777 on root, write/edit to `/etc/` or `.ssh/`, download-then-execute chains (`curl -o file && chmod +x && bash`) |
|
||||
| High | 0.80 | review | `sudo`, `kill -9`, destructive git, DROP TABLE, write/edit secrets, HTTP mutations, `ssh`/`scp`, credential file access, browser automation + data export, transitive installs (`npx skills add`, `pip install git+`), control plane mutations (`crontab`, `systemctl enable/start/stop`) |
|
||||
| Medium | 0.70 | review | Content ingestion pipelines (`curl \| python3`), interpreter execution (`python3 script.py`, `node build.js`), cloud CLI mutations (`az/gcloud/aws/kubectl/terraform` with create/delete/destroy verbs), package installs, `write_file`, MCP tools, Docker operations |
|
||||
| Low | 0.85 | approve | `read_file`, `list_directory`, `search`, `recall`, `use_prompt`, `tool_search`, `read_resource`, `web_search`, read-only bash (`ls`, `cat`, `head`, `grep`, `find`, etc.) |
|
||||
| Low | 0.85 | approve | `read_file`, `list_directory`, `search`, `recall`, `man`, `use_prompt`, `tool_search`, `read_resource`, `web_search`, read-only bash (`ls`, `cat`, `head`, `grep`, `find`, etc.) |
|
||||
|
||||
When no rule matches, the heuristic returns a default verdict: medium risk,
|
||||
0.50 confidence, "review" recommendation.
|
||||
@@ -231,23 +210,6 @@ calls for approval, it calls `_evaluate_intent()` which:
|
||||
4. Attaches each heuristic verdict to its item as `_heuristic_verdict`
|
||||
5. The daemon thread runs the LLM judge and delivers results via `ui.on_intent_verdict()`
|
||||
|
||||
The daemon evaluates items sequentially, so a large parallel batch can outlive
|
||||
its approval gate. With `cancel_on_approval = false` (the default) the daemon
|
||||
runs every item to completion: verdicts that land after the operator decided
|
||||
still stream to the UI and persist, stamped with the decision. The daemon is
|
||||
aborted only when the next tool batch supersedes it or the session closes —
|
||||
then each unfinished item degrades to an `llm_fallback` verdict. With
|
||||
`cancel_on_approval = true` the abort additionally fires the moment the gate
|
||||
resolves, trading verdict completeness for inference savings — recommended
|
||||
when the judge shares a single local inference backend with the session model,
|
||||
where a large batch's remaining judge calls would otherwise compete with the
|
||||
next turn's completion.
|
||||
|
||||
Verdicts that arrive after a *newer batch* has replaced the judge generation
|
||||
are withheld from the live surfaces (a reused call_id must never ride a stale
|
||||
`approve` into Smart Approvals) but still persist with
|
||||
`user_decision = "superseded"` so the audit trail records the judge's answer.
|
||||
|
||||
Sub-agents (plan agent, task agent) are exempt from intent validation -- they
|
||||
always get full tool visibility without judge evaluation.
|
||||
|
||||
@@ -259,13 +221,7 @@ All verdicts are persisted to the `intent_verdicts` table (migration 012):
|
||||
|
||||
- Heuristic verdicts are stored when the `approve_request` event is emitted
|
||||
- LLM verdicts are stored when the `intent_verdict` event is delivered
|
||||
- The `user_decision` column is updated when the user approves or denies;
|
||||
auto-approved rows carry the bypass reason (`policy`, `blanket`,
|
||||
`auto_approve_tools`, `smart_approval`), and rows whose verdict landed only
|
||||
after a newer batch replaced the judge generation carry `superseded`
|
||||
- Every stored verdict — including the benign `risk_level = "none"` majority —
|
||||
is re-attached to its tool call on history replay, so a reloaded workstream
|
||||
shows the same verdict badges the live stream did
|
||||
- The `user_decision` column is updated when the user approves or denies
|
||||
|
||||
The console admin panel exposes verdict history via:
|
||||
|
||||
@@ -359,7 +315,7 @@ four independent risk axes:
|
||||
`Bash(*)` (unrestricted shell) is high risk. `Bash(git:*)` is low.
|
||||
Read-only tools are safe.
|
||||
|
||||
Results are stored in `risk_level` (tier: safe/low/medium/high/critical) and
|
||||
Results are stored in `scan_status` (tier: safe/low/medium/high/critical) and
|
||||
`scan_report` (JSON breakdown) on the `prompt_templates` table. These fields are
|
||||
system-managed and not editable via the admin API.
|
||||
|
||||
@@ -416,36 +372,10 @@ redact_secrets = true # auto-redact detected credentials (default)
|
||||
|
||||
Configurable at runtime via the admin Settings tab.
|
||||
|
||||
### Merge semantics (heuristic + LLM judge)
|
||||
|
||||
The chip is a **merge** of the two detectors (issue #560, "show, annotated"),
|
||||
not a winner-take-all:
|
||||
|
||||
- `risk_level` = **max**(heuristic, llm) and `flags` = **union**. A positive
|
||||
from either detector surfaces; a negative ("none") or failed/absent LLM
|
||||
**never lowers** a heuristic positive. The judge reads adversarial tool
|
||||
output, so it may raise the alarm but must not be able to hide a
|
||||
deterministic regex finding — defeating the judge can't erase the tripwire.
|
||||
- Credential **redaction** is a heuristic-only signal the LLM cannot override.
|
||||
- When the judge returned a verdict, its OWN verdict rides along as
|
||||
annotation (`judge_risk` / `confidence` / `reasoning` / `judge_model`) so
|
||||
the operator sees the judge's opinion even when it disagrees with the
|
||||
displayed (merged) risk.
|
||||
|
||||
The same merge runs live and on reconnect (both call
|
||||
`output_guard.merge_guard_display_payload`), so the chip can't drift between
|
||||
the two surfaces.
|
||||
|
||||
The MODEL on the other side of the conversation is shown the merged
|
||||
`risk_level` + `flags` (via the `GuardAdvisory` spliced into the tool-result
|
||||
envelope), but is **never** told the judge cleared a finding — a judge fooled
|
||||
into "none" must not get to talk the model out of caution. The judge's
|
||||
"benign" verdict is operator-facing only.
|
||||
|
||||
### SSE event: `output_warning`
|
||||
|
||||
When the merged finding is non-clean (or credentials were redacted), an
|
||||
`output_warning` SSE event is emitted to the frontend. A regex-only finding:
|
||||
When the output guard detects risk signals, an `output_warning` SSE event is
|
||||
emitted to the frontend:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -456,58 +386,25 @@ When the merged finding is non-clean (or credentials were redacted), an
|
||||
"flags": ["credential_leak"],
|
||||
"annotations": ["API key detected (sk-proj-...)"],
|
||||
"output_length": 1024,
|
||||
"redacted": true,
|
||||
"tier": "heuristic"
|
||||
"redacted": true
|
||||
}
|
||||
```
|
||||
|
||||
When the LLM judge returned a verdict, `tier` is `"llm"` and the event carries
|
||||
the judge's own verdict as annotation. Here the regex flagged MEDIUM but the
|
||||
judge assessed the output benign — the finding still surfaces (`risk_level`
|
||||
stays MEDIUM), annotated with the judge's dissent (`judge_risk: "none"`):
|
||||
The web UI renders this as an inline warning after the tool result. The CLI
|
||||
shows a colored terminal warning. The server forwards it as an
|
||||
`OutputWarningEvent` for console subscribers.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "output_warning",
|
||||
"call_id": "call_def456",
|
||||
"func_name": "web_fetch",
|
||||
"risk_level": "medium",
|
||||
"flags": ["camouflaged_injection"],
|
||||
"annotations": ["Authority-framed directive embedded in the document."],
|
||||
"output_length": 8192,
|
||||
"redacted": false,
|
||||
"tier": "llm",
|
||||
"judge_risk": "none",
|
||||
"confidence": 0.92,
|
||||
"reasoning": "Legitimate analyst commentary; no injection.",
|
||||
"judge_model": "gpt-5-mini"
|
||||
}
|
||||
```
|
||||
|
||||
`judge_risk` (the judge's OWN risk verdict, which may differ from the merged
|
||||
`risk_level`), `confidence` (0.0–1.0), `reasoning`, and `judge_model` are
|
||||
present only on the `"llm"` tier. The identical shape is projected onto
|
||||
history replay by `build_merged_output_assessment_payload`, so the inline chip
|
||||
renders the same live and on refresh.
|
||||
|
||||
The web UI renders this as an inline warning after the tool result — the
|
||||
`"llm"` tier adds a `⚖ LLM · NN%` badge (showing the judge's verdict when it
|
||||
differs from the displayed risk, e.g. `⚖ LLM: none · 92%`) and the judge's
|
||||
rationale. The CLI shows a colored terminal warning. The server forwards it as
|
||||
an `OutputWarningEvent` for console subscribers.
|
||||
|
||||
Assessments are persisted to the `output_assessments` table (one row per
|
||||
`(call_id, tier)`) for calibration. Raw tool output is never stored — only
|
||||
metadata: flags, risk level, annotations, output length, redaction status,
|
||||
and — for the LLM tier — confidence, reasoning, judge model, and latency.
|
||||
Assessments are persisted to the `output_assessments` table for v2
|
||||
calibration. Raw tool output is never stored — only metadata (flags, risk
|
||||
level, annotations, output length, redaction status).
|
||||
|
||||
### Session-level skill scan warning
|
||||
|
||||
When a skill with `risk_level` of `high` or `critical` is loaded into a
|
||||
When a skill with `scan_status` of `high` or `critical` is loaded into a
|
||||
session, a warning is emitted via `on_info`:
|
||||
|
||||
```
|
||||
⚠ Skill 'my-skill' has risk level: high.
|
||||
⚠ Skill 'my-skill' has scan status: high.
|
||||
Review scan report in admin panel before enabling in production.
|
||||
```
|
||||
|
||||
@@ -524,7 +421,7 @@ All three evaluation systems persist their assessments for future calibration:
|
||||
|-------|--------|-------------|
|
||||
| `intent_verdicts` | Intent judge (heuristic + LLM) | `func_name`, `risk_level`, `confidence`, `user_decision` |
|
||||
| `output_assessments` | Output guard | `func_name`, `risk_level`, `flags`, `redacted` |
|
||||
| `prompt_templates` | Skill scanner | `risk_level`, `scan_report`, `scan_version` |
|
||||
| `prompt_templates` | Skill scanner | `scan_status`, `scan_report`, `scan_version` |
|
||||
|
||||
Run v1 with all tools requiring manual approval to build a local dataset.
|
||||
In v2, calibration tooling will analyze this data to:
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
# MCP OAuth — per-user authorization for MCP servers
|
||||
|
||||
Turnstone supports **per-(user, MCP server) OAuth 2.1 + PKCE** delegation so each Turnstone user authorizes a remote MCP server with their own identity, rather than sharing a single bearer token across the deployment. This is the right shape for MCP servers that expose user-specific data (a personal CRM, an email inbox, a calendar) and for MCP servers that want per-user audit attribution.
|
||||
|
||||
Per-user OAuth is opt-in per `mcp_servers` row. Local-auth Turnstone installs with no `oauth_user` rows exercise zero new code paths — the entire feature is dark by default.
|
||||
|
||||
> **Note**: This is a separate authorization layer from Turnstone's own user authentication. A user who logs into Turnstone with a local username + password can still authorize a per-server OAuth MCP server. OIDC SSO and per-server OAuth are orthogonal.
|
||||
|
||||
---
|
||||
|
||||
## When to use which `auth_type`
|
||||
|
||||
The MCP server admin form exposes three authorization modes ("Multitenant Authorization"):
|
||||
|
||||
| `auth_type` | What it means | When to use |
|
||||
|---|---|---|
|
||||
| `none` | No headers attached. Open MCP server (or one gated by network policy only). | Internal MCP servers on a trusted network. |
|
||||
| `static` | One static bearer token, configured per server, sent on every request from every user. | Service-to-service MCP servers where per-user attribution doesn't matter, or single-tenant deployments. |
|
||||
| `oauth_user` *(recommended for user-data servers)* | Each user authorizes separately via OAuth 2.1 + PKCE; Turnstone stores per-user tokens encrypted at rest. | MCP servers that expose user-specific data or that want per-user audit attribution. |
|
||||
|
||||
Switching `auth_type` away from `oauth_user` orphans existing per-user tokens. Use the admin **bulk-revoke** affordance on the server row (Phase 9) to clear them, or let them expire naturally — they're inert without the matching `auth_type` value.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites for `auth_type=oauth_user`
|
||||
|
||||
1. **Encryption key**. Tokens are stored encrypted with Fernet. Set `[security] mcp_token_encryption_key` in `config.toml` (Turnstone won't start with an `oauth_user` row configured but no key installed). Rotate via `MultiFernet` — add the new key first, then later remove the old one once all rows have been re-encrypted.
|
||||
|
||||
2. **MCP server publishes RFC 9728 PRM and RFC 8414 AS metadata** *or* you configure the AS URL override on the server row. PKCE S256 is mandatory; Turnstone refuses to connect to authorization servers that don't advertise `code_challenge_methods_supported: ["S256"]`.
|
||||
|
||||
3. **OAuth client registration**. Two paths:
|
||||
- **Pre-registered** (most common): you create an OAuth client at the authorization server (manually, via admin console, or via Terraform), then paste the `client_id` / `client_secret` into the Turnstone admin form.
|
||||
- **Dynamic client registration** (RFC 7591): if the AS supports it and you select that mode in the admin form, Turnstone registers a client at first use and persists the `client_id` automatically.
|
||||
|
||||
4. **Redirect URI** registered at the authorization server: `https://your-turnstone-host/v1/api/mcp/oauth/callback`.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Per-server fields (admin UI)
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| Server URL | Yes | The MCP server's `streamable-http` base URL. |
|
||||
| Multitenant Authorization | Yes | `none` / `static` / `oauth_user` (recommended). |
|
||||
| Authorization Server URL | No | Override for RFC 9728 PRM discovery. Set when your AS endpoint differs from the MCP server URL (e.g., corporate AS protecting a third-party MCP). When unset, Turnstone falls back to PRM discovery against the MCP server itself. |
|
||||
| Client Registration | Yes (oauth_user) | `preregistered` or `dynamic`. |
|
||||
| Client ID | Yes (preregistered) | OAuth 2.0 client ID. Stored unencrypted. |
|
||||
| Client Secret | Optional (write-only) | OAuth 2.0 client secret (confidential client). Encrypted at rest. Written but never re-read by the API; field stays masked. |
|
||||
| Scopes | No | Space-separated default scope set requested at the authorize endpoint. Per-tool step-up may union additional scopes from a server's `insufficient_scope` response. |
|
||||
| Audience | No | RFC 8707 `resource=` parameter sent on every authorize and token request. Defaults to the MCP server URL when unset. Validate against the `aud` claim in returned JWT tokens. |
|
||||
|
||||
### Encryption key
|
||||
|
||||
```toml
|
||||
[security]
|
||||
mcp_token_encryption_key = "base64-fernet-key"
|
||||
# For rotation, list the keys in priority order — first is used for new
|
||||
# writes, all are tried for reads.
|
||||
# mcp_token_encryption_keys = ["new-key", "old-key"]
|
||||
```
|
||||
|
||||
Keep this in `config.toml` rather than environment variables. An in-process LLM with shell-tool access can read the server's environment via `env` / `os.environ` and exfiltrate any secret stored there; secrets in `config.toml` are only loaded into the server at startup and never re-read on a tool-driven path, so a prompt-injection attack against the agent cannot reach them.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. **First tool call** for a user against an `oauth_user` MCP server: pool dispatch finds no stored token, returns `mcp_consent_required` to the agent. Dashboard renders an inline "Connect" action card.
|
||||
|
||||
2. **User clicks Connect**: opens `/v1/api/mcp/oauth/start?server=<name>` in a popup. Browser redirects through the AS authorize endpoint, user grants consent, AS redirects back to `/v1/api/mcp/oauth/callback`. Turnstone exchanges code → tokens via PKCE, validates audience, encrypts, persists in `mcp_user_tokens`, redirects user back to the originating URL.
|
||||
|
||||
3. **Subsequent tool calls** by the same user against the same server reuse the persisted token via the per-(user, server) session pool. Tokens auto-refresh via the refresh-token grant when expired; failed refresh emits `mcp_consent_required` to drive re-consent.
|
||||
|
||||
4. **Step-up scope**: when a tool call hits `403` with `WWW-Authenticate: error="insufficient_scope"`, Turnstone emits `mcp_insufficient_scope` with the parsed scope set; the dashboard offers a "Connect with additional scopes" affordance that opens `/v1/api/mcp/oauth/start?server=<name>&scopes=<extra>` so the union of original + new scopes flows into the AS authorize request.
|
||||
|
||||
5. **User revoke** (settings modal): `DELETE /v1/api/mcp/oauth/connections/{server_name}` runs the authoritative local delete + best-effort RFC 7009 upstream revoke (fire-and-forget, capped at 256 concurrent in-flight tasks).
|
||||
|
||||
6. **Admin bulk-revoke** (Phase 9): `POST /v1/api/admin/mcp-servers/{name}/bulk-revoke` drops every user's token for the server. Upstream RFC 7009 revoke is intentionally **not** attempted in bulk (avoids N upstream HTTP calls per admin click); tokens at the AS expire naturally. Use the per-user revoke endpoint if you need guaranteed upstream invalidation.
|
||||
|
||||
---
|
||||
|
||||
## Admin status indicators
|
||||
|
||||
The MCP Servers admin tab shows per-server status pills (Phase 9):
|
||||
|
||||
- **Consented users count** — distinct users with a non-expired token for this server. Surfaced as a `bulk-revoke (N)` button when ≥1; clicking it opens a confirmation dialog. Hidden when 0.
|
||||
- **Last refresh** — timestamp + outcome (`ok` / `error:ClassName`) of the most recent manual or auto-reconnect refresh. Per node. Absent until at least one refresh has occurred (renders as "never" in the admin UI).
|
||||
|
||||
Additional indicators (circuit-breaker state, encryption-key mismatch) are exposed via `get_server_status` on the API but do not yet have a dedicated admin pill — operators see them today via the per-server status text + error tooltip and in audit logs. A future phase may surface these as discrete pills.
|
||||
|
||||
---
|
||||
|
||||
## Auth-type transitions
|
||||
|
||||
| From | To | What happens |
|
||||
|---|---|---|
|
||||
| `none` / `static` → `oauth_user` | — | New code path activates for this server. Existing static headers (if any) are no longer sent. Users must authorize on first use. |
|
||||
| `oauth_user` → `none` / `static` | — | Existing `mcp_user_tokens` rows are **orphaned** — inert without a matching `auth_type`. Use admin bulk-revoke to drop them, or let them expire. Switching back to `oauth_user` later re-activates the orphaned rows if they haven't been deleted. |
|
||||
| OAuth `client_id` or `client_secret` rotated | — | Existing tokens may stop refreshing if the AS treats them as bound to the previous client. Bulk-revoke after rotation. |
|
||||
|
||||
The orphan-by-default behavior is chosen so switching back to `oauth_user` is non-destructive. Bulk-revoke is the explicit cleanup path.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Action |
|
||||
|---|---|---|
|
||||
| `mcp_consent_required` even after consenting | Token persistence failed, or refresh-token rejected by AS | Check audit log for `mcp_server.oauth.persist_failed` or `mcp_server.oauth.token_revoked`. Re-consent via settings modal. |
|
||||
| `mcp_token_undecryptable_key_unknown` | Encryption key rotated without keeping the previous key in the keyring | Add the previous key back to `mcp_token_encryption_keys` until all rows have been re-encrypted, then drop. |
|
||||
| `mcp_oauth_url_insecure` | MCP server URL is `http://` (not `https://`) on a non-loopback host | Use `https://`. Per-user bearers must not transit cleartext. |
|
||||
| Tools fail in scheduled / Discord / Slack runs | OAuth-MCP requires browser-based consent | Users must pre-consent via the web UI. Phase 9 dashboard badge surfaces deferred consents from these runs on next login. |
|
||||
| Circuit breaker open repeatedly | Transport-level errors on the MCP server (DNS, TLS, 5xx) | Check the per-server error pill; auth errors do not trip the breaker. |
|
||||
|
||||
See also: `docs/operations/mcp-oauth-headless.md` for the cron / channel-driven run caveat.
|
||||
@@ -146,7 +146,7 @@ with TurnstoneConsole("http://localhost:8081", token="...") as client:
|
||||
### TypeScript
|
||||
|
||||
```typescript
|
||||
import { TurnstoneConsole } from "@turnstone/sdk";
|
||||
import { TurnstoneConsole } from "@anthropic/turnstone-sdk";
|
||||
|
||||
const client = new TurnstoneConsole({
|
||||
baseUrl: "http://localhost:8081",
|
||||
|
||||
+13
-82
@@ -39,19 +39,18 @@ are set.
|
||||
| `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 continue to work. |
|
||||
| `TURNSTONE_OIDC_REDIRECT_BASE` | Yes | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Without this, OIDC will refuse to start. The previous Host-header fallback was unsafe under permissive reverse proxies. |
|
||||
| `TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS` | No | — | Comma-separated list of additional hostnames whose endpoints the IdP discovery document is allowed to reference. See [Cross-host endpoints](#cross-host-endpoints). |
|
||||
| `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. |
|
||||
|
||||
All four required fields — issuer, client ID, client secret, and
|
||||
`TURNSTONE_OIDC_REDIRECT_BASE` — must be set. If any are missing OIDC
|
||||
is disabled at startup (an error is logged when only `redirect_base`
|
||||
is missing) and the login screen shows only the password form.
|
||||
OIDC is enabled when all three required fields (issuer, client ID, client
|
||||
secret) are non-empty. If any is missing, OIDC is silently disabled and
|
||||
the login screen shows only the password form.
|
||||
|
||||
### Redirect base (required)
|
||||
### Reverse Proxy / Load Balancer
|
||||
|
||||
`TURNSTONE_OIDC_REDIRECT_BASE` pins the redirect URI sent to the identity
|
||||
provider to a known externally-visible origin. Set it to the public origin
|
||||
of your Turnstone deployment:
|
||||
When Turnstone runs behind a reverse proxy, the internal `Host` header may
|
||||
not match the externally-reachable URL. Set `TURNSTONE_OIDC_REDIRECT_BASE`
|
||||
to the public origin so the redirect URI sent to the identity provider is
|
||||
correct:
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_REDIRECT_BASE=https://app.example.com
|
||||
@@ -61,44 +60,6 @@ The resulting callback URL will be
|
||||
`https://app.example.com/v1/api/auth/oidc/callback` — register this as the
|
||||
authorized redirect URI in your identity provider.
|
||||
|
||||
OIDC will refuse to start when this variable is unset. There is no
|
||||
Host-header fallback: a permissive reverse proxy or direct backend access
|
||||
would otherwise let an attacker spoof `Host` and steer the IdP redirect
|
||||
to a callback origin they control.
|
||||
|
||||
### Cross-host endpoints
|
||||
|
||||
By default, every endpoint in the IdP discovery document
|
||||
(`token_endpoint`, `jwks_uri`, `userinfo_endpoint`) must share the
|
||||
issuer's `(scheme, host, port)`. This prevents a hostile or compromised
|
||||
IdP from redirecting the token-exchange POST (which carries
|
||||
`client_secret`) to an arbitrary host, and prevents JWKS fetches from
|
||||
being aimed at internal services.
|
||||
|
||||
A few public IdPs legitimately split endpoints across hostnames. Google
|
||||
is the canonical example:
|
||||
|
||||
| Field | Hostname |
|
||||
|-------|----------|
|
||||
| issuer | `accounts.google.com` |
|
||||
| token_endpoint | `oauth2.googleapis.com` |
|
||||
| jwks_uri | `www.googleapis.com` |
|
||||
| userinfo_endpoint | `openidconnect.googleapis.com` |
|
||||
|
||||
Google's set is built in — operators using `https://accounts.google.com`
|
||||
need no extra configuration.
|
||||
|
||||
For other IdPs whose discovery document references a non-issuer host,
|
||||
extend the allow-list explicitly:
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS=token.example.com,keys.example.com
|
||||
```
|
||||
|
||||
The same scheme / no-userinfo / SSRF rules apply to allow-listed hosts —
|
||||
this knob only relaxes the same-origin check, not the security gates.
|
||||
Each entry is a hostname (no scheme, no path).
|
||||
|
||||
### config.toml alternative
|
||||
|
||||
```toml
|
||||
@@ -237,19 +198,6 @@ TURNSTONE_OIDC_ROLE_MAP="admin:builtin-admin,engineering:builtin-operator,viewer
|
||||
the user authenticates via OIDC, so new group memberships are picked
|
||||
up on the next login.
|
||||
|
||||
### `assigned_by` markers
|
||||
|
||||
Role assignments record an `assigned_by` value that controls how the
|
||||
sync logic treats them. OIDC-driven flows use two distinct markers:
|
||||
|
||||
- `oidc` — set by claim-driven role mapping; revoked automatically on
|
||||
the next login when the corresponding claim value is no longer
|
||||
present.
|
||||
- `oidc-default` — applied to brand-new OIDC users who have no
|
||||
claim-mapped roles, as a safety net so they still get
|
||||
`builtin-viewer` access on first login. Survives subsequent logins
|
||||
regardless of claim contents and is never revoked by `apply_role_mapping`.
|
||||
|
||||
### Built-in Roles
|
||||
|
||||
| Role ID | Permissions |
|
||||
@@ -427,27 +375,10 @@ callback validation. Entries are automatically cleaned up after 5 minutes.
|
||||
|
||||
### "OIDC not configured"
|
||||
|
||||
All four required environment variables must be set:
|
||||
`TURNSTONE_OIDC_ISSUER`, `TURNSTONE_OIDC_CLIENT_ID`,
|
||||
`TURNSTONE_OIDC_CLIENT_SECRET`, and `TURNSTONE_OIDC_REDIRECT_BASE`.
|
||||
Check that none are empty or whitespace-only.
|
||||
|
||||
### "OIDC enabled but TURNSTONE_OIDC_REDIRECT_BASE is unset"
|
||||
|
||||
This error is logged when the three credential variables are set but
|
||||
`TURNSTONE_OIDC_REDIRECT_BASE` is missing. OIDC is disabled at startup
|
||||
to prevent Host-header-derived redirect URI spoofing. Set the variable
|
||||
to your service's externally-visible origin (e.g.
|
||||
`https://app.example.com`) and restart the server. See
|
||||
[Redirect base](#redirect-base-required) for the rationale.
|
||||
|
||||
### Discovery silently disables OIDC with "host does not match issuer"
|
||||
|
||||
The IdP discovery document points `token_endpoint`, `jwks_uri`, or
|
||||
`userinfo_endpoint` at a hostname that doesn't share the issuer's
|
||||
origin. If the IdP is legitimate, add the additional hostname(s) to
|
||||
`TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS`. Google is allow-listed
|
||||
automatically; see [Cross-host endpoints](#cross-host-endpoints).
|
||||
All three required environment variables must be set:
|
||||
`TURNSTONE_OIDC_ISSUER`, `TURNSTONE_OIDC_CLIENT_ID`, and
|
||||
`TURNSTONE_OIDC_CLIENT_SECRET`. Check that none are empty or
|
||||
whitespace-only.
|
||||
|
||||
### "Login session expired"
|
||||
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ cannot bypass the proxy.
|
||||
|--------|-------|---------|
|
||||
| `openai_api` | `api.openai.com` | OpenAI LLM API |
|
||||
| `anthropic_api` | `api.anthropic.com` | Anthropic LLM API |
|
||||
| `searxng` | `searxng:8080` (bundled service) | Web search backend |
|
||||
| `tavily_api` | `api.tavily.com` | Web search fallback |
|
||||
| `skills_registry` | `skills.sh` | Skill discovery |
|
||||
| `github_api` | `api.github.com` (read-only L7), `raw.githubusercontent.com` | Skill fetch, GitHub API |
|
||||
| `mcp_registry` | `registry.modelcontextprotocol.io` (read-only L7) | MCP server discovery |
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# MCP OAuth in headless / scheduled / channel-driven runs
|
||||
|
||||
**Constraint**: OAuth-MCP servers (`auth_type=oauth_user`) require browser-based user consent. Users must pre-consent via the web UI before any run that cannot drive a browser redirect.
|
||||
|
||||
**Affected surfaces**:
|
||||
|
||||
- Scheduled workstreams (`turnstone-console` task scheduler).
|
||||
- Discord adapter runs.
|
||||
- Slack adapter runs.
|
||||
- Any future channel adapter without an interactive browser session.
|
||||
|
||||
**What happens when consent is missing**:
|
||||
|
||||
A tool call against an `oauth_user` server returns a structured `mcp_consent_required` error to the agent. The agent surfaces the deferred work in its output. Turnstone persists a record to `mcp_pending_consent` so the dashboard badge surfaces the deferred consent need to the user on next login.
|
||||
|
||||
**Recovery**:
|
||||
|
||||
The user opens the dashboard, sees the gear-icon badge counting pending consents, opens the settings modal, clicks Connect for each affected server, and completes the OAuth dance. The pending-consent record is cleared by the OAuth callback handler on success. Subsequent scheduled / channel runs use the freshly-stored token.
|
||||
|
||||
**Pre-consent recipe**:
|
||||
|
||||
Before scheduling a workstream that depends on an `oauth_user` MCP server, the user should:
|
||||
|
||||
1. Open the dashboard.
|
||||
2. Open the settings modal (gear icon).
|
||||
3. Click Connect on each MCP server the schedule will use.
|
||||
4. Confirm consent in the popup.
|
||||
|
||||
This stores tokens that the scheduled run will reuse. Refresh-token rotation is handled transparently on the run side; only the first consent requires browser interaction.
|
||||
+13
-39
@@ -40,20 +40,18 @@ Add PgBouncer between turnstone services and PostgreSQL:
|
||||
```yaml
|
||||
services:
|
||||
pgbouncer:
|
||||
image: edoburu/pgbouncer:latest
|
||||
image: bitnami/pgbouncer:latest
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: ${POSTGRES_DB:-turnstone}
|
||||
DB_USER: ${POSTGRES_USER:-turnstone}
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD:?}
|
||||
LISTEN_PORT: "6432"
|
||||
AUTH_TYPE: ${POSTGRES_AUTH_TYPE:-scram-sha-256}
|
||||
POOL_MODE: transaction
|
||||
DEFAULT_POOL_SIZE: "40"
|
||||
MAX_CLIENT_CONN: "5000"
|
||||
MAX_DB_CONNECTIONS: "80"
|
||||
SERVER_IDLE_TIMEOUT: "300"
|
||||
POSTGRESQL_HOST: postgres
|
||||
POSTGRESQL_PORT: "5432"
|
||||
POSTGRESQL_DATABASE: turnstone
|
||||
POSTGRESQL_USERNAME: ${POSTGRES_USER:-turnstone}
|
||||
POSTGRESQL_PASSWORD: ${POSTGRES_PASSWORD:?}
|
||||
PGBOUNCER_POOL_MODE: transaction
|
||||
PGBOUNCER_DEFAULT_POOL_SIZE: "40"
|
||||
PGBOUNCER_MAX_CLIENT_CONN: "5000"
|
||||
PGBOUNCER_MAX_DB_CONNECTIONS: "80"
|
||||
PGBOUNCER_SERVER_IDLE_TIMEOUT: "300"
|
||||
ports:
|
||||
- "6432:6432"
|
||||
networks:
|
||||
@@ -69,7 +67,7 @@ services:
|
||||
```
|
||||
|
||||
Then point turnstone services at PgBouncer instead of PostgreSQL
|
||||
directly by changing `TURNSTONE_DB_URL`:
|
||||
directly by changing the `DATABASE_URL` (or `TURNSTONE_DB_URL`):
|
||||
|
||||
```bash
|
||||
# Before (direct)
|
||||
@@ -84,7 +82,7 @@ TURNSTONE_DB_URL=postgresql://turnstone:secret@pgbouncer:6432/turnstone
|
||||
## Helm / Kubernetes
|
||||
|
||||
Add a PgBouncer deployment or use a Helm chart like
|
||||
[edoburu/pgbouncer](https://github.com/edoburu/docker-pgbouncer/tree/master/examples/kubernetes).
|
||||
[bitnami/pgbouncer](https://github.com/bitnami/charts/tree/main/bitnami/pgbouncer).
|
||||
|
||||
In `values.yaml`, point the database at PgBouncer:
|
||||
|
||||
@@ -199,28 +197,4 @@ does not support prepared statements. Turnstone's SQLAlchemy layer does
|
||||
not use server-side prepared statements by default, so this is not an
|
||||
issue.
|
||||
|
||||
**LISTEN / NOTIFY not supported in transaction mode** — PgBouncer's
|
||||
transaction pooling assigns a real server connection only for the
|
||||
duration of each transaction, then returns it to the pool. PostgreSQL
|
||||
`LISTEN` is session state — a transaction-pooled client can't hold the
|
||||
multi-statement session a long-lived `LISTEN` needs. The console's
|
||||
`NotifyDispatcher` (reactive node discovery via the `services` channel)
|
||||
therefore opens a **dedicated, direct-to-Postgres** connection that
|
||||
bypasses PgBouncer.
|
||||
|
||||
Configure via `config.toml` `[database] listen_url` (preferred —
|
||||
co-located with the main `url`) or the `TURNSTONE_DB_LISTEN_URL` env var
|
||||
(config.toml wins when both are set). Defaults to the main DB URL when
|
||||
unset.
|
||||
|
||||
| Setting | Behaviour |
|
||||
|---|---|
|
||||
| unset | Listener uses `TURNSTONE_DB_URL` as-is. Fine when PgBouncer is in **session** mode, or when there's no pooler in front of Postgres. With transaction-mode PgBouncer the listener's `LISTEN` will fail and the dispatcher retries with exponential backoff (1 s → 30 s cap) without ever succeeding. Reactive NOTIFY-driven node discovery is silently lost; the cluster collector's 60 s `_discovery_loop` is the only remaining backstop. |
|
||||
| set to direct-to-PG URL (e.g. `postgresql://…/turnstone`) | Listener bypasses PgBouncer for its one dedicated connection. Reactive discovery latency drops from up-to-60 s to ~500 ms. The rest of the storage layer continues to go through PgBouncer in transaction mode. |
|
||||
|
||||
Set this whenever PgBouncer is in transaction mode (the recommended
|
||||
setting per this doc). The override only adds one long-lived PG
|
||||
connection per console process — sized into the cluster's
|
||||
`max_connections` budget alongside the pool.
|
||||
|
||||
See also: [Docker deployment](docker.md) · [Security](security.md)
|
||||
|
||||
+15
-25
@@ -1,25 +1,17 @@
|
||||
# Release Process
|
||||
|
||||
Turnstone ships several parallel release tracks from a single PyPI package.
|
||||
Turnstone uses two parallel release tracks published from a single PyPI package.
|
||||
|
||||
## Release Tracks
|
||||
|
||||
| Track | Versions | Branch | Docker tags | PyPI install |
|
||||
|-------|----------|--------|-------------|--------------|
|
||||
| **Stable 1.5** | `1.5.x` | `stable/1.5` | `:1.5.x`, `:1.5` | `pip install 'turnstone==1.5.*'` |
|
||||
| **Stable 1.6** | `1.6.x` | `stable/1.6` | `:1.6.x`, `:1.6`, `:stable`, `:latest` | `pip install turnstone` |
|
||||
| **Experimental** | `1.7.0aN` | `main` | `:1.7.0aN`, `:experimental` | `pip install turnstone --pre` |
|
||||
| **Stable** | `1.1.0`, `1.1.1` | `stable/1.1` | `:1.1.0`, `:1.1`, `:stable`, `:latest` | `pip install turnstone` |
|
||||
| **Experimental** | `1.2.0a1`, `1.2.0a2` | `main` | `:1.2.0a1`, `:experimental` | `pip install turnstone --pre` |
|
||||
|
||||
- **Stable** tracks receive bugfixes only. The most-recent stable minor
|
||||
owns the `:stable` / `:latest` Docker tags and the default PyPI
|
||||
install.
|
||||
- **Experimental** (always on `main`) receives new features. May be
|
||||
rough around the edges.
|
||||
- When experimental matures, it is promoted to a new stable minor via
|
||||
a `stable/X.Y` branch. One prior stable track is maintained alongside
|
||||
the current one; at each promotion the oldest track is retired — its
|
||||
branch is deleted, while its tags and released artifacts remain
|
||||
available.
|
||||
- **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
|
||||
|
||||
@@ -34,17 +26,17 @@ Turnstone ships several parallel release tracks from a single PyPI package.
|
||||
## Releasing an Experimental Version (from main)
|
||||
|
||||
```bash
|
||||
scripts/release.sh 1.7.0a2 --push
|
||||
scripts/release.sh 1.1.0a2 --push
|
||||
```
|
||||
|
||||
This bumps `pyproject.toml` + `turnstone/__init__.py`, regenerates `uv.lock`, commits, tags `v1.7.0a2`, and pushes. CI runs, then publish + Docker workflows fire automatically.
|
||||
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.6
|
||||
git checkout stable/1.0
|
||||
git cherry-pick <commit-hash> # bugfix from main
|
||||
scripts/release.sh 1.6.1 --push
|
||||
scripts/release.sh 1.0.2 --push
|
||||
```
|
||||
|
||||
## Promoting Experimental to Stable
|
||||
@@ -53,19 +45,17 @@ When `main` is ready for a stable release:
|
||||
|
||||
```bash
|
||||
# 1. Tag the stable release on main
|
||||
scripts/release.sh 1.6.0 --push
|
||||
scripts/release.sh 1.1.0 --push
|
||||
|
||||
# 2. Create the stable maintenance branch from that tag
|
||||
git branch stable/1.6 v1.6.0
|
||||
git push origin stable/1.6
|
||||
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.7.0a1 --push
|
||||
scripts/release.sh 1.2.0a1 --push
|
||||
```
|
||||
|
||||
The previous stable branch continues to receive security-only patches;
|
||||
the track before it is retired at each promotion (at 1.6.0:
|
||||
`stable/1.5` stays maintained, `stable/1.4` is retired).
|
||||
The previous `stable/1.0` branch stops receiving patches at this point.
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
|
||||
+4
-39
@@ -69,14 +69,11 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|
||||
|----------|--------|---------|
|
||||
| **Workstreams** | `list_workstreams()` | `ListWorkstreamsResponse` |
|
||||
| | `dashboard()` | `DashboardResponse` |
|
||||
| | `create_workstream(*, name, model, auto_approve, skill, initial_message, attachments)` | `CreateWorkstreamResponse` |
|
||||
| | `create_workstream(*, name, model, auto_approve, skill)` | `CreateWorkstreamResponse` |
|
||||
| | `close_workstream(ws_id)` | `StatusResponse` |
|
||||
| **Attachments** | `upload_attachment(ws_id, filename, data, *, mime_type=...)` | `UploadAttachmentResponse` |
|
||||
| | `list_attachments(ws_id)` | `ListAttachmentsResponse` |
|
||||
| | `get_attachment_content(ws_id, attachment_id)` | `bytes` |
|
||||
| | `delete_attachment(ws_id, attachment_id)` | `StatusResponse` |
|
||||
| **Chat** | `send(message, ws_id)` | `SendResponse` |
|
||||
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
|
||||
| | `plan_feedback(*, ws_id, feedback)` | `StatusResponse` |
|
||||
| | `command(*, ws_id, command)` | `StatusResponse` |
|
||||
| | `cancel(ws_id, *, force=False)` | `StatusResponse` |
|
||||
| **Streaming** | `stream_events(ws_id)` | `Iterator[ServerEvent]` |
|
||||
@@ -133,12 +130,10 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
|
||||
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output`, `is_error` |
|
||||
| `tool_output_chunk` | `ToolOutputChunkEvent` | `call_id`, `chunk` |
|
||||
| `status` | `StatusEvent` | `prompt_tokens`, `total_tokens`, `pct`, `effort`, `cache_creation_tokens`, `cache_read_tokens` |
|
||||
| `plan_review` | `PlanReviewEvent` | `content` |
|
||||
| `error` | `ErrorEvent` | `message` |
|
||||
| `info` | `InfoEvent` | `message` |
|
||||
| `stream_end` | `StreamEndEvent` | — |
|
||||
| `state_change` | `StateChangeEvent` | `state` ∈ `running`/`thinking`/`attention`/`idle`/`error` |
|
||||
| `in_progress_snapshot` | `InProgressSnapshotEvent` | `content`, `reasoning` (one-shot mid-stream refresh resume) |
|
||||
| `approval_resolved` | `ApprovalResolvedEvent` | `approved`, `feedback` |
|
||||
| `cancelled` | `CancelledEvent` | — |
|
||||
|
||||
**Global events** (from `stream_global_events()`):
|
||||
@@ -176,36 +171,6 @@ result.ok # True if no errors and not timed out
|
||||
result.timed_out # True if timeout expired
|
||||
```
|
||||
|
||||
### Attachments
|
||||
|
||||
Upload files to a workstream and attach them to the next user turn:
|
||||
|
||||
```python
|
||||
# Upload separately, then send a message — attachments auto-attach
|
||||
with open("screenshot.png", "rb") as f:
|
||||
att = client.upload_attachment(ws.ws_id, "screenshot.png",
|
||||
f.read(),
|
||||
mime_type="image/png")
|
||||
client.send("What's wrong in this screenshot?", ws.ws_id)
|
||||
|
||||
# Or attach at workstream-creation time (multipart upload)
|
||||
from turnstone.sdk import AttachmentUpload
|
||||
|
||||
with open("notes.txt", "rb") as f:
|
||||
ws = client.create_workstream(
|
||||
name="triage",
|
||||
initial_message="Summarize the notes",
|
||||
attachments=[AttachmentUpload(data=f.read(),
|
||||
filename="notes.txt",
|
||||
mime_type="text/plain")],
|
||||
)
|
||||
```
|
||||
|
||||
Limits: images ≤ 4 MiB (png/jpeg/gif/webp), text ≤ 512 KiB (UTF-8),
|
||||
10 pending per (workstream, user). The SDK auto-generates `ws_id` on the
|
||||
client so cluster-routed callers bind attachments to the owning node
|
||||
before the request lands.
|
||||
|
||||
### Error Handling
|
||||
|
||||
Non-2xx responses raise `TurnstoneAPIError`:
|
||||
@@ -319,7 +284,7 @@ turnstone/sdk/ Python SDK (sub-package)
|
||||
_base.py Shared httpx async client, auth, error handling
|
||||
_sync.py Background event loop for sync wrappers
|
||||
_types.py TurnResult + TurnstoneAPIError
|
||||
events.py 38 SSE event dataclasses with type registry
|
||||
events.py 27 SSE event dataclasses with type registry
|
||||
server.py AsyncTurnstoneServer + TurnstoneServer
|
||||
console.py AsyncTurnstoneConsole + TurnstoneConsole
|
||||
|
||||
|
||||
+8
-11
@@ -1,10 +1,8 @@
|
||||
# Security and Authentication
|
||||
|
||||
Turnstone uses a layered authentication system with two token types
|
||||
(database-backed API tokens + HMAC-SHA256 JWTs), hierarchical scopes,
|
||||
and a split architecture where the console manages credentials while
|
||||
individual server nodes validate JWTs locally. Inter-service traffic
|
||||
uses short-lived service JWTs minted by `ServiceTokenManager`.
|
||||
Turnstone uses a layered authentication system with three token types,
|
||||
hierarchical scopes, and a split architecture where the console manages
|
||||
credentials while individual server nodes validate JWTs locally.
|
||||
|
||||
---
|
||||
|
||||
@@ -39,7 +37,7 @@ Claims:
|
||||
|-------|-------------|
|
||||
| `sub` | User ID |
|
||||
| `scopes` | Comma-separated scope list (`read,write,approve`) |
|
||||
| `src` | Token source (`password`, `database`, `oidc`, or a service origin like `console`, `cli`, or `channel`) |
|
||||
| `src` | Token source (`password`, `api_token`, `config`, `oidc`) |
|
||||
| `iss` | Issuer — always `turnstone` |
|
||||
| `aud` | Audience — `turnstone-server` or `turnstone-console` |
|
||||
| `iat` | Issued-at timestamp |
|
||||
@@ -67,11 +65,10 @@ Scopes are hierarchical — higher scopes imply all lower ones.
|
||||
| Method | Path pattern | Required scope |
|
||||
|--------|-------------|----------------|
|
||||
| GET | Any protected path | `read` |
|
||||
| POST | `/api/command` | `write` |
|
||||
| POST | `/api/workstreams/new`, `/api/cluster/workstreams/new` | `write` |
|
||||
| POST | `/api/workstreams/{ws_id}/{send,cancel,close,delete,open,refresh-title,title,attachments}` | `write` |
|
||||
| DELETE | `/api/workstreams/{ws_id}/send` (dequeue), `/api/workstreams/{ws_id}/attachments/{attachment_id}` | `write` |
|
||||
| POST | `/api/workstreams/{ws_id}/approve` | `approve` |
|
||||
| POST | `/api/send`, `/api/plan`, `/api/command` | `write` |
|
||||
| POST | `/api/workstreams/new`, `/api/workstreams/close` | `write` |
|
||||
| POST | `/api/cluster/workstreams/new` | `write` |
|
||||
| POST | `/api/approve` | `approve` |
|
||||
| Any | `/api/admin/*` | `approve` |
|
||||
|
||||
Public paths bypass authentication entirely: `/`, `/health`, `/metrics`,
|
||||
|
||||
+4
-57
@@ -36,59 +36,6 @@ users to the admin Settings API.
|
||||
|
||||
---
|
||||
|
||||
## Per-Model Sampling Overrides
|
||||
|
||||
The global `model.temperature`, `model.max_tokens`, and `model.reasoning_effort`
|
||||
settings serve as cluster-wide defaults. Individual models can override these
|
||||
via per-model settings in the `model_definitions` table (admin Models tab).
|
||||
|
||||
Resolution order for sampling parameters:
|
||||
|
||||
| Priority | Source |
|
||||
|----------|--------|
|
||||
| 1 (highest) | Per-model override (set in Models tab) |
|
||||
| 2 | Global default (set in Settings tab) |
|
||||
| 3 | Registry default (code) |
|
||||
|
||||
When a per-model override is `NULL` (empty in the UI), the global default is
|
||||
used. Switching models via `/model <alias>` re-resolves sampling parameters
|
||||
from the new model's overrides or global defaults.
|
||||
|
||||
**Removed settings:** `model.name` and `model.context_window` have been removed
|
||||
from ConfigStore. Model names and context windows are now configured per-model
|
||||
in the Models tab. A startup warning is logged if these keys appear in
|
||||
`config.toml`.
|
||||
|
||||
### Reasoning persistence (per-model)
|
||||
|
||||
Two boolean flags on `model_definitions` (migration 052) control how
|
||||
reasoning text round-trips per model:
|
||||
|
||||
| Flag | Default | Effect |
|
||||
|------|---------|--------|
|
||||
| `surface_persisted_reasoning` | `True` | Surface stored reasoning text on `/history` payloads so a page reload re-renders the reasoning bubble. **Storage of reasoning bytes is independent of this flag** — they ride in `provider_data` regardless. |
|
||||
| `replay_reasoning_to_model` | `False` | Send stored reasoning blocks back to the provider on subsequent turns. Capability-gated: only takes effect when the model's `ModelCapabilities.supports_reasoning_replay` is also `True`. Set on canonical OpenAI gpt-5*/o-series and Anthropic Claude entries; unknown / local-server models default to `False` so an operator who flips the flag on a model whose API doesn't understand reasoning replay silently no-ops rather than 400-ing. |
|
||||
|
||||
Edit both via the admin Models tab. See the architecture doc for the
|
||||
provider-side mechanics (Anthropic `thinking`, OpenAI Responses
|
||||
`reasoning` + `include=["reasoning.encrypted_content"]`, synthetic
|
||||
`reasoning_text` for Chat Completions / vLLM / llama.cpp / Gemini-compat).
|
||||
|
||||
### Task agent overrides
|
||||
|
||||
`task_agent` sub-sessions resolve independently from the conversation model
|
||||
so operators can pick a cheaper/faster model for autonomous loops:
|
||||
|
||||
| Setting | Purpose |
|
||||
|---------|---------|
|
||||
| `model.task_alias` | Alias used for `task_agent` sub-sessions. Falls back to `[model].agent_model` in config.toml, then the session's active model. |
|
||||
| `model.task_effort` | Reasoning effort for `task_agent`. Empty string means "inherit from the session". |
|
||||
|
||||
Both are live-editable from the Settings tab and take effect on the
|
||||
next sub-agent invocation — no restart required.
|
||||
|
||||
---
|
||||
|
||||
## Bootstrap vs ConfigStore
|
||||
|
||||
**Bootstrap settings** are required before storage is available (database
|
||||
@@ -102,17 +49,17 @@ connection, Redis, auth secrets, server bind address). These stay in
|
||||
| Auth | `[auth]` | config.toml / env |
|
||||
| Console bind | `[console]` | config.toml / env |
|
||||
|
||||
**ConfigStore settings** are loaded from the database after storage
|
||||
initialization:
|
||||
**ConfigStore settings** (51 settings) are loaded from the database after
|
||||
storage initialization:
|
||||
|
||||
| Section | Settings |
|
||||
|---------|----------|
|
||||
| `model` | default_alias, temperature, max_tokens, reasoning_effort, task_alias, task_effort |
|
||||
| `model` | name, temperature, max_tokens, reasoning_effort, context_window |
|
||||
| `session` | instructions, retention_days, compact_max_tokens, auto_compact_pct |
|
||||
| `tools` | timeout, truncation, agent_max_turns, skip_permissions, search, search_threshold, search_max_results |
|
||||
| `server` | workstream_idle_timeout, max_workstreams |
|
||||
| `cluster` | node_fan_out_limit, mcp_max_servers |
|
||||
| `mcp` | config_path, registry_url |
|
||||
| `mcp` | config_path, refresh_interval, registry_url |
|
||||
| `ratelimit` | enabled, requests_per_second, burst, trusted_proxies |
|
||||
| `health` | backend_probe_interval, backend_probe_timeout, circuit_breaker_threshold, circuit_breaker_cooldown |
|
||||
| `judge` | enabled, model, provider, base_url, api_key, confidence_threshold, max_context_ratio, timeout, read_only_tools, output_guard, redact_secrets, cancel_on_approval |
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
---
|
||||
name: import-conversation-history
|
||||
description: Use this skill when the user wants to import or migrate conversation history from another LLM chat or coding tool (e.g. ChatGPT, Claude.ai, Cursor, Copilot Chat, Aider, Gemini, a custom JSON export) into Turnstone. The skill teaches Turnstone's destination contracts — workstream identity, the OpenAI-shaped message rows, tool-call/result pairing, provider-fidelity blobs, attachments, and archive-vs-resumable choice — so the agent can map any source format onto them. Trigger phrases: "import my chats", "migrate this transcript into Turnstone", "bring my Claude.ai history over", "load this export as a workstream".
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Importing Conversation History into Turnstone
|
||||
|
||||
## Overview
|
||||
|
||||
Source formats vary; the destination does not. Your job is to translate whatever the user hands you (JSON dump, ZIP export, scraped HTML, screenshot OCR, raw transcript) into Turnstone's internal shape: **one workstream row** plus an ordered sequence of **conversation rows** in OpenAI message format. This skill documents the destination so you can write a correct mapper for any source.
|
||||
|
||||
Two questions to settle with the user before writing anything:
|
||||
|
||||
1. **Archive or resumable?** An archive ("saved" workstream — `state="closed"`) is read-only history. A resumable workstream (`state="idle"`) lets the user continue the conversation; this only works cleanly when the source LLM matches a Turnstone-supported provider/model and tool definitions still resolve.
|
||||
2. **One workstream per source thread, or merge?** Default to one-to-one unless the user explicitly asks to merge.
|
||||
|
||||
Default to **archive** when in doubt — resuming a foreign transcript with mismatched tool schemas or stale provider signatures will fail at the next turn.
|
||||
|
||||
## Turnstone Data Model (the destination)
|
||||
|
||||
Two tables carry the conversation:
|
||||
|
||||
### `workstreams` (one row per imported thread)
|
||||
|
||||
| Column | Required | Notes |
|
||||
|---|---|---|
|
||||
| `ws_id` | yes | 32-char lowercase hex. Auto-generate with `secrets.token_hex(16)` if you don't already have one. **First 4 hex chars are the routing bucket** — see "Identity & Routing" below. |
|
||||
| `name` | yes | Short title. Pull from source thread title; fall back to first ~60 chars of first user message. |
|
||||
| `state` | yes | `"closed"` for archive, `"idle"` for resumable. Never set `"running"` on import. |
|
||||
| `kind` | yes | `"interactive"` for normal threads. Do NOT use `"coordinator"` for imports — that's reserved for cluster-spawned coordinator workstreams. |
|
||||
| `parent_ws_id` | no | Leave NULL. Only set if you're importing a coordinator-spawned subtree and re-parenting it; rare. |
|
||||
| `user_id` | yes | Owner. Must exist in `users`; importer must know which Turnstone user owns the imported history. |
|
||||
| `node_id` | yes (multi-node) | Denormalized cache of the node that owns this `ws_id`'s bucket. Single-node deployments can leave it NULL or set it to the only node. |
|
||||
| `alias` | no | Human-typeable short name. Optional; must be unique cluster-wide if set. |
|
||||
| `title` | no | Auto-titled later by the LLM; safe to leave NULL on import. |
|
||||
| `skill_id`, `skill_version` | yes | Default `""` and `0` unless the source thread was scoped to a Turnstone skill. |
|
||||
| `created`, `updated` | yes | ISO8601 strings. Use the source's first/last message timestamps when available. |
|
||||
|
||||
### `conversations` (many rows per thread, ordered by `id`/`timestamp`)
|
||||
|
||||
| Column | Notes |
|
||||
|---|---|
|
||||
| `ws_id` | The workstream this row belongs to. |
|
||||
| `timestamp` | ISO8601 string. Preserve source timestamps; fall back to monotonically increasing values if unknown. **Order is canonical via `id` (autoincrement), not `timestamp`** — but always insert in conversational order so both agree. |
|
||||
| `role` | One of `system`, `user`, `assistant`, `tool`, `developer`. See role mapping below. |
|
||||
| `content` | Text. May be NULL for assistant rows that are *only* tool calls. |
|
||||
| `tool_name` | Set on `role="tool"` rows (the tool whose result this is). NULL otherwise. |
|
||||
| `tool_call_id` | Set on `role="tool"` rows (matches the assistant row's `tool_calls[].id`). NULL otherwise. |
|
||||
| `tool_calls` | JSON-encoded list, on `role="assistant"` rows that issued tool calls. OpenAI shape — see "Tool Calls" below. |
|
||||
| `provider_data` | JSON blob preserving provider-native content blocks (Anthropic `signature`, Gemini `thought_signature`, etc.). Optional; only matters for **resumable** imports against the same provider. Skip for archives. |
|
||||
|
||||
The internal format is **OpenAI-shaped**, even when the source was Anthropic or Gemini. Providers translate at their own API boundary; storage stays uniform.
|
||||
|
||||
## Identity & Routing (`ws_id`)
|
||||
|
||||
- `ws_id` is **32-char lowercase hex** (i.e. `secrets.token_hex(16)`).
|
||||
- The **routing bucket** is `int(ws_id[:4], 16)` — the first 4 hex chars place this workstream on a specific node via the consistent hash ring.
|
||||
- For multi-node imports: either insert through the console's routing proxy (which forwards to the owning node), or generate `ws_id`s and write directly to each node's database in batches grouped by bucket.
|
||||
- For single-node imports: bucket math is irrelevant; any `ws_id` works.
|
||||
- **Do not reuse the source platform's IDs as `ws_id`** unless they happen to be 32-char hex. Generate fresh; if you need the old ID for traceability, store it in `workstream_config` under a key like `import.source_id`.
|
||||
|
||||
## Recommended Import Path
|
||||
|
||||
Three options, in order of preference:
|
||||
|
||||
### 1. Storage protocol (recommended for full history)
|
||||
|
||||
Use `turnstone.core.storage.Storage.save_messages_bulk(rows)`. This is the canonical bulk-insert primitive and bypasses the LLM round-trip entirely.
|
||||
|
||||
```python
|
||||
from turnstone.core.storage import get_storage # construct via the same path the server uses
|
||||
|
||||
storage = get_storage(...) # see turnstone.core.storage.__init__ for the project's wiring
|
||||
|
||||
storage.create_workstream( # or whatever the project's exposed creator is — check turnstone/core/storage/_protocol.py
|
||||
ws_id=ws_id,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
state="closed",
|
||||
kind="interactive",
|
||||
...
|
||||
)
|
||||
|
||||
storage.save_messages_bulk([
|
||||
{"ws_id": ws_id, "role": "user", "content": "Hello"},
|
||||
{"ws_id": ws_id, "role": "assistant", "content": "Hi! What can I help with?"},
|
||||
{"ws_id": ws_id, "role": "assistant", "content": None,
|
||||
"tool_calls": json.dumps([{"id": "call_1", "type": "function",
|
||||
"function": {"name": "search", "arguments": "{\"q\":\"x\"}"}}])},
|
||||
{"ws_id": ws_id, "role": "tool", "tool_name": "search", "tool_call_id": "call_1",
|
||||
"content": "result text"},
|
||||
# ...
|
||||
])
|
||||
```
|
||||
|
||||
`save_messages_bulk` handles `timestamp` and the workstream's `updated` column internally, so you don't need to compute them per row. **Verify the exact creator signature** by reading `turnstone/core/storage/_protocol.py` — table layout has shifted across migrations and the Storage protocol is the source of truth.
|
||||
|
||||
### 2. SDK `create_workstream(resume_ws=...)` (when the source is already a Turnstone workstream)
|
||||
|
||||
Only useful for *Turnstone → Turnstone* re-parenting. Not relevant for foreign sources.
|
||||
|
||||
### 3. SDK `create_workstream(initial_message=...)` + `send()` per turn (last resort)
|
||||
|
||||
Only fits archives where the source had **no tool calls** and you don't care about preserving assistant turns verbatim. Each `send()` triggers a real LLM round-trip, which is expensive and rewrites assistant content. Don't use this for full history.
|
||||
|
||||
## Role Mapping
|
||||
|
||||
Common source-role conventions and how they map to Turnstone:
|
||||
|
||||
| Source role | Turnstone `role` | Notes |
|
||||
|---|---|---|
|
||||
| `user`, `human` | `user` | Direct map. |
|
||||
| `assistant`, `ai`, `model`, `bot` | `assistant` | Direct map. |
|
||||
| `system` | `system` | Preserve only if it's content the user wrote (custom instructions). Drop boilerplate provider preambles — Turnstone composes its own system message. |
|
||||
| `developer` (OpenAI o-series) | `developer` | Preserve. |
|
||||
| `tool`, `function`, `tool_result` | `tool` | Must carry `tool_name` and `tool_call_id` matching the prior assistant row's `tool_calls[].id`. |
|
||||
| `tool_use` (Anthropic) | `assistant` with `tool_calls` | Anthropic emits tool calls *inside* an assistant message; flatten to OpenAI shape. |
|
||||
| `human_feedback`, `revision` | `user` | Treat as a follow-up user turn. |
|
||||
|
||||
## Tool Calls (the most error-prone part)
|
||||
|
||||
Turnstone stores tool calls in OpenAI's nested-function shape on the assistant row, and matches them with `role="tool"` result rows by `tool_call_id`.
|
||||
|
||||
### Assistant row with tool calls
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_web",
|
||||
"arguments": "{\"query\":\"turnstone import\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`tool_calls[].function.arguments` is **a JSON-encoded string**, not an object. Source formats commonly get this wrong — Anthropic stores arguments as a parsed object, Gemini as a struct. Always re-serialize to a string.
|
||||
|
||||
### Tool result row
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_name": "search_web",
|
||||
"tool_call_id": "call_abc123",
|
||||
"content": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Pairing rules:
|
||||
- Every assistant `tool_calls[].id` MUST be followed by exactly one `role="tool"` row with the matching `tool_call_id`, before the next user/assistant turn.
|
||||
- If the source dropped the tool result (cut-off transcript), insert a synthetic `role="tool"` row with `content="[tool result missing in source]"` to keep the chain valid. An assistant row with an unanswered `tool_calls[].id` will break replay and any LLM round-trip.
|
||||
- Multi-tool assistant turns: one `role="tool"` row per call, in any order, all before the next non-tool row.
|
||||
|
||||
### Tool ID generation
|
||||
|
||||
If the source used opaque tool IDs that aren't unique within a thread (some platforms reuse them), regenerate with a stable scheme like `f"call_{i}"` where `i` is a per-thread counter. Update both the assistant and tool rows together.
|
||||
|
||||
## Provider Fidelity (`provider_data`)
|
||||
|
||||
Skip this entirely for **archive** imports.
|
||||
|
||||
For **resumable** imports against the same provider, populate `provider_data` to preserve provider-specific tool-call metadata that the next API round-trip will require:
|
||||
|
||||
- **Anthropic**: `signature` field on thinking blocks; required for round-tripping extended-thinking responses.
|
||||
- **Gemini**: `thought_signature` on tool calls; required for fidelity.
|
||||
- **OpenAI**: typically nothing to preserve.
|
||||
|
||||
The runtime-side dict key is `_provider_content` (a list of provider-native blocks); the persisted column is `provider_data` (the same list, JSON-encoded). If you don't have provider-native blocks from the source — and you usually won't, because a foreign export won't include them — leave `provider_data` NULL. The first new turn will succeed without it, but the previous assistant turn's reasoning won't replay back to the model.
|
||||
|
||||
## Attachments
|
||||
|
||||
If the source thread had image or file attachments:
|
||||
|
||||
- **Size limits**: images ≤ 4 MiB, text documents ≤ 512 KiB. Reject or downsample anything bigger.
|
||||
- **Allowed types**: server validates magic bytes for images and UTF-8-decodes for text. Binary blobs that aren't images won't pass.
|
||||
- **Lifecycle**: pending → reserved → consumed. For imports, the cleanest path is to upload as pending and immediately consume by attaching to the relevant `conversations.id`.
|
||||
|
||||
Two import paths:
|
||||
|
||||
1. **Bulk-insert + post-attach**: insert messages first, get back the assistant/user `conversations.id`, then write `workstream_attachments` rows linking the file to `message_id`.
|
||||
2. **SDK multipart create**: `create_workstream(attachments=[...], initial_message=...)` for the *first* turn only — the server reserves and consumes them onto that turn. Doesn't help for mid-thread attachments.
|
||||
|
||||
For full-history imports with multiple attachments at different turns, path (1) is the only option.
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
Before declaring success, verify:
|
||||
|
||||
- [ ] `ws_id` is 32-char lowercase hex.
|
||||
- [ ] `workstreams` row exists with the right `user_id`, `state`, `kind`.
|
||||
- [ ] Conversation rows are inserted **in order** (autoincrement `id` will reflect insert order).
|
||||
- [ ] Every assistant `tool_calls[].id` has a matching `role="tool"` row with the same `tool_call_id`.
|
||||
- [ ] `tool_calls[].function.arguments` is a JSON-encoded **string**, not a parsed object.
|
||||
- [ ] First message is typically `role="user"` (not `system`) — Turnstone composes its own system prompt at runtime.
|
||||
- [ ] No empty assistant rows (`content=NULL` AND `tool_calls=NULL` is invalid).
|
||||
- [ ] If multi-node: the `ws_id`'s bucket maps to a node that exists; `workstreams.node_id` matches.
|
||||
- [ ] Round-trip test: run `Storage.load_messages(ws_id)` and confirm the reconstructed list matches what you inserted (modulo timestamps).
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Don't import the source provider's system prompt verbatim.** Provider boilerplate ("You are Claude...", "You are ChatGPT...") will conflict with Turnstone's composed system message and confuse the model on resume. Drop it; preserve only user-authored custom instructions.
|
||||
- **Don't preserve foreign tool definitions as Turnstone tools.** If the source had custom tools that don't exist in Turnstone, the assistant rows that called them are still valid history (archive), but the workstream is **not resumable** — mark `state="closed"`.
|
||||
- **Don't fabricate `tool_call_id`s without re-pairing.** Mismatched ids silently break the replay chain on the next turn.
|
||||
- **Don't skip the `tool_name` field on `role="tool"` rows.** Some load paths use it for display and audit; NULL there will render as "unknown tool".
|
||||
- **Don't write through the LLM (`send()` per turn) for full history.** It's expensive, rewrites assistant turns, and rate-limits will bite long imports.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Path |
|
||||
|---|---|
|
||||
| Generate ws_id | `secrets.token_hex(16)` |
|
||||
| Bulk insert messages | `Storage.save_messages_bulk(rows)` |
|
||||
| Archive (read-only) | `state="closed"`, skip `provider_data` |
|
||||
| Resumable | `state="idle"`, populate `provider_data` if same provider |
|
||||
| Tool call id | OpenAI shape: `{"id": ..., "type": "function", "function": {"name": ..., "arguments": "<json string>"}}` |
|
||||
| Tool result row | `role="tool"`, `tool_name`, `tool_call_id`, `content` |
|
||||
| Source role → Turnstone role | See "Role Mapping" table |
|
||||
| Per-thread metadata | Store source IDs in `workstream_config` under `import.*` keys |
|
||||
|
||||
## Files to read before writing the importer
|
||||
|
||||
- `turnstone/core/storage/_schema.py` — authoritative table definitions.
|
||||
- `turnstone/core/storage/_protocol.py` — `save_message`, `save_messages_bulk`, `load_messages` signatures.
|
||||
- `turnstone/core/session.py` (around the message-save section) — how the runtime constructs in-memory message dicts; mirror this shape on import to round-trip cleanly.
|
||||
- `turnstone/api/server_schemas.py` — Pydantic shapes for the SDK paths if you go through HTTP.
|
||||
+8
-104
@@ -8,7 +8,7 @@ inter-service communication, powered by [lacme](https://pypi.org/project/lacme/)
|
||||
## Quick Start (Docker Compose)
|
||||
|
||||
```bash
|
||||
docker compose -f turnstone/deploy/compose.yaml -f deploy/docker-compose.tls.yml up
|
||||
docker compose -f compose.yaml -f deploy/docker-compose.tls.yml up
|
||||
```
|
||||
|
||||
This:
|
||||
@@ -19,53 +19,6 @@ This:
|
||||
|
||||
---
|
||||
|
||||
## Browser access (dashboard HTTPS)
|
||||
|
||||
The mTLS above secures **service-to-service** traffic (node↔node, collector and
|
||||
routing proxy → nodes). The **console dashboard itself serves plain HTTP** — and
|
||||
must, because it is the cluster's ACME bootstrap endpoint: new nodes fetch
|
||||
`/acme/ca.pem` and provision their first cert over HTTP, before they have the CA
|
||||
to verify TLS. So the console cannot be HTTPS-only on its port.
|
||||
|
||||
To put the **browser → console** hop on HTTPS, terminate TLS at a reverse proxy
|
||||
in front of the console. The dev stack (root `compose.yaml`) ships a `caddy`
|
||||
service that does exactly this — and it's the only published entry point, so the
|
||||
dashboard is HTTPS by default:
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
# dashboard: https://localhost:${CONSOLE_HTTPS_PORT:-8443}
|
||||
```
|
||||
|
||||
The production stack (`turnstone/deploy/compose.yaml`) bundles the same `caddy`
|
||||
service, so the dashboard is HTTPS there too. For a real domain and a publicly
|
||||
trusted cert, point Caddy at Let's Encrypt by editing `turnstone/deploy/Caddyfile`.
|
||||
|
||||
```
|
||||
browser --h2 / HTTPS--> caddy:443 --h1.1 / HTTP--> console:8090
|
||||
```
|
||||
|
||||
Caddy uses its **own local CA** (`tls internal`, see `turnstone/deploy/Caddyfile`), so the
|
||||
setup is self-contained with no dependency on the console's ACME path. Trust the
|
||||
local root once to silence the browser warning:
|
||||
|
||||
```bash
|
||||
docker compose exec caddy \
|
||||
cat /data/caddy/pki/authorities/local/root.crt # import into your OS/browser
|
||||
```
|
||||
|
||||
**Can Caddy get its cert from the console's internal CA instead?** Technically
|
||||
yes — the console exposes a real ACME directory (`/acme/directory`) with
|
||||
auto-approval, so Caddy's `tls { ca http://console:8090/acme/directory }` would
|
||||
mint a cert for any name. It's not recommended as the default: lacme's ACME
|
||||
responder is built for turnstone's own client (interop with Caddy's client is
|
||||
unverified), it couples Caddy startup to the console, and the browser must trust
|
||||
a private CA either way — so it buys nothing over `tls internal`. For a publicly
|
||||
trusted cert (no warning), point Caddy at Let's Encrypt with a real domain
|
||||
instead.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -88,32 +41,6 @@ Console (CA + ACME Server)
|
||||
- **Frontend cert** (HTTPS): From an external ACME CA (e.g. Let's Encrypt)
|
||||
if `tls.acme_directory` is set, otherwise self-issued from the internal CA.
|
||||
|
||||
### Boot, retry, and fallback
|
||||
|
||||
With `tls.enabled`, a node fetches the CA cert and requests its own cert
|
||||
during startup, retrying with exponential backoff (6 attempts, ~31 s total)
|
||||
— enough to absorb a whole-stack restart where every node races the console
|
||||
for its listener. If all attempts fail, the node **falls back to plain
|
||||
HTTP** (availability over confidentiality) and reports `"tls": "fallback"`
|
||||
in `GET /health`; a node serving HTTPS reports `"tls": "active"`, and the
|
||||
key is absent when TLS is disabled. Fallback persists until the next
|
||||
restart — it is not upgraded in place.
|
||||
|
||||
### Container healthcheck under mTLS
|
||||
|
||||
An mTLS listener rejects plain-HTTP probes at the socket, so
|
||||
`docker/healthcheck.py` falls back to HTTPS when the plain probe fails:
|
||||
it presents the node's own cert as the client cert and pins the cluster
|
||||
CA, using the PEM files the server writes at boot under
|
||||
`$TURNSTONE_TLS_PEM_DIR` (default `<tmpdir>/turnstone-tls`). The probe
|
||||
dials `localhost` for the TLS attempt — the internal CA issues DNS SANs
|
||||
only, so a literal-IP URL would fail verification. Cert renewal rewrites
|
||||
the PEM dir alongside the live listener swap, so the probe's client cert
|
||||
never outlives the served cert. With TLS disabled the plain probe succeeds
|
||||
and the PEM directory is never consulted. On bare metal with multiple
|
||||
nodes per host, set `TURNSTONE_TLS_PEM_DIR` per node (each boot clears
|
||||
stale `lacme-pem-*` dirs under its root).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
@@ -246,15 +173,8 @@ const client = new TurnstoneServer({
|
||||
1. Node starts, connects to shared database (plain connection)
|
||||
2. Discovers console URL from `services` table
|
||||
3. Fetches CA root cert from `http://console/acme/ca.pem` (plain HTTP, TOFU)
|
||||
4. Requests a service cert via ACME (plain HTTP, JWS-signed). The cert's
|
||||
primary domain / SAN is the node's **advertised host** (the host of
|
||||
`TURNSTONE_ADVERTISE_URL`, e.g. `node-1`) — the name peers actually dial,
|
||||
not the container hostname. This makes mTLS hostname verification succeed
|
||||
and keys the cert by a stable name that survives container recreation.
|
||||
5. Starts auto-renewal (24h interval, re-issues before expiry) **scoped to its
|
||||
own certificate**. Each node renews only its own cert; the shared store is
|
||||
never swept wholesale. Renewed certs are hot-swapped into the live HTTPS
|
||||
listener with no restart.
|
||||
4. Requests service cert via ACME protocol (plain HTTP, JWS-signed)
|
||||
5. Starts auto-renewal (24h interval, re-issues before expiry)
|
||||
6. All subsequent inter-service communication uses mTLS
|
||||
|
||||
### Console Startup Flow
|
||||
@@ -263,9 +183,7 @@ const client = new TurnstoneServer({
|
||||
2. Initialize CA (load from DB or generate new root key)
|
||||
3. Mount ACME responder at `/acme` (serves `/ca.pem` natively)
|
||||
4. Issue console certs (internal + optional frontend)
|
||||
5. Start CA-direct auto-renewal (no network, signs directly), scoped to the
|
||||
console's own cert, plus a periodic GC that reclaims cert rows for
|
||||
long-departed nodes
|
||||
5. Start CA-direct auto-renewal (no network, signs directly)
|
||||
6. Register console URL in services table with heartbeat
|
||||
|
||||
---
|
||||
@@ -277,31 +195,17 @@ const client = new TurnstoneServer({
|
||||
Certs are valid for 48 hours. If auto-renewal stopped (e.g. console was down),
|
||||
restart the service to re-request a cert.
|
||||
|
||||
### Collector/proxy can't reach a node (TLS hostname mismatch)
|
||||
|
||||
mTLS verifies a node's advertised host against the cert's SANs. Each node's
|
||||
cert is issued for the host in its `TURNSTONE_ADVERTISE_URL`, so that name is
|
||||
always a SAN automatically — you do **not** need to set `TURNSTONE_TLS_SANS`
|
||||
per node. Only set `TURNSTONE_TLS_SANS` to add *extra* names (e.g. a node
|
||||
fronted under a second hostname). Symptom if this is wrong: the console
|
||||
dashboard shows nodes as unreachable and `openssl s_client` reports the served
|
||||
cert's SANs don't include the dialed name.
|
||||
|
||||
### "No console service found"
|
||||
|
||||
The console registers itself in the `services` table on startup. If the console
|
||||
hasn't started or the registration expired (1 hour TTL), nodes can't discover
|
||||
it. Use `--console-url` explicitly.
|
||||
|
||||
### Browser HTTPS to the console
|
||||
### Let's Encrypt for console frontend
|
||||
|
||||
The console serves plain HTTP (it's the ACME bootstrap endpoint — see
|
||||
[Browser access](#browser-access-dashboard-https)). Put browser traffic on
|
||||
HTTPS by terminating TLS at a reverse proxy; the `cluster` profile's `caddy`
|
||||
service does this with Caddy's local CA. For a publicly trusted cert, front the
|
||||
console with a proxy pointed at Let's Encrypt using a real domain. The
|
||||
`tls.acme_directory` setting only governs the console's internal/frontend cert
|
||||
material — it does **not** make the console listen on HTTPS itself.
|
||||
Set `tls.acme_directory` to `https://acme-v02.api.letsencrypt.org/directory`
|
||||
in the admin Settings tab. The console will request a publicly trusted cert
|
||||
for its HTTPS endpoint. Internal mTLS still uses the private CA.
|
||||
|
||||
### Verifying the cert chain
|
||||
|
||||
|
||||
+134
-101
@@ -1,6 +1,6 @@
|
||||
# Tools Reference
|
||||
|
||||
turnstone exposes 16 built-in tools plus any number of external MCP tools to the
|
||||
turnstone exposes 19 built-in tools plus any number of external MCP tools to the
|
||||
LLM via the OpenAI function-calling interface. Built-in tools are defined as JSON
|
||||
files under `turnstone/tools/` and loaded at startup by `turnstone/core/tools.py`.
|
||||
MCP tools are discovered from configured MCP servers at startup by
|
||||
@@ -22,6 +22,7 @@ schema plus turnstone-specific metadata keys:
|
||||
"properties": { ... },
|
||||
"required": ["param1"]
|
||||
},
|
||||
"agent": true,
|
||||
"task_agent": true,
|
||||
"auto_approve": true,
|
||||
"primary_key": "param1"
|
||||
@@ -32,7 +33,8 @@ schema plus turnstone-specific metadata keys:
|
||||
|
||||
| Key | Type | Meaning |
|
||||
|----------------|------|---------|
|
||||
| `task_agent` | bool | Tool is available to task sub-agents. |
|
||||
| `agent` | bool | Tool is available to plan/task sub-agents (read-only subset). |
|
||||
| `task_agent` | bool | Tool is available to task sub-agents (broader subset). |
|
||||
| `auto_approve` | bool | Tool runs without user confirmation (read-only, safe operations). |
|
||||
| `primary_key` | str | When the model sends a bare string instead of JSON args, map it to this parameter name. |
|
||||
|
||||
@@ -44,10 +46,12 @@ schema plus turnstone-specific metadata keys:
|
||||
|
||||
| Name | Description |
|
||||
|---------------------|-------------|
|
||||
| `TOOLS` | All 28 loaded built-in tool definitions (interactive + coordinator union). Sessions send a kind-specific subset (`INTERACTIVE_TOOLS` or `COORDINATOR_TOOLS`). |
|
||||
| `TOOLS` | All 19 tool definitions (sent to the model). |
|
||||
| `AGENT_TOOLS` | Tools with `agent: true` -- available to plan sub-agents. Read-only tools. |
|
||||
| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. |
|
||||
| `TASK_AUTO_TOOLS` | Set of all tool names with `auto_approve: true` -- used by task-agent sub-sessions to skip confirmation for matching available tools. |
|
||||
| `BUILTIN_TOOL_NAMES`| Frozenset of all 28 built-in tool names (interactive + coordinator union). Used by tool search to distinguish always-on tools from deferrable MCP tools. |
|
||||
| `AGENT_AUTO_TOOLS` | Set of tool names with `auto_approve: true` -- no user confirmation needed. |
|
||||
| `TASK_AUTO_TOOLS` | Same as `AGENT_AUTO_TOOLS` (identical filter). |
|
||||
| `BUILTIN_TOOL_NAMES`| Frozenset of all 19 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
|
||||
| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. |
|
||||
|
||||
---
|
||||
@@ -65,7 +69,7 @@ Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools
|
||||
- Parses the JSON arguments (with fallback for malformed JSON).
|
||||
- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string
|
||||
to the correct parameter.
|
||||
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 16
|
||||
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 19
|
||||
built-in tools plus `tool_search` (synthetic, client-side BM25 fallback) and
|
||||
the generic `_prepare_mcp_tool()` handler for MCP tools.
|
||||
- Validates arguments and builds a preview dict containing:
|
||||
@@ -107,6 +111,9 @@ Each item's `execute` callable is invoked:
|
||||
denials are tracked separately. This removes the need for text-prefix heuristics.
|
||||
Other tools deliver results atomically via
|
||||
`ui.on_tool_result(call_id, name, output, is_error=...)` only.
|
||||
- Special post-execution gate for `plan`: the plan output is shown to the user
|
||||
for review, and the user can reject or annotate it.
|
||||
|
||||
---
|
||||
|
||||
## Tool Approval Flow
|
||||
@@ -114,6 +121,7 @@ Each item's `execute` callable is invoked:
|
||||
**Auto-approved** (no user confirmation needed at runtime):
|
||||
- `read_file` -- reads files, no side effects
|
||||
- `search` -- grep-style search, no side effects
|
||||
- `man` -- reads man pages, no side effects
|
||||
- `memory` -- structured persistent memory (save/search/delete/list)
|
||||
- `recall` -- searches conversation history
|
||||
- `notify` -- sends notifications to linked channels (time-sensitive, auto-approved for urgency)
|
||||
@@ -122,14 +130,16 @@ Each item's `execute` callable is invoked:
|
||||
- `bash` -- arbitrary command execution
|
||||
- `write_file` -- creates or overwrites files
|
||||
- `edit_file` -- modifies file content
|
||||
- `math` -- sandboxed computation (confirmation required despite being sandboxed)
|
||||
- `web_fetch` -- fetches a URL (SSRF-protected, but makes network requests)
|
||||
- `web_search` -- web search via self-hosted SearxNG (makes network requests)
|
||||
- `task_agent` -- spawns an autonomous sub-agent
|
||||
- `web_search` -- web search via Tavily API (makes network requests)
|
||||
- `task` -- spawns an autonomous sub-agent
|
||||
- `plan` -- spawns a planning sub-agent, plus post-execution review gate
|
||||
|
||||
Note: The JSON schema metadata key `auto_approve` controls membership in
|
||||
`TASK_AUTO_TOOLS` (used for task agent sub-sessions). The actual runtime
|
||||
approval behavior is determined by the `needs_approval` field set in each
|
||||
`_prepare_*` method on `ChatSession`. These two mechanisms can differ.
|
||||
`AGENT_AUTO_TOOLS`/`TASK_AUTO_TOOLS` (used for agent sub-sessions). The actual
|
||||
runtime approval behavior is determined by the `needs_approval` field set in
|
||||
each `_prepare_*` method on `ChatSession`. These two mechanisms can differ.
|
||||
|
||||
---
|
||||
|
||||
@@ -155,9 +165,12 @@ Every tool defines a `primary_key`. The mapping is:
|
||||
| `write_file` | `content` |
|
||||
| `edit_file` | `old_string`|
|
||||
| `search` | `query` |
|
||||
| `math` | `code` |
|
||||
| `man` | `page` |
|
||||
| `web_fetch` | `url` |
|
||||
| `web_search` | `query` |
|
||||
| `task_agent` | `prompt` |
|
||||
| `task` | `prompt` |
|
||||
| `plan` | `prompt` |
|
||||
| `memory` | `name` |
|
||||
| `recall` | `query` |
|
||||
| `notify` | `message` |
|
||||
@@ -181,7 +194,7 @@ Execute a bash command and return stdout + stderr.
|
||||
- **What it does**: Runs the command in a subprocess with a configurable timeout. Commands are sanitized and checked against a blocklist (e.g. `rm -rf /`). Environment variables containing secrets are scrubbed (`*_KEY`, `*_SECRET`, `*_TOKEN`, etc.).
|
||||
- **Output format**: Stdout is returned directly. Stderr lines are prefixed with `[stderr]` so the model can distinguish them. When the command itself redirects stderr to stdout (`2>&1`), no prefix is added. Output exceeding 256KB is truncated (head + tail preserved, middle replaced with a truncation notice).
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **Agent availability**: `task_agent` only.
|
||||
- **Agent availability**: `task_agent` only (not available to plan sub-agents).
|
||||
|
||||
---
|
||||
|
||||
@@ -199,7 +212,7 @@ base64-encoded image data for supported image formats.
|
||||
- **What it does**: For text files, reads and returns content with line numbers. For image files (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO), returns image data as multi-part content when the model supports vision, or a text description when it does not. SVG files are read as text. Images larger than 4 MB are rejected. Must be called before `edit_file` on the same path (the session tracks which files have been read).
|
||||
- **Vision support**: Controlled by `ModelCapabilities.supports_vision`. All commercial OpenAI and Anthropic models have vision enabled. Local models (vLLM, llama.cpp, NIM) default to off — enable via `[models.*.capabilities] supports_vision = true` in config.toml.
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: `task_agent`.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
@@ -255,7 +268,7 @@ Show a unified diff between two files, or between a file and a provided string.
|
||||
|
||||
- **What it does**: Returns unified diff output using Python's `difflib`. Binary files (containing null bytes) are rejected with a clear error. Files read through `diff_file` satisfy `edit_file`'s read guard — you can diff then edit without a separate `read_file` call. Large diffs are streamed with early cutoff at the tool truncation limit.
|
||||
- **Auto-approve**: Yes (read-only).
|
||||
- **Agent availability**: `task_agent`.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
@@ -270,12 +283,44 @@ Search file contents for a regex pattern.
|
||||
|
||||
- **What it does**: Recursively searches for the pattern using `grep -rn`. Returns matching lines with file paths and line numbers.
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: `task_agent`.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
## Computation
|
||||
|
||||
### math
|
||||
|
||||
Execute Python code for math and computation in a sandbox.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `code` | string | yes | Python code to execute. Must use `print()` for output. |
|
||||
|
||||
- **What it does**: Runs Python code in a sandboxed environment with pre-imported libraries: `sympy`, `numpy`, `scipy`, `math`, `fractions`, `itertools`, `functools`, `collections`, `decimal`, `operator`, `random`, `re`, `string`. Common sympy names (`symbols`, `solve`, `simplify`, `sqrt`, `Matrix`, etc.) are pre-imported. `pytest` is also available for import.
|
||||
- **Installation**: `sympy`, `numpy`, `scipy`, and `pytest` require the `[sandbox]` extras group: `pip install turnstone[sandbox]` (included in `[all]`).
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
## Information
|
||||
|
||||
### man
|
||||
|
||||
Read a man page.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | string | yes | The man page name (e.g. `grep`, `socket`, `printf`). |
|
||||
| `section` | string | no | Manual section (e.g. `1` commands, `2` syscalls, `3` library). |
|
||||
|
||||
- **What it does**: Returns the full formatted manual entry. Preferred over `bash('man ...')` or `web_search` for command/API documentation.
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
### web_fetch
|
||||
|
||||
Fetch a URL and extract specific information from it.
|
||||
@@ -287,7 +332,7 @@ Fetch a URL and extract specific information from it.
|
||||
|
||||
- **What it does**: Fetches the URL, strips HTML to plain text, and uses the LLM to extract the answer to the question from the page content. Protected against SSRF (blocks private/internal IPs).
|
||||
- **Auto-approve**: No -- requires user confirmation (makes network requests).
|
||||
- **Agent availability**: `task_agent`.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
@@ -299,58 +344,20 @@ Search the web using a text query.
|
||||
|---------------|---------|----------|-------------|
|
||||
| `query` | string | yes | The search query. |
|
||||
| `max_results` | integer | no | Max results to return (default 5, max 20). |
|
||||
| `category` | string | no | Search category: `general` (default), `news`, `it` (code/tech), or `science`. Maps to SearxNG categories; the model picks per query. |
|
||||
| `topic` | string | no | Search topic: `general`, `news`, or `finance` (default `general`). |
|
||||
|
||||
- **What it does**: Searches the web and returns ranked results with titles, URLs, and content snippets. Uses provider-native search when available:
|
||||
- **Anthropic**: Replaced at the API boundary with Anthropic's `web_search_20250305` server-side tool. Claude decides when to search; the API executes it and returns results with citations inline. No backend needed.
|
||||
- **Anthropic**: Replaced at the API boundary with Anthropic's `web_search_20250305` server-side tool. Claude decides when to search; the API executes it and returns results with citations inline. No Tavily key needed.
|
||||
- **OpenAI search models** (`gpt-5-search-api`): Replaced with `web_search_options` parameter. The model always searches and returns `url_citation` annotations.
|
||||
- **Local/vLLM models**: Falls back to a self-hosted [SearxNG](https://searxng.org) instance. Set `searxng_url` in `config.toml` `[tools]` or `$TURNSTONE_SEARXNG_URL` (the docker-compose stack bundles a `searxng` service and points at it by default). Operators with a custom MCP search server can instead set `web_search_backend = "mcp:server:tool"`.
|
||||
- **Local/vLLM models**: Falls back to the Tavily API. Requires `tavily_key` in `config.toml` or `$TAVILY_API_KEY`.
|
||||
- **Auto-approve**: Yes (auto-approved for all tool dispatch paths).
|
||||
- **Agent availability**: `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
### Reranking (optional)
|
||||
|
||||
`web_search` can use an external **reranker** to re-order the backend's result pool by relevance to the query before returning the top hits. Turnstone runs no reranker model itself; it POSTs to a Cohere/Jina-compatible `/rerank` endpoint (self-hosted [vLLM](https://docs.vllm.ai) / [TEI](https://github.com/huggingface/text-embeddings-inference) / llama.cpp, or hosted Cohere/Jina/Voyage).
|
||||
|
||||
**Disabled by default.** In the console **Models** tab, add a model definition whose `base_url` is a Cohere/Jina-compatible `/rerank` endpoint and whose capabilities include `{"supports_rerank": true}`, then select it under **Models → Roles → Reranker**. It's managed like every other model (write-only key, enable/disable, calibration). The reranker is purely this per-model definition — there is no global `rerank_url`-style endpoint setting.
|
||||
|
||||
The `rerank_web_search` toggle defaults on once a reranker is selected. If the endpoint is unreachable or errors, web_search falls back silently to the backend's native result order — reranking never makes a search fail.
|
||||
|
||||
When `rerank_bm25` is enabled, the candidate text for memory, tool, and skill retrieval (memory name/description/content and tool/skill names + descriptions) is also sent to the rerank endpoint — a self-hosted endpoint (vLLM/TEI/llama.cpp) keeps it on your infrastructure, a hosted provider (Cohere/Jina/Voyage) sends it off-box.
|
||||
|
||||
**Serving a Qwen3-Reranker with vLLM.** The model is instruction-aware, so vLLM **must** apply its chat template — pass `--chat-template` explicitly. Without it the bare query produces near-random scores and reranking actively *hurts* retrieval (verified: an irrelevant passage outscored the correct one):
|
||||
|
||||
```bash
|
||||
vllm serve /models/Qwen3-Reranker-0.6B \
|
||||
--runner pooling \
|
||||
--hf-overrides '{"architectures":["Qwen3ForSequenceClassification"],"classifier_from_token":["no","yes"],"is_original_qwen3_reranker":true}' \
|
||||
--chat-template /models/Qwen3-Reranker-0.6B/chat_template.jinja \
|
||||
--served-model-name qwen3-reranker --port 8000
|
||||
```
|
||||
|
||||
Then add a reranker model in the **Models** tab with `base_url` `http://vllm:8000/rerank` (model name `qwen3-reranker`) and select it under **Models → Roles → Reranker**.
|
||||
|
||||
For an endpoint that does *not* apply the model's template, set `rerank_instruction` instead — Turnstone then wraps each query as `<Instruct>: {instruction}` / `<Query>: {query}` (Qwen3's own default is `Given a web search query, retrieve relevant passages that answer the query`). Use the chat template **or** the instruction, not both (they double-wrap).
|
||||
|
||||
**Picking `rerank_bm25_threshold`.** The relevance floor that gates proactive memory injection is a probability in `[0, 1]`, but the right value differs per model (a sharp 0.6B reranker may want ~0.95; a broader 4B ~0.33). Calibrate it against your endpoint:
|
||||
|
||||
```bash
|
||||
turnstone-admin rerank-calibrate # probe the endpoint, recommend a floor
|
||||
turnstone-admin rerank-calibrate --apply # ...and write tools.rerank_bm25_threshold
|
||||
```
|
||||
|
||||
It reports the score scale, whether the endpoint cleanly separates relevant from irrelevant probes (a **"no clean separation"** result flags a mis-served or weak reranker), and the suggested floor. Leave the threshold at `0` to rerank-without-filtering.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
## Agent
|
||||
|
||||
The tool name uses the `_agent` suffix — bare `task` collides with
|
||||
chat-template channel names on some local models.
|
||||
|
||||
### task_agent
|
||||
### task
|
||||
|
||||
Delegate a general-purpose task to an autonomous sub-agent.
|
||||
|
||||
@@ -358,9 +365,23 @@ Delegate a general-purpose task to an autonomous sub-agent.
|
||||
|-----------|--------|----------|-------------|
|
||||
| `prompt` | string | yes | Complete task description for the sub-agent. |
|
||||
|
||||
- **What it does**: Spawns a sub-agent that inherits the `TASK_AGENT_TOOLS` set (read, write, edit, search, bash, web tools, memory tools). The sub-agent runs autonomously to completion. Use for work that requires file modifications or command execution.
|
||||
- **What it does**: Spawns a sub-agent that inherits the `TASK_AGENT_TOOLS` set (read, write, edit, search, bash, math, man, web tools, memory tools). The sub-agent runs autonomously to completion. Use for work that requires file modifications or command execution.
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **Agent availability**: Top-level only.
|
||||
- **Agent availability**: Not available to sub-agents (top-level only).
|
||||
|
||||
---
|
||||
|
||||
### plan
|
||||
|
||||
Plan before implementing -- an autonomous agent explores the codebase and writes a structured plan.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `prompt` | string | yes | What to plan -- the goal, constraints, and scope. |
|
||||
|
||||
- **What it does**: Spawns a planning sub-agent with `AGENT_TOOLS` (read-only tools: `read_file`, `search`, `math`, `man`, `web_fetch`, `web_search`). The agent explores the codebase and writes a structured plan to `.plan-<ws_id>.md` (unique per workstream, so concurrent workstreams never collide). If the `plan` tool has been called before in the same session, the prior plan is passed to the agent as context so it refines rather than restarts. After completion, the user is prompted to review and can accept, reject, or annotate the plan.
|
||||
- **Auto-approve**: No -- requires user confirmation, plus post-execution review gate.
|
||||
- **Agent availability**: Not available to sub-agents (top-level only).
|
||||
|
||||
---
|
||||
|
||||
@@ -421,7 +442,7 @@ Provide either `username` for user-based targeting or `channel_type` +
|
||||
|
||||
- **What it does**: Sends a notification via the channel gateway's HTTP endpoint (`POST /v1/api/notify`). The server queries the `services` table for healthy channel gateways, authenticates with a service JWT (`aud: turnstone-channel`), and delivers to the first healthy gateway. On failure, retries up to 2 additional times with backoff (1s, 3s). Rate-limited to 5 notifications per turn (counter only increments on success).
|
||||
- **Auto-approve**: Yes — notifications are time-sensitive and auto-approved so the model can alert users urgently.
|
||||
- **Agent availability**: `task_agent`.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
> See [Channel Integrations: Notifications](channels.md#notifications)
|
||||
> for the full delivery flow, service registry details, and security
|
||||
@@ -497,7 +518,7 @@ data.get("mergedAt") is not None
|
||||
- Duplicate names rejected within the same workstream.
|
||||
|
||||
- **Auto-approve**: `create` requires approval; `list` and `cancel` are auto-approved.
|
||||
- **Agent availability**: Main session only — not available to task sub-agents.
|
||||
- **Agent availability**: Main session only — not available to plan/task sub-agents.
|
||||
|
||||
> See [Watch Architecture](diagrams/png/18-watch-architecture.png) for the
|
||||
> full poll → evaluate → dispatch flow.
|
||||
@@ -522,38 +543,41 @@ pre-configure skills at workstream creation.
|
||||
- `load` — Activate a skill by name. Calls `set_skill()` which handles content
|
||||
rendering with `{{model}}`/`{{ws_id}}`/`{{node_id}}` variables, system message
|
||||
reinitialization, and config persistence. Returns the skill name, description,
|
||||
and security risk level. Warns on high/critical risk level.
|
||||
and security scan tier. Warns on high/critical scan status.
|
||||
- `search` — Find available skills by query. Uses BM25 relevance ranking over
|
||||
name, description, tags, and category (same `BM25Index` used by memory
|
||||
relevance and tool search). Returns up to 10 results with name, description,
|
||||
category, risk level, and activation type.
|
||||
category, scan status, and activation type.
|
||||
|
||||
- **Auto-approve**: `load` requires approval (changes session behavior); `search`
|
||||
is auto-approved (read-only).
|
||||
- **Agent availability**: Main session only — not available to task sub-agents.
|
||||
- **Agent availability**: Main session only — not available to plan/task sub-agents.
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Tool | Category | Auto-approve | task_agent | primary_key |
|
||||
|--------------|------------|--------------|------------|-------------|
|
||||
| `bash` | File Ops | No | Yes | `command` |
|
||||
| `read_file` | File Ops | Yes | Yes | `path` |
|
||||
| `write_file` | File Ops | No | Yes | `content` |
|
||||
| `edit_file` | File Ops | No | Yes | `old_string`|
|
||||
| `search` | File Ops | Yes | Yes | `query` |
|
||||
| `web_fetch` | Info | No | Yes | `url` |
|
||||
| `web_search` | Info | No | Yes | `query` |
|
||||
| `task_agent` | Agent | No | No | `prompt` |
|
||||
| `memory` | Memory | Yes | No | `name` |
|
||||
| `recall` | Memory | Yes | No | `query` |
|
||||
| `notify` | Notify | Yes | Yes | `message` |
|
||||
| `watch` | Monitor | No (create) | No | `command` |
|
||||
| `read_resource`| MCP | No | Yes | `uri` |
|
||||
| `use_prompt` | MCP | No | Yes | `name` |
|
||||
| `skill` | Skills | No (load) | No | `name` |
|
||||
| `tool_search`| Search | Yes | No | `query` |
|
||||
| Tool | Category | Auto-approve | agent | task_agent | primary_key |
|
||||
|--------------|------------|--------------|-------|------------|-------------|
|
||||
| `bash` | File Ops | No | No | Yes | `command` |
|
||||
| `read_file` | File Ops | Yes | Yes | Yes | `path` |
|
||||
| `write_file` | File Ops | No | No | Yes | `content` |
|
||||
| `edit_file` | File Ops | No | No | Yes | `old_string`|
|
||||
| `search` | File Ops | Yes | Yes | Yes | `query` |
|
||||
| `math` | Compute | No | Yes | Yes | `code` |
|
||||
| `man` | Info | Yes | Yes | Yes | `page` |
|
||||
| `web_fetch` | Info | No | Yes | Yes | `url` |
|
||||
| `web_search` | Info | No | Yes | Yes | `query` |
|
||||
| `task` | Agent | No | No | No | `prompt` |
|
||||
| `plan` | Agent | No | No | No | `prompt` |
|
||||
| `memory` | Memory | Yes | No | No | `name` |
|
||||
| `recall` | Memory | Yes | No | No | `query` |
|
||||
| `notify` | Notify | Yes | Yes | Yes | `message` |
|
||||
| `watch` | Monitor | No (create) | No | No | `command` |
|
||||
| `read_resource`| MCP | No | Yes | Yes | `uri` |
|
||||
| `use_prompt` | MCP | No | Yes | Yes | `name` |
|
||||
| `skill` | Skills | No (load) | No | No | `name` |
|
||||
| `tool_search`| Search | Yes | No | No | `query` |
|
||||
|
||||
---
|
||||
|
||||
@@ -605,9 +629,8 @@ CLI flags override the config file:
|
||||
directly.
|
||||
|
||||
2. **Partitioning**: When active, tools are split into two sets:
|
||||
- **Always-on** -- the built-in tools present in the current session
|
||||
(interactive sessions currently have 16; `BUILTIN_TOOL_NAMES` is the
|
||||
28-tool built-in union). These are always visible to the model.
|
||||
- **Always-on** -- the 19 built-in tools (members of `BUILTIN_TOOL_NAMES`).
|
||||
These are always visible to the model.
|
||||
- **Deferred** -- all MCP tools. These are not sent in the tool list unless
|
||||
the model searches for them.
|
||||
|
||||
@@ -621,10 +644,10 @@ CLI flags override the config file:
|
||||
|
||||
### Agent exemption
|
||||
|
||||
Task sub-agents do not use tool search. They operate on the scoped tool set
|
||||
(`TASK_AGENT_TOOLS`) with MCP tools merged in. Tool search is only active for
|
||||
the top-level session, where the model can interactively search for tools it
|
||||
needs.
|
||||
Plan and task sub-agents do not use tool search. They operate on scoped tool
|
||||
sets (`AGENT_TOOLS` for plan agents, `TASK_AGENT_TOOLS` for task agents) with
|
||||
MCP tools merged in. Tool search is only active for the top-level session,
|
||||
where the model can interactively search for tools it needs.
|
||||
|
||||
---
|
||||
|
||||
@@ -649,7 +672,7 @@ MCP-compatible service.
|
||||
3. **Schema conversion**: Each MCP tool's `inputSchema` is converted to OpenAI
|
||||
function-calling format. The tool name is prefixed: `mcp__{server}__{tool}`.
|
||||
|
||||
4. **Merging**: MCP tools are appended after the 16 built-in tools via
|
||||
4. **Merging**: MCP tools are appended after the 19 built-in tools via
|
||||
`merge_mcp_tools()`. Built-in tools appear first, giving them natural LLM priority.
|
||||
When dynamic tool search is active, MCP tools are deferred rather than directly
|
||||
visible -- the model discovers them via search as needed (see
|
||||
@@ -675,6 +698,7 @@ gives per-tool-type granularity (e.g., all `use_prompt` calls).
|
||||
MCP tools are available to:
|
||||
- **Main session** — full access
|
||||
- **Task sub-agents** — via `self._task_tools` (merged list)
|
||||
- **Plan sub-agents** — via `self._agent_tools` (merged list)
|
||||
|
||||
### Naming convention
|
||||
|
||||
@@ -731,25 +755,34 @@ MCP tools (3):
|
||||
|
||||
### Dynamic tool refresh
|
||||
|
||||
MCP tool lists stay up-to-date without restart through two mechanisms:
|
||||
MCP tool lists stay up-to-date without restart through three mechanisms:
|
||||
|
||||
1. **Push notifications** -- MCP servers that declare `tools.listChanged: true` in
|
||||
their capabilities send `notifications/tools/list_changed` when their tool list
|
||||
changes. `MCPClientManager` registers a `message_handler` on each `ClientSession`
|
||||
that triggers an immediate refresh for that server.
|
||||
|
||||
2. **Manual** -- `/mcp refresh` re-fetches tools from all servers immediately.
|
||||
2. **Periodic timer** -- Servers that do *not* support push notifications are polled
|
||||
on a configurable interval (default 4 hours). The timer is staggered using a
|
||||
launch-time seed (`monotonic_ns ^ pid`) so cluster nodes don't all hit MCP
|
||||
servers simultaneously. Configure via `[mcp] refresh_interval` in `config.toml`
|
||||
or `--mcp-refresh-interval SECONDS` on the CLI. Set to `0` to disable.
|
||||
|
||||
3. **Manual** -- `/mcp refresh` re-fetches tools from all servers immediately.
|
||||
`/mcp refresh <server>` targets a single server. If a server has disconnected,
|
||||
manual refresh attempts reconnection. The console admin panel exposes the
|
||||
same controls (refresh / reconnect buttons per server) for cluster-wide
|
||||
fan-out.
|
||||
manual refresh attempts reconnection.
|
||||
|
||||
When tools change, `MCPClientManager` rebuilds its merged tool list using copy-on-write
|
||||
(new list/dict objects assigned atomically) and notifies all active `ChatSession`
|
||||
instances via registered listener callbacks. Each session rebuilds its `_tools`,
|
||||
`_task_tools`, and reconstructs its `ToolSearchManager` (if active),
|
||||
`_task_tools`, `_agent_tools`, and reconstructs its `ToolSearchManager` (if active),
|
||||
preserving the set of previously expanded (discovered) tools.
|
||||
|
||||
```toml
|
||||
[mcp]
|
||||
refresh_interval = 14400 # seconds (default 4h), 0 to disable
|
||||
```
|
||||
|
||||
```
|
||||
/mcp refresh
|
||||
MCP refresh complete:
|
||||
@@ -803,7 +836,7 @@ Use read_resource(uri='...') to access the resources listed above.
|
||||
|
||||
- **What it does**: Reads the resource from its MCP server via `MCPClientManager.read_resource_sync()`. Returns text content for text resources or base64-encoded data for binary resources. Output is truncated by the standard tool output limiter.
|
||||
- **Auto-approve**: No -- requires user confirmation (reads external data).
|
||||
- **Agent availability**: `task_agent`.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
### Capability guards
|
||||
|
||||
@@ -845,7 +878,7 @@ the `initialize` handshake. Each prompt is stored with its prefixed name
|
||||
|
||||
- **What it does**: Invokes an MCP prompt by name via `MCPClientManager.get_prompt_sync()`, expanding it into messages. Returns the expanded prompt content formatted as `[role]: content` blocks joined with blank lines. The prompt catalog is listed in the system message so the model knows which prompts are available. Output is truncated by the standard tool output limiter.
|
||||
- **Auto-approve**: No -- requires user confirmation (invokes external prompt servers).
|
||||
- **Agent availability**: `task_agent`.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
### Invocation
|
||||
|
||||
|
||||
@@ -2,48 +2,11 @@
|
||||
|
||||
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.
|
||||
|
||||
> [!NOTE]
|
||||
> **Superseded by the built-in coordinator workstream in Turnstone 1.5.**
|
||||
>
|
||||
> This MCP side-car is the pre-1.5 pattern for cluster-wide orchestration.
|
||||
> Turnstone 1.5 promotes coordinator behaviour to a first-class workstream
|
||||
> kind hosted inside `turnstone-console` — no external MCP server to
|
||||
> install or operate, proper per-user audit attribution, and a dedicated
|
||||
> UI at `/coordinator/{ws_id}`.
|
||||
>
|
||||
> The extension continues to work for 1.4-and-earlier clusters. On 1.5+:
|
||||
> grant the `admin.coordinator` permission, set `coordinator.model_alias`
|
||||
> in the admin Settings tab, and create sessions via the dashboard's
|
||||
> "new coordinator" button or `POST /v1/api/coordinator/new`. Full
|
||||
> removal of this example (including docker / compose references) is
|
||||
> planned once 1.5 is confirmed in production.
|
||||
>
|
||||
> | Concern | Built-in coordinator (1.5+) | This MCP extension (1.4-and-earlier) |
|
||||
> |---|---|---|
|
||||
> | Install | None — shipped in-tree | `pip install -e examples/mcp-cluster-ops` + MCP client config |
|
||||
> | Auth | Real creator's `user_id` + `admin.coordinator` permission | Shared service token |
|
||||
> | Audit | `coordinator.create` / `close` / `cancel` events on the console; `src="coordinator"` preserved on upstream hops | Service identity only |
|
||||
> | UI | `/coordinator/{ws_id}` one-pane HTML | No UI — model-only |
|
||||
> | Tool approvals | Inline approval bar in the coordinator pane | MCP approval flow |
|
||||
> | Configuration | `coordinator.model_alias`, `coordinator.max_active`, `coordinator.reasoning_effort`, `coordinator.session_jwt_ttl_seconds` | MCP server config file |
|
||||
>
|
||||
> Minimal 1.5 migration:
|
||||
>
|
||||
> ```bash
|
||||
> curl -X POST https://console.example/v1/api/coordinator/new \
|
||||
> -H "Authorization: Bearer $TOKEN" \
|
||||
> -H "Content-Type: application/json" \
|
||||
> -d '{"name":"planner","initial_message":"Spawn a worker to check the build"}'
|
||||
> ```
|
||||
>
|
||||
> The response carries `ws_id`; open
|
||||
> `https://console.example/coordinator/{ws_id}` to watch the session.
|
||||
|
||||
## How it works
|
||||
|
||||
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 rendezvous routing proxy, returning `ws_id` and `node_url`.
|
||||
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.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ name = "mcp-cluster-ops"
|
||||
version = "0.1.0"
|
||||
description = "MCP server for Turnstone cluster operations — reference implementation."
|
||||
requires-python = ">=3.11"
|
||||
license = "Apache-2.0"
|
||||
license = "BUSL-1.1"
|
||||
dependencies = [
|
||||
"turnstone",
|
||||
"mcp>=1.6",
|
||||
|
||||
+27
-25
@@ -4,11 +4,10 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.6.1"
|
||||
version = "1.2.2"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE", "NOTICE", "THIRD-PARTY-NOTICES"]
|
||||
license = "BUSL-1.1"
|
||||
requires-python = ">=3.11"
|
||||
authors = [{name = "Patrick Buckley", email = "buckleypm@gmail.com"}]
|
||||
keywords = ["ai", "chat", "llm", "agent", "tools", "openai"]
|
||||
@@ -23,24 +22,19 @@ classifiers = [
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
dependencies = [
|
||||
"openai>=2.37",
|
||||
"anthropic>=0.108", # claude-fable-5 support; hard runtime floor is 0.105 (mid-conversation system blocks)
|
||||
"openai>=2.24",
|
||||
"httpx>=0.28",
|
||||
"mcp>=1.27",
|
||||
"starlette>=1.0.1", # PYSEC-2026-161: host-header path-injection in URL reconstruction (auth-bypass on apps comparing reconstructed URL paths)
|
||||
"mcp>=1.6",
|
||||
"starlette>=0.45",
|
||||
"uvicorn>=0.34",
|
||||
"sse-starlette>=2.0",
|
||||
"httpx-sse>=0.4",
|
||||
"pydantic>=2.0",
|
||||
"sqlalchemy>=2.0",
|
||||
"alembic>=1.14",
|
||||
"psycopg[binary]>=3.2",
|
||||
"croniter>=3.0",
|
||||
"structlog>=24.1",
|
||||
"PyJWT>=2.8",
|
||||
"bcrypt>=4.0",
|
||||
"cryptography>=42",
|
||||
"lacme>=1.0.5",
|
||||
"python-frontmatter>=1.0",
|
||||
]
|
||||
|
||||
@@ -50,11 +44,16 @@ Repository = "https://github.com/turnstonelabs/turnstone"
|
||||
Issues = "https://github.com/turnstonelabs/turnstone/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest>=9.0", "pytest-cov>=6.0", "slack-bolt>=1.18", "aiohttp>=3.9"]
|
||||
test = ["pytest>=9.0", "pytest-cov>=6.0", "croniter>=3.0"]
|
||||
dev = ["ruff>=0.9", "mypy>=1.14"]
|
||||
console = ["croniter>=3.0"]
|
||||
anthropic = ["anthropic>=0.39"]
|
||||
postgres = ["psycopg[binary]>=3.2"]
|
||||
ddg = ["ddgs>=9.0"]
|
||||
discord = ["discord.py>=2.4"]
|
||||
slack = ["slack-bolt>=1.18", "aiohttp>=3.9"]
|
||||
all = ["turnstone[discord,slack]"]
|
||||
tls = ["lacme>=1.0.5"]
|
||||
sandbox = ["sympy>=1.13", "numpy>=2.0", "scipy>=1.14", "pytest>=9.0"]
|
||||
all = ["turnstone[console,anthropic,postgres,discord,ddg,tls,sandbox]"]
|
||||
|
||||
[project.scripts]
|
||||
turnstone = "turnstone.cli:main"
|
||||
@@ -76,19 +75,14 @@ include = [
|
||||
"turnstone/console/static/*.html",
|
||||
"turnstone/console/static/*.css",
|
||||
"turnstone/console/static/*.js",
|
||||
"turnstone/console/static/coordinator/*.html",
|
||||
"turnstone/console/static/coordinator/*.css",
|
||||
"turnstone/console/static/coordinator/*.js",
|
||||
"turnstone/shared_static/*.css",
|
||||
"turnstone/shared_static/*.js",
|
||||
"turnstone/shared_static/katex-0.17.0/**/*",
|
||||
"turnstone/shared_static/katex-0.16.45/**/*",
|
||||
"turnstone/shared_static/hljs-11.11.1/**/*",
|
||||
"turnstone/shared_static/mermaid-11.15.0/**/*",
|
||||
"turnstone/shared_static/hls-1.6.16/**/*",
|
||||
"turnstone/shared_static/mermaid-11.14.0/**/*",
|
||||
"turnstone/shared_static/hls-1.6.15/**/*",
|
||||
"turnstone/sdk/py.typed",
|
||||
"turnstone/deploy/*.yaml",
|
||||
"turnstone/deploy/Caddyfile",
|
||||
"turnstone/deploy/searxng/settings.yml",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
@@ -102,6 +96,7 @@ line-length = 100
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM", "TCH"]
|
||||
ignore = ["E501"]
|
||||
per-file-ignores = { "turnstone/core/sandbox.py" = ["N802"] }
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
@@ -131,6 +126,10 @@ exclude_lines = [
|
||||
'if __name__ == "__main__"',
|
||||
]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["sympy", "sympy.*", "numpy", "numpy.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["mcp", "mcp.*"]
|
||||
ignore_missing_imports = true
|
||||
@@ -167,6 +166,10 @@ ignore_missing_imports = true
|
||||
module = ["frontmatter", "frontmatter.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["ddgs", "ddgs.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["lacme", "lacme.*"]
|
||||
ignore_missing_imports = true
|
||||
@@ -178,6 +181,5 @@ disallow_untyped_decorators = false
|
||||
warn_unused_ignores = false
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["slack_bolt", "slack_bolt.*", "slack_sdk", "slack_sdk.*"]
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_calls = false
|
||||
module = "tests.*"
|
||||
disallow_untyped_defs = false
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Turnstone one-line installer.
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/turnstonelabs/turnstone/main/run.sh | bash
|
||||
#
|
||||
# Autodetects your distro (Ubuntu/Debian, Fedora/RHEL, Arch, and WSL on any of
|
||||
# them) and:
|
||||
# 1. ensures git is installed, then clones the repo
|
||||
# 2. ensures Docker + the compose plugin are installed and the daemon is usable
|
||||
# 3. asks how many server nodes to run (1-10)
|
||||
# 4. builds the image
|
||||
# 5. picks free host ports for Caddy (prefers 443) and PostgreSQL
|
||||
# 6. writes a .env with a generated JWT secret + Postgres password
|
||||
# 7. pins the node count and runs `docker compose up -d`, then prints how to
|
||||
# finish setup in the UI
|
||||
#
|
||||
# Re-running is safe: it updates the checkout and keeps an existing .env.
|
||||
#
|
||||
# Env overrides:
|
||||
# TURNSTONE_DIR where to clone (default: $HOME/turnstone)
|
||||
# TURNSTONE_REPO git URL (default: https://github.com/turnstonelabs/turnstone.git)
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
REPO_URL="${TURNSTONE_REPO:-https://github.com/turnstonelabs/turnstone.git}"
|
||||
INSTALL_DIR="${TURNSTONE_DIR:-$HOME/turnstone}"
|
||||
|
||||
# -- output helpers -----------------------------------------------------------
|
||||
if [ -t 1 ]; then
|
||||
BOLD=$'\033[1m'; DIM=$'\033[2m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'
|
||||
RED=$'\033[31m'; RESET=$'\033[0m'
|
||||
else
|
||||
BOLD=""; DIM=""; GREEN=""; YELLOW=""; RED=""; RESET=""
|
||||
fi
|
||||
info() { printf '%s==>%s %s\n' "$GREEN" "$RESET" "$*"; }
|
||||
warn() { printf '%swarning:%s %s\n' "$YELLOW" "$RESET" "$*" >&2; }
|
||||
die() { printf '%serror:%s %s\n' "$RED" "$RESET" "$*" >&2; exit 1; }
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# Any *unhandled* failure (set -Ee) lands here with an actionable note instead of
|
||||
# a bare non-zero exit. `die` exits explicitly and does not trigger this.
|
||||
on_error() {
|
||||
local rc=$?
|
||||
printf '\n%serror:%s the installer stopped unexpectedly (exit %s). The output above shows why.\n' \
|
||||
"$RED" "$RESET" "$rc" >&2
|
||||
printf ' Fix the issue and re-run this script — it resumes the parts already done.\n' >&2
|
||||
}
|
||||
trap on_error ERR
|
||||
|
||||
# Ask a yes/no question, reading from the terminal even under `curl | bash`
|
||||
# (where stdin is the script). Defaults to "$2" when non-interactive.
|
||||
ask() {
|
||||
local prompt="$1" default="${2:-y}" ans hint
|
||||
[ "$default" = y ] && hint="Y/n" || hint="y/N"
|
||||
if [ ! -r /dev/tty ]; then
|
||||
warn "non-interactive shell; assuming '$default' for: $prompt"
|
||||
[ "$default" = y ]; return
|
||||
fi
|
||||
printf '%s%s%s [%s] ' "$BOLD" "$prompt" "$RESET" "$hint" >/dev/tty
|
||||
read -r ans </dev/tty || ans=""
|
||||
ans="${ans:-$default}"
|
||||
case "$ans" in [Yy]*) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
# -- distro / package manager detection --------------------------------------
|
||||
OS_ID=""; OS_LIKE=""; PKG=""; IS_WSL=0; SUDO=""
|
||||
|
||||
detect_os() {
|
||||
if [ -r /etc/os-release ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
OS_ID="${ID:-}"; OS_LIKE="${ID_LIKE:-}"
|
||||
fi
|
||||
if grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null || [ -n "${WSL_DISTRO_NAME:-}" ]; then
|
||||
IS_WSL=1
|
||||
fi
|
||||
case " $OS_ID $OS_LIKE " in
|
||||
*" arch "*|*manjaro*) PKG=pacman ;;
|
||||
*" ubuntu "*|*" debian "*) PKG=apt ;;
|
||||
*" fedora "*|*" rhel "*|*" centos "*) PKG=dnf ;;
|
||||
*) if have apt-get; then PKG=apt
|
||||
elif have dnf; then PKG=dnf
|
||||
elif have yum; then PKG=yum
|
||||
elif have pacman; then PKG=pacman
|
||||
fi ;;
|
||||
esac
|
||||
[ -n "$PKG" ] || die "could not detect a supported package manager (apt/dnf/yum/pacman). Install git + Docker manually, then re-run."
|
||||
[ "$PKG" = dnf ] && ! have dnf && have yum && PKG=yum
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
have sudo || die "this script needs root for package installs — install sudo or run as root."
|
||||
SUDO="sudo"
|
||||
fi
|
||||
local where="$OS_ID"; [ "$IS_WSL" -eq 1 ] && where="$OS_ID (WSL)"
|
||||
info "Detected ${where:-linux} — using ${PKG}."
|
||||
}
|
||||
|
||||
pkg_install() {
|
||||
info "Installing: $*"
|
||||
case "$PKG" in
|
||||
apt) $SUDO apt-get update -y && $SUDO apt-get install -y "$@" ;;
|
||||
dnf) $SUDO dnf install -y "$@" ;;
|
||||
yum) $SUDO yum install -y "$@" ;;
|
||||
pacman) $SUDO pacman -Sy --needed --noconfirm "$@" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# -- git ----------------------------------------------------------------------
|
||||
ensure_git() {
|
||||
have git && return
|
||||
warn "git is not installed."
|
||||
ask "Install git now?" y || die "git is required to clone the repo."
|
||||
pkg_install git || die "git installation failed — see the output above."
|
||||
have git || die "git installation reported success but 'git' is not on PATH."
|
||||
}
|
||||
|
||||
clone_repo() {
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
info "Updating existing checkout at $INSTALL_DIR"
|
||||
git -C "$INSTALL_DIR" pull --ff-only || warn "could not fast-forward; using the existing checkout."
|
||||
else
|
||||
[ -e "$INSTALL_DIR" ] && [ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ] \
|
||||
&& die "$INSTALL_DIR exists and is not a turnstone checkout. Set TURNSTONE_DIR to an empty path."
|
||||
info "Cloning $REPO_URL into $INSTALL_DIR"
|
||||
# Skip Git LFS smudge — the LFS objects are only diagram PNGs, not needed to run.
|
||||
GIT_LFS_SKIP_SMUDGE=1 git clone --depth 1 "$REPO_URL" "$INSTALL_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
# -- docker -------------------------------------------------------------------
|
||||
DOCKER="docker"
|
||||
|
||||
install_docker() {
|
||||
case "$PKG" in
|
||||
apt|dnf|yum)
|
||||
info "Installing Docker via the official get.docker.com script"
|
||||
curl -fsSL https://get.docker.com | $SUDO sh ;;
|
||||
pacman)
|
||||
pkg_install docker docker-compose ;;
|
||||
esac
|
||||
# Best-effort: start the daemon and let the current user run docker.
|
||||
if have systemctl; then
|
||||
$SUDO systemctl enable --now docker 2>/dev/null || true
|
||||
fi
|
||||
if [ "$(id -u)" -ne 0 ] && getent group docker >/dev/null 2>&1; then
|
||||
$SUDO usermod -aG docker "$USER" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
start_docker_daemon() {
|
||||
if have systemctl; then
|
||||
$SUDO systemctl start docker 2>/dev/null || true
|
||||
elif have service; then
|
||||
$SUDO service docker start 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve how to invoke docker (direct / via sudo) and make sure the daemon runs.
|
||||
ensure_docker_usable() {
|
||||
if ! have docker; then
|
||||
warn "Docker is not installed."
|
||||
ask "Install Docker now?" y || die "Docker is required."
|
||||
install_docker
|
||||
have docker || die "Docker installation failed."
|
||||
fi
|
||||
|
||||
docker info >/dev/null 2>&1 && { DOCKER="docker"; return; }
|
||||
|
||||
# Maybe the daemon just isn't running yet.
|
||||
start_docker_daemon
|
||||
docker info >/dev/null 2>&1 && { DOCKER="docker"; return; }
|
||||
|
||||
# Maybe we lack permission (not in the docker group yet, group change not
|
||||
# active in this shell) — fall back to sudo for this run.
|
||||
if [ "$(id -u)" -ne 0 ] && have sudo && sudo docker info >/dev/null 2>&1; then
|
||||
DOCKER="sudo docker"
|
||||
warn "Using 'sudo docker' for this run. To drop the sudo: 'sudo usermod -aG docker $USER' then log out and back in."
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$IS_WSL" -eq 1 ]; then
|
||||
die "Docker isn't usable inside WSL. Install Docker Desktop on Windows and enable WSL integration for this distro (Settings -> Resources -> WSL integration), then re-run."
|
||||
fi
|
||||
die "Docker is installed but not usable — the daemon may be stopped or you lack permission. Try: 'sudo systemctl start docker', then re-run."
|
||||
}
|
||||
|
||||
ensure_compose() {
|
||||
$DOCKER compose version >/dev/null 2>&1 && return
|
||||
warn "The Docker Compose v2 plugin is missing."
|
||||
case "$PKG" in
|
||||
apt|dnf|yum) ask "Install docker-compose-plugin?" y && pkg_install docker-compose-plugin || true ;;
|
||||
pacman) ask "Install docker-compose?" y && pkg_install docker-compose || true ;;
|
||||
esac
|
||||
$DOCKER compose version >/dev/null 2>&1 \
|
||||
|| die "'docker compose' is unavailable. Install Docker Compose v2 and re-run."
|
||||
}
|
||||
|
||||
# -- node count ---------------------------------------------------------------
|
||||
NODE_COUNT=10
|
||||
|
||||
pick_node_count() {
|
||||
if [ ! -r /dev/tty ]; then
|
||||
info "Non-interactive shell — starting the recommended 10-node cluster."
|
||||
return
|
||||
fi
|
||||
cat >/dev/tty <<EOF
|
||||
|
||||
${BOLD}How many server nodes? (1-10)${RESET}
|
||||
The full 10-node cluster is recommended — it best shows off routing across
|
||||
nodes. Each node uses on the order of a few hundred MB of RAM at idle; MCP
|
||||
stdio servers that a node launches can raise that. Pick fewer on a small
|
||||
machine — you can always add nodes later.
|
||||
EOF
|
||||
local ans
|
||||
while :; do
|
||||
printf '%sNodes [default 10]:%s ' "$BOLD" "$RESET" >/dev/tty
|
||||
read -r ans </dev/tty || ans=""
|
||||
ans="${ans:-10}"
|
||||
case "$ans" in
|
||||
[1-9]|10) NODE_COUNT="$ans"; break ;;
|
||||
*) printf 'Please enter a whole number from 1 to 10.\n' >/dev/tty ;;
|
||||
esac
|
||||
done
|
||||
info "Will start ${NODE_COUNT} server node(s)."
|
||||
}
|
||||
|
||||
# Pin the chosen count: park nodes above NODE_COUNT behind the "extra" profile in
|
||||
# an auto-loaded compose.override.yaml, so a later *plain* `docker compose up -d`
|
||||
# keeps the same count (Compose merges compose.override.yaml automatically). A
|
||||
# full (10) choice removes the file. Never clobbers an override we didn't write.
|
||||
OVERRIDE_MARKER="# turnstone run.sh — node-count limiter (safe to delete)"
|
||||
write_node_override() {
|
||||
local f="$INSTALL_DIR/compose.override.yaml" k
|
||||
if [ -f "$f" ] && ! head -1 "$f" 2>/dev/null | grep -qF "$OVERRIDE_MARKER"; then
|
||||
warn "$f exists and isn't managed by this installer — leaving it as-is."
|
||||
warn "A plain 'docker compose up -d' may not match your ${NODE_COUNT}-node choice."
|
||||
return
|
||||
fi
|
||||
if [ "$NODE_COUNT" -ge 10 ]; then
|
||||
rm -f "$f"
|
||||
return
|
||||
fi
|
||||
{
|
||||
echo "$OVERRIDE_MARKER"
|
||||
echo "# Keeps 'docker compose up -d' at ${NODE_COUNT} node(s). Nodes above"
|
||||
echo "# node-${NODE_COUNT} are parked behind the 'extra' profile:"
|
||||
echo "# docker compose --profile extra up -d # start all 10"
|
||||
echo "# docker compose up -d node-$((NODE_COUNT + 1)) # start one more"
|
||||
echo "services:"
|
||||
for k in $(seq $((NODE_COUNT + 1)) 10); do
|
||||
printf ' node-%s: { profiles: ["extra"] }\n' "$k"
|
||||
done
|
||||
} >"$f"
|
||||
info "Pinned ${NODE_COUNT} node(s) in $f (a later 'docker compose up -d' honors it)."
|
||||
}
|
||||
|
||||
# -- ports --------------------------------------------------------------------
|
||||
port_in_use() {
|
||||
local p="$1"
|
||||
if have ss; then
|
||||
ss -ltnH 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${p}\$" && return 0 || return 1
|
||||
fi
|
||||
if have lsof; then
|
||||
lsof -iTCP:"$p" -sTCP:LISTEN >/dev/null 2>&1 && return 0 || return 1
|
||||
fi
|
||||
# bash fallback: a successful connect means something is listening.
|
||||
(exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null && { exec 3>&- 3<&-; return 0; }
|
||||
return 1
|
||||
}
|
||||
port_free() { ! port_in_use "$1"; }
|
||||
|
||||
random_high_port() {
|
||||
local p
|
||||
for _ in $(seq 1 25); do
|
||||
p=$(( (RANDOM % 64000) + 1024 ))
|
||||
port_free "$p" && { echo "$p"; return; }
|
||||
done
|
||||
echo "" # caller handles
|
||||
}
|
||||
|
||||
pick_caddy_port() {
|
||||
# Prefer 443; rootless Docker can't bind privileged ports, so skip it there.
|
||||
if [ "$ROOTLESS" -eq 0 ] && port_free 443; then echo 443; return; fi
|
||||
port_free 8443 && { echo 8443; return; }
|
||||
local p; p="$(random_high_port)"
|
||||
[ -n "$p" ] && { echo "$p"; return; }
|
||||
echo 8443
|
||||
}
|
||||
|
||||
pick_pg_port() {
|
||||
port_free 5432 && { echo 5432; return; }
|
||||
local p; p="$(random_high_port)"
|
||||
[ -n "$p" ] && { echo "$p"; return; }
|
||||
echo 5432
|
||||
}
|
||||
|
||||
# -- secrets / .env -----------------------------------------------------------
|
||||
gen_hex() {
|
||||
# $1 = number of bytes → 2*$1 hex chars
|
||||
if have openssl; then openssl rand -hex "$1"
|
||||
elif have python3; then python3 -c 'import secrets,sys; print(secrets.token_hex(int(sys.argv[1])))' "$1"
|
||||
else head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n'
|
||||
fi
|
||||
}
|
||||
|
||||
_env_get() { # _env_get KEY DEFAULT → value from $INSTALL_DIR/.env, else DEFAULT
|
||||
local v
|
||||
v="$(grep -E "^$1=" "$INSTALL_DIR/.env" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
|
||||
printf '%s' "${v:-$2}"
|
||||
}
|
||||
|
||||
# Resolve CADDY_PORT + PG_PORT and ensure a .env exists. On re-run the existing
|
||||
# .env (secrets + ports) is reused so the rest of the script reports the ports
|
||||
# the stack actually binds, not freshly-picked ones.
|
||||
prepare_env() {
|
||||
if [ -f "$INSTALL_DIR/.env" ]; then
|
||||
info "Keeping existing $INSTALL_DIR/.env (secrets and ports preserved)."
|
||||
chmod 600 "$INSTALL_DIR/.env" 2>/dev/null || true
|
||||
CADDY_PORT="$(_env_get CONSOLE_HTTPS_PORT 8443)"
|
||||
PG_PORT="$(_env_get POSTGRES_PORT 5432)"
|
||||
return
|
||||
fi
|
||||
CADDY_PORT="$(pick_caddy_port)"
|
||||
PG_PORT="$(pick_pg_port)"
|
||||
local jwt pgpw
|
||||
jwt="$(gen_hex 32)" # 64 hex chars — comfortably over the 32-char minimum
|
||||
pgpw="$(gen_hex 18)" # hex keeps it URL-safe in the Postgres DSN
|
||||
# Write 0600 from the start (umask scoped to the subshell so it doesn't leak).
|
||||
(
|
||||
umask 077
|
||||
cat >"$INSTALL_DIR/.env" <<EOF
|
||||
# Generated by run.sh — keep this private.
|
||||
TURNSTONE_JWT_SECRET=$jwt
|
||||
POSTGRES_USER=turnstone
|
||||
POSTGRES_PASSWORD=$pgpw
|
||||
POSTGRES_BIND=127.0.0.1
|
||||
POSTGRES_PORT=$PG_PORT
|
||||
CONSOLE_HTTPS_PORT=$CADDY_PORT
|
||||
EOF
|
||||
)
|
||||
chmod 600 "$INSTALL_DIR/.env"
|
||||
info "Wrote $INSTALL_DIR/.env (generated JWT secret + Postgres password, mode 600)."
|
||||
}
|
||||
|
||||
# -- summary ------------------------------------------------------------------
|
||||
print_done() {
|
||||
local url scale
|
||||
[ "$CADDY_PORT" = 443 ] && url="https://localhost" || url="https://localhost:${CADDY_PORT}"
|
||||
if [ "$NODE_COUNT" -lt 10 ]; then
|
||||
scale="${NODE_COUNT} of 10 nodes — pinned in compose.override.yaml, so a plain
|
||||
'docker compose up -d' keeps it. Start all 10 anytime:
|
||||
${DIM}cd $INSTALL_DIR && $DOCKER compose --profile extra up -d${RESET}"
|
||||
else
|
||||
scale="all 10 nodes. To run fewer, stop some:
|
||||
${DIM}cd $INSTALL_DIR && $DOCKER compose stop node-8 node-9 node-10${RESET}"
|
||||
fi
|
||||
cat <<EOF
|
||||
|
||||
${GREEN}${BOLD}Turnstone is running${RESET} (${NODE_COUNT} node$([ "$NODE_COUNT" = 1 ] || echo s)).
|
||||
|
||||
Dashboard ${BOLD}${url}${RESET}
|
||||
Caddy serves it with its own local CA, so your browser warns once.
|
||||
Trust it (optional):
|
||||
${DIM}cd $INSTALL_DIR && $DOCKER compose exec caddy cat /data/caddy/pki/authorities/local/root.crt${RESET}
|
||||
|
||||
Finish setup
|
||||
1. Create the first admin user:
|
||||
${DIM}cd $INSTALL_DIR && $DOCKER compose exec node-1 turnstone-admin create-user --username admin --name "Admin"${RESET}
|
||||
2. Open ${url}, log in, and add a model backend in the ${BOLD}Models${RESET} tab —
|
||||
a local server (vLLM / llama.cpp / Ollama) or an OpenAI / Anthropic / Gemini key.
|
||||
Nodes boot without a model and pick it up live; no restart needed.
|
||||
|
||||
Scale Running ${scale}
|
||||
|
||||
Manage ${DIM}cd $INSTALL_DIR${RESET}
|
||||
${DIM}$DOCKER compose ps${RESET} status
|
||||
${DIM}$DOCKER compose logs -f${RESET} logs
|
||||
${DIM}$DOCKER compose down${RESET} stop (add -v to wipe data)
|
||||
|
||||
Config $INSTALL_DIR/.env (generated secrets + ports)
|
||||
EOF
|
||||
}
|
||||
|
||||
# -- main ---------------------------------------------------------------------
|
||||
main() {
|
||||
printf '%s%sTurnstone installer%s\n\n' "$BOLD" "$GREEN" "$RESET"
|
||||
|
||||
detect_os
|
||||
ensure_git
|
||||
clone_repo
|
||||
ensure_docker_usable
|
||||
ensure_compose
|
||||
|
||||
ROOTLESS=0
|
||||
$DOCKER info 2>/dev/null | grep -qi 'rootless' && ROOTLESS=1
|
||||
|
||||
pick_node_count
|
||||
|
||||
info "Building the image (first run pulls dependencies — this can take a few minutes)…"
|
||||
if ! ( cd "$INSTALL_DIR" && $DOCKER compose build ); then
|
||||
die "image build failed (see output above). Common causes: low memory or disk, or the Docker daemon stopped. Free up resources and re-run — it resumes."
|
||||
fi
|
||||
|
||||
prepare_env
|
||||
info "Ports — dashboard (Caddy): ${CADDY_PORT}, PostgreSQL: 127.0.0.1:${PG_PORT}"
|
||||
write_node_override
|
||||
|
||||
info "Starting the stack…"
|
||||
# Plain `up -d` (honoring compose.override.yaml) + --remove-orphans so a
|
||||
# re-run that lowers the count also stops the now-excluded nodes.
|
||||
if ! ( cd "$INSTALL_DIR" && $DOCKER compose up -d --remove-orphans ); then
|
||||
die "the stack failed to start (see output above). Inspect logs: cd $INSTALL_DIR && $DOCKER compose logs"
|
||||
fi
|
||||
|
||||
print_done
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,260 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Manual BM25-vs-BM25→rerank benchmark for the retrieval surfaces.
|
||||
|
||||
Run this by hand on a host that has a Cohere/Jina-compatible rerank endpoint
|
||||
configured (vLLM, TEI, llama.cpp, or a hosted Cohere/Jina/Voyage key). It is NOT
|
||||
a pytest test and is never collected by the test suite — it needs a live endpoint
|
||||
to do anything useful.
|
||||
|
||||
It compares plain BM25 top-k against the two-stage BM25→rerank path on two small
|
||||
in-file labeled corpora (tool-like docs and synthetic memory dicts), printing
|
||||
precision@k, MRR, a ranking diff, and the relevant-vs-irrelevant score
|
||||
distribution so you can pick a sensible ``tools.rerank_bm25_threshold`` default.
|
||||
|
||||
The endpoint bearer token (for hosted providers) is read from the
|
||||
``$TURNSTONE_RERANK_API_KEY`` environment variable, never a flag, so it does not
|
||||
land in shell history or the process listing.
|
||||
|
||||
Example::
|
||||
|
||||
TURNSTONE_RERANK_API_KEY=... \
|
||||
.venv/bin/python scripts/bench_bm25_rerank.py \
|
||||
--rerank-url http://localhost:8000/rerank \
|
||||
--rerank-model BAAI/bge-reranker-v2-m3 --k 3 --threshold 0.0
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import statistics
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from turnstone.core.bm25 import BM25Index
|
||||
from turnstone.core.rerank import resolve_rerank_client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from turnstone.core.rerank import RerankClient
|
||||
|
||||
# (query, relevant-doc-name) labels over a handful of tool-like docs.
|
||||
TOOL_DOCS: list[dict[str, str]] = [
|
||||
{"name": "read_file", "description": "Read the contents of a file from disk"},
|
||||
{"name": "write_file", "description": "Write or overwrite a file on disk"},
|
||||
{"name": "list_dir", "description": "List the entries in a directory"},
|
||||
{"name": "web_search", "description": "Search the web and return result snippets"},
|
||||
{"name": "send_email", "description": "Send an email message to a recipient"},
|
||||
{"name": "run_bash", "description": "Execute a shell command and capture output"},
|
||||
{"name": "create_issue", "description": "Open a new issue in the bug tracker"},
|
||||
{"name": "query_db", "description": "Run a SQL query against the database"},
|
||||
]
|
||||
TOOL_LABELS: list[tuple[str, str]] = [
|
||||
("open a file and show me what's inside", "read_file"),
|
||||
("look something up on the internet", "web_search"),
|
||||
("send a message by email", "send_email"),
|
||||
("run a terminal command", "run_bash"),
|
||||
("file a bug report", "create_issue"),
|
||||
("fetch rows from the database", "query_db"),
|
||||
]
|
||||
|
||||
# Synthetic memory dicts (name + description + content) with labeled queries.
|
||||
MEMORY_DOCS: list[dict[str, str]] = [
|
||||
{
|
||||
"name": "postgres_conn",
|
||||
"description": "database connection settings",
|
||||
"content": "host=db.internal port=5432 user=app sslmode=require",
|
||||
},
|
||||
{
|
||||
"name": "redis_cache",
|
||||
"description": "cache server config",
|
||||
"content": "host=redis port=6379 maxmemory 2gb eviction allkeys-lru",
|
||||
},
|
||||
{
|
||||
"name": "deploy_runbook",
|
||||
"description": "production deploy steps",
|
||||
"content": "build image, push to registry, roll nodes one at a time",
|
||||
},
|
||||
{
|
||||
"name": "oncall_rotation",
|
||||
"description": "who is on call",
|
||||
"content": "primary alice, secondary bob, escalate to carol after 30m",
|
||||
},
|
||||
{
|
||||
"name": "tls_certs",
|
||||
"description": "certificate renewal",
|
||||
"content": "acme renews every 60 days; caddy reloads the SSLContext in place",
|
||||
},
|
||||
{
|
||||
"name": "api_ratelimits",
|
||||
"description": "rate limit policy",
|
||||
"content": "100 req/min per token, burst 20, 429 with retry-after header",
|
||||
},
|
||||
{
|
||||
"name": "backup_schedule",
|
||||
"description": "nightly backups",
|
||||
"content": "pg_dump at 02:00 UTC, retained 14 days, offsite copy weekly",
|
||||
},
|
||||
{
|
||||
"name": "feature_flags",
|
||||
"description": "rollout toggles",
|
||||
"content": "rerank_bm25 default on, voice_io default off, smart_approvals off",
|
||||
},
|
||||
{
|
||||
"name": "smtp_settings",
|
||||
"description": "outbound email",
|
||||
"content": "relay smtp.internal port 587 starttls from noreply@example.com",
|
||||
},
|
||||
{
|
||||
"name": "log_retention",
|
||||
"description": "log storage policy",
|
||||
"content": "structured logs to loki, 30 day retention, audit logs 1 year",
|
||||
},
|
||||
{
|
||||
"name": "node_placement",
|
||||
"description": "routing config",
|
||||
"content": "rendezvous hashing fnv-1a, ~100 node ceiling, hrw weights",
|
||||
},
|
||||
{
|
||||
"name": "jwt_secret_rotation",
|
||||
"description": "auth secret",
|
||||
"content": "TURNSTONE_JWT_SECRET in config.toml, rotate quarterly, hs256",
|
||||
},
|
||||
]
|
||||
MEMORY_LABELS: list[tuple[str, str]] = [
|
||||
("what is the postgres database host and port", "postgres_conn"),
|
||||
("how often do we rotate the jwt signing secret", "jwt_secret_rotation"),
|
||||
("when do nightly database backups run", "backup_schedule"),
|
||||
("who do I escalate an incident to", "oncall_rotation"),
|
||||
("how are nodes placed for routing", "node_placement"),
|
||||
("what is the per token api rate limit", "api_ratelimits"),
|
||||
]
|
||||
|
||||
|
||||
def _doc_text(d: dict[str, str]) -> str:
|
||||
return " ".join(
|
||||
filter(None, (d.get("name", ""), d.get("description", ""), d.get("content", "")))
|
||||
)
|
||||
|
||||
|
||||
def _make_rank(client: RerankClient, threshold: float) -> Callable[[str, list[str]], list[int]]:
|
||||
def _rank(query: str, docs: list[str]) -> list[int]:
|
||||
return [
|
||||
h.index for h in client.rerank(query, docs) if threshold <= 0 or h.score >= threshold
|
||||
]
|
||||
|
||||
return _rank
|
||||
|
||||
|
||||
def _precision_at_k(result_names: list[str], relevant: str, k: int) -> float:
|
||||
return 1.0 / k if relevant in result_names[:k] else 0.0
|
||||
|
||||
|
||||
def _reciprocal_rank(result_names: list[str], relevant: str) -> float:
|
||||
for i, name in enumerate(result_names, 1):
|
||||
if name == relevant:
|
||||
return 1.0 / i
|
||||
return 0.0
|
||||
|
||||
|
||||
def _bench_corpus(
|
||||
title: str,
|
||||
docs: list[dict[str, str]],
|
||||
labels: list[tuple[str, str]],
|
||||
client: RerankClient,
|
||||
threshold: float,
|
||||
k: int,
|
||||
) -> None:
|
||||
print(f"\n=== {title} ({len(docs)} docs, {len(labels)} queries, k={k}) ===")
|
||||
texts = [_doc_text(d) for d in docs]
|
||||
names = [d["name"] for d in docs]
|
||||
plain = BM25Index(texts)
|
||||
# Filter mode when a floor is set (mirrors the memory composition call site)
|
||||
# so --threshold actually suppresses below-floor hits rather than being
|
||||
# masked by reorder-mode backfill; reorder mode at threshold 0.
|
||||
reranked = BM25Index(
|
||||
texts, reranker=_make_rank(client, threshold), rerank_filters=threshold > 0
|
||||
)
|
||||
|
||||
bm25_p = bm25_mrr = rr_p = rr_mrr = 0.0
|
||||
for query, relevant in labels:
|
||||
b_names = [names[i] for i in plain.search(query, k=k)]
|
||||
r_names = [names[i] for i in reranked.search(query, k=k)]
|
||||
bm25_p += _precision_at_k(b_names, relevant, k)
|
||||
rr_p += _precision_at_k(r_names, relevant, k)
|
||||
bm25_mrr += _reciprocal_rank([names[i] for i in plain.search(query, k=len(docs))], relevant)
|
||||
rr_mrr += _reciprocal_rank(
|
||||
[names[i] for i in reranked.search(query, k=len(docs))], relevant
|
||||
)
|
||||
flag = "" if b_names[:k] == r_names[:k] else " <-- reordered"
|
||||
print(f" q: {query!r}")
|
||||
print(f" want={relevant} bm25={b_names[:k]} rerank={r_names[:k]}{flag}")
|
||||
|
||||
n = len(labels)
|
||||
print(f" -- precision@{k}: bm25={bm25_p / n:.3f} rerank={rr_p / n:.3f}")
|
||||
print(f" -- MRR: bm25={bm25_mrr / n:.3f} rerank={rr_mrr / n:.3f}")
|
||||
|
||||
|
||||
def _score_distribution(
|
||||
docs: list[dict[str, str]],
|
||||
labels: list[tuple[str, str]],
|
||||
client: RerankClient,
|
||||
) -> None:
|
||||
"""Print rerank-score stats for labeled relevant vs irrelevant pairs.
|
||||
|
||||
A threshold default should sit above the irrelevant max / below the relevant
|
||||
min where those separate; this prints both so you can eyeball the gap.
|
||||
"""
|
||||
texts = [_doc_text(d) for d in docs]
|
||||
names = [d["name"] for d in docs]
|
||||
relevant_scores: list[float] = []
|
||||
irrelevant_scores: list[float] = []
|
||||
for query, relevant in labels:
|
||||
for hit in client.rerank(query, texts):
|
||||
bucket = relevant_scores if names[hit.index] == relevant else irrelevant_scores
|
||||
bucket.append(hit.score)
|
||||
|
||||
print("\n=== rerank score distribution (memory corpus) ===")
|
||||
for label, scores in (("relevant", relevant_scores), ("irrelevant", irrelevant_scores)):
|
||||
if not scores:
|
||||
print(f" {label}: (no scores)")
|
||||
continue
|
||||
print(
|
||||
f" {label:<10} n={len(scores):>3} "
|
||||
f"min={min(scores):.4f} median={statistics.median(scores):.4f} "
|
||||
f"max={max(scores):.4f}"
|
||||
)
|
||||
if relevant_scores and irrelevant_scores:
|
||||
suggested = (min(relevant_scores) + max(irrelevant_scores)) / 2
|
||||
sep = "separable" if min(relevant_scores) > max(irrelevant_scores) else "overlapping"
|
||||
print(f" -> classes are {sep}; midpoint threshold candidate ~= {suggested:.4f}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--rerank-url", required=True, help="full Cohere/Jina-compatible /rerank URL")
|
||||
ap.add_argument("--rerank-model", default="", help="model name sent in the request body")
|
||||
ap.add_argument("--threshold", type=float, default=0.0, help="relevance floor (0 disables)")
|
||||
ap.add_argument("--k", type=int, default=3, help="top-k to score precision@k over")
|
||||
args = ap.parse_args()
|
||||
|
||||
# Bearer token comes from the environment, not a flag, to keep it out of
|
||||
# shell history and the process listing.
|
||||
api_key = os.environ.get("TURNSTONE_RERANK_API_KEY", "")
|
||||
client = resolve_rerank_client(args.rerank_url, model=args.rerank_model, api_key=api_key)
|
||||
if client is None:
|
||||
print("No rerank endpoint resolved (empty --rerank-url?). Nothing to do.")
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"reranker: url={args.rerank_url} model={args.rerank_model or '(default)'} "
|
||||
f"threshold={args.threshold}"
|
||||
)
|
||||
_bench_corpus("tool search", TOOL_DOCS, TOOL_LABELS, client, args.threshold, args.k)
|
||||
_bench_corpus("memory composition", MEMORY_DOCS, MEMORY_LABELS, client, args.threshold, args.k)
|
||||
_score_distribution(MEMORY_DOCS, MEMORY_LABELS, client)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,503 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the livepass harnesses — render real hatch dialogs/shelves headlessly.
|
||||
|
||||
The livepass is how converted modal surfaces get verified without booting a
|
||||
server: a minimal page that symlinks the REAL stylesheets and scripts, embeds
|
||||
the REAL markup (extracted fresh from the index files at build time), stubs
|
||||
``window.authFetch`` with canned fixtures, and drives surfaces via ``?open=``
|
||||
query params — including click-driving submits so dead buttons can't hide
|
||||
(the model-Save bug class).
|
||||
|
||||
Usage:
|
||||
python3 scripts/livepass.py # build into /tmp/livepass/
|
||||
python3 scripts/livepass.py --out DIR # build elsewhere
|
||||
python3 scripts/livepass.py --serve 8950 # build + serve (Ctrl+C stops)
|
||||
|
||||
Then screenshot states (file:// blocks ES modules — always serve over http;
|
||||
the reduced-motion flag is REQUIRED, entrance animations race the capture):
|
||||
|
||||
google-chrome --headless --disable-gpu --hide-scrollbars \\
|
||||
--force-prefers-reduced-motion --window-size=1440,900 \\
|
||||
--virtual-time-budget=9000 --screenshot=out.png \\
|
||||
"http://localhost:8950/ui/livepass.html?open=new-ws&theme=light"
|
||||
|
||||
UI harness (?open=): new-ws · new-ws-fork · edit-title · delete-ws ·
|
||||
revoke-mcp · ws-delete · ws-delete-results (+ &theme=light, &busy=1)
|
||||
Console harness (?open=): schedule-create · schedule-edit · model-create ·
|
||||
model-edit · model-save (drives a Save click; document.title becomes
|
||||
PUT-OK-<n> on success) · policy · confirm · token
|
||||
Plus &tall=1 (90-row users panel — the .admin-content scroll state; the
|
||||
synthetic rows wrap to two lines, so judge overflow geometry, not row
|
||||
cadence) · &scrolled=1 lands mid-list, &scrolled=bottom shows the 24px
|
||||
scroll tail · &focuslast=1 focuses the last shelf-body control (the
|
||||
displaced-dock regression probe: only .sh-body may scroll; head/foot stay
|
||||
pinned). All combinable with ?open=. The console page wraps the fragment
|
||||
in the REAL L-shell chain — pane-pinned height, interior scroller — so
|
||||
scroll/dock geometry matches production; keep it that way. Body-level
|
||||
dialogs (confirm/install/coord-delete) are injected as riders; a driven
|
||||
?open= that ends with no open dialog stamps OPEN-FAILED-<state> into the
|
||||
title instead of passing silently.
|
||||
Governance surfaces (roles/HR/OGP/memory/skill) need fixtures that are not
|
||||
canned yet — add a fixture + driver branch below when you need one.
|
||||
|
||||
Rebuild after ANY markup change: the dialog blocks are embedded at build
|
||||
time. Assets are symlinked, so CSS/JS edits are live on refresh.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
UI_INDEX = ROOT / "turnstone/ui/static/index.html"
|
||||
CONSOLE_INDEX = ROOT / "turnstone/console/static/index.html"
|
||||
|
||||
|
||||
def extract_dialogs(index: Path, only_id: str | None = None) -> list[str]:
|
||||
"""Every <dialog class="hatch ..."> block, verbatim from the tree."""
|
||||
html = index.read_text(encoding="utf-8")
|
||||
blocks = []
|
||||
for m in re.finditer(r"[ \t]*<dialog\s[^>]*class=\"[^\"]*\bhatch\b[^\"]*\"", html):
|
||||
end = html.index("</dialog>", m.start()) + len("</dialog>")
|
||||
block = html[m.start() : end]
|
||||
if only_id and f'id="{only_id}"' not in block:
|
||||
continue
|
||||
blocks.append(block)
|
||||
if not blocks:
|
||||
raise SystemExit(f"no dialog.hatch blocks found in {index}")
|
||||
return blocks
|
||||
|
||||
|
||||
def extract_admin_fragment() -> str:
|
||||
"""The console admin pane — the hatch-host all shelves live inside."""
|
||||
html = CONSOLE_INDEX.read_text(encoding="utf-8")
|
||||
start = html.index('<div id="admin-layout"')
|
||||
end = html.index("<!-- /admin-layout -->") + len("<!-- /admin-layout -->")
|
||||
return html[start:end]
|
||||
|
||||
|
||||
def inject(template: str, marker: str, payload: str) -> str:
|
||||
begin = template.index(f"<!-- {marker}:BEGIN -->") + len(f"<!-- {marker}:BEGIN -->")
|
||||
end = template.index(f"<!-- {marker}:END -->")
|
||||
return template[:begin] + "\n" + payload + "\n" + template[end:]
|
||||
|
||||
|
||||
def symlink(link: Path, target: Path) -> None:
|
||||
if link.is_symlink() or link.exists():
|
||||
link.unlink()
|
||||
link.symlink_to(target)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# UI harness — the standalone app's dialog tier. Drives the REAL cards.js
|
||||
# controller for the batch surfaces so the production code path renders.
|
||||
# --------------------------------------------------------------------------
|
||||
UI_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ui livepass</title>
|
||||
<link rel="stylesheet" href="shared/base.css" />
|
||||
<link rel="stylesheet" href="shared/ui-base.css" />
|
||||
<link rel="stylesheet" href="shared/chat.css" />
|
||||
<link rel="stylesheet" href="shared/conversation.css" />
|
||||
<link rel="stylesheet" href="shared/cards.css" />
|
||||
<link rel="stylesheet" href="static/style.css" />
|
||||
<link rel="stylesheet" href="shared/shell.css" />
|
||||
<link rel="stylesheet" href="shared/interactive.css" />
|
||||
<link rel="stylesheet" href="shared/hatch.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- DIALOGS:BEGIN -->
|
||||
<!-- DIALOGS:END -->
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
<script>
|
||||
window.authFetch = function (url) {
|
||||
// One canned failure so the results view shows the mixed state.
|
||||
var fail = url && url.indexOf("c3d4e5f6a1b2") !== -1;
|
||||
return Promise.resolve({
|
||||
ok: !fail,
|
||||
status: fail ? 409 : 200,
|
||||
headers: { get: function () { return "application/json"; } },
|
||||
json: function () { return Promise.resolve({}); },
|
||||
text: function () {
|
||||
return Promise.resolve(
|
||||
fail ? '{"error": "workstream is still running"}' : "",
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
window.showToast = function (msg) { console.log("toast:", msg); };
|
||||
</script>
|
||||
<script type="module">
|
||||
import { openDialog, setBusy } from "./shared/hatch.js";
|
||||
const q = new URLSearchParams(location.search);
|
||||
if (q.get("theme") === "light")
|
||||
document.documentElement.dataset.theme = "light";
|
||||
const open = q.get("open") || "";
|
||||
function fill(id, text) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
if (open === "new-ws" || open === "new-ws-fork") {
|
||||
const dlg = document.getElementById("new-ws-dialog");
|
||||
const canned = {
|
||||
"new-ws-model": ["sonnet-4-6", "gpt-5-2", "qwen3-32b"],
|
||||
"new-ws-judge-model": ["sonnet-4-6", "qwen3-32b"],
|
||||
"new-ws-skill": ["code-review (default)", "deep-research"],
|
||||
};
|
||||
for (const id in canned) {
|
||||
const s = document.getElementById(id);
|
||||
for (const n of canned[id]) {
|
||||
const o = document.createElement("option");
|
||||
o.value = n;
|
||||
o.textContent = n;
|
||||
s.appendChild(o);
|
||||
}
|
||||
}
|
||||
if (open === "new-ws-fork") {
|
||||
fill("new-ws-title", "Fork workstream");
|
||||
fill("new-ws-tag", "WS-FORK");
|
||||
document.getElementById("new-ws-submit").textContent = "Fork";
|
||||
const skillLabel = document.querySelector('label[for="new-ws-skill"]');
|
||||
if (skillLabel) skillLabel.hidden = true;
|
||||
document.getElementById("new-ws-skill").hidden = true;
|
||||
document.getElementById("new-ws-attach-row").hidden = true;
|
||||
}
|
||||
openDialog(dlg);
|
||||
} else if (open === "edit-title") {
|
||||
document.getElementById("edit-title-input").value =
|
||||
"lshell renovation pass 3";
|
||||
openDialog(document.getElementById("edit-title-dialog"));
|
||||
} else if (open === "delete-ws") {
|
||||
fill(
|
||||
"delete-ws-message",
|
||||
'Delete "lshell renovation pass 3"? This cannot be undone.',
|
||||
);
|
||||
openDialog(document.getElementById("delete-ws-dialog"));
|
||||
} else if (open === "revoke-mcp") {
|
||||
fill(
|
||||
"revoke-mcp-message",
|
||||
"Revoke the connection to github? Tools that need this server will require re-consent.",
|
||||
);
|
||||
openDialog(document.getElementById("revoke-mcp-dialog"));
|
||||
} else if (open === "ws-delete" || open === "ws-delete-results") {
|
||||
// Drive the REAL shared controller so the dialog renders through
|
||||
// the production code path (cards.js confirmSelection/confirm).
|
||||
const mod = await import("./shared/cards.js");
|
||||
const c = mod.createSavedCardsController({
|
||||
idPrefix: "ws-delete",
|
||||
buttonId: "ws-delete-btn",
|
||||
noun: "workstream",
|
||||
activateLabel: (s) => "Resume: " + (s.title || s.ws_id),
|
||||
render: () => {},
|
||||
buildDeleteRequest: (wsId) => ({
|
||||
url: "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/delete",
|
||||
options: { method: "POST" },
|
||||
}),
|
||||
});
|
||||
c.setItems([
|
||||
{ ws_id: "a1b2c3d4e5f6", title: "lshell renovation pass 3" },
|
||||
{ ws_id: "b2c3d4e5f6a1", title: "canonical trajectory spike" },
|
||||
{
|
||||
ws_id: "c3d4e5f6a1b2",
|
||||
title:
|
||||
"a very long workstream title that should wrap " +
|
||||
"rather than punch out of the dialog box entirely",
|
||||
},
|
||||
]);
|
||||
c.toggleAll();
|
||||
c.confirmSelection();
|
||||
if (open === "ws-delete-results") c.confirm();
|
||||
}
|
||||
if (q.get("busy")) {
|
||||
const d = document.querySelector("dialog[open]");
|
||||
if (d) setBusy(d, true);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Console harness — the admin pane fragment hosts the shelves (token-created
|
||||
# included); dialog-tier markup outside the fragment (confirm/install/
|
||||
# coord-delete) is injected via the RIDERS marker in build().
|
||||
# model-save click-drives the submit: document.title flips to PUT-OK-<n>.
|
||||
# --------------------------------------------------------------------------
|
||||
CONSOLE_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>console livepass</title>
|
||||
<link rel="stylesheet" href="shared/base.css" />
|
||||
<link rel="stylesheet" href="shared/ui-base.css" />
|
||||
<link rel="stylesheet" href="console-static/style.css" />
|
||||
<link rel="stylesheet" href="shared/shell.css" />
|
||||
<link rel="stylesheet" href="shared/hatch.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- The REAL L-shell chain (shell.js buildShell + pane.js DOM, verbatim
|
||||
class names) so the harness inherits production scroll geometry:
|
||||
.pane-body > #view-admin > .admin-layout height-pin the hatch-host
|
||||
and .admin-content is the pane's interior scroller. Never replace
|
||||
this with bespoke height overrides — the clipped-pane / displaced-
|
||||
shelf regressions were invisible to the harness precisely because
|
||||
it used to pin #admin-layout with its own CSS. -->
|
||||
<div class="app">
|
||||
<aside class="rail" id="shell-rail">
|
||||
<div class="rail-brand">
|
||||
<button class="brand-home" type="button">
|
||||
<div class="brand-mark"></div>
|
||||
<span class="brand-name">turnstone</span>
|
||||
<span class="brand-sub">console</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="content">
|
||||
<div class="tabbar"></div>
|
||||
<div class="panes">
|
||||
<section class="pane">
|
||||
<!-- no .pane-head: PaneManager._mount builds section.pane >
|
||||
div.pane-body only -->
|
||||
<div class="pane-body">
|
||||
<div id="view-admin">
|
||||
<!-- FRAGMENT:BEGIN -->
|
||||
<!-- FRAGMENT:END -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<!-- Body-level dialog tier (confirm / install / coord-delete): their
|
||||
markup sits OUTSIDE #admin-layout in index.html, so the fragment
|
||||
extraction misses them — build() injects every hatch dialog the
|
||||
fragment does not already contain. -->
|
||||
<!-- RIDERS:BEGIN -->
|
||||
<!-- RIDERS:END -->
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
<script>
|
||||
(function () {
|
||||
function reply(data) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: function () { return "application/json"; } },
|
||||
json: function () { return Promise.resolve(data); },
|
||||
text: function () { return Promise.resolve(JSON.stringify(data)); },
|
||||
});
|
||||
}
|
||||
var SCHED = {
|
||||
task_id: "t1", name: "nightly-digest", description: "Morning digest",
|
||||
schedule_type: "cron", cron_expr: "0 6 * * 1,3,5", at_time: "",
|
||||
target_mode: "auto", model: "fable-5", skill: "daily-digest",
|
||||
initial_message: "Summarize overnight cluster activity.",
|
||||
auto_approve: false, enabled: true,
|
||||
notify_targets: [{ channel_type: "discord", channel_id: "8675309" }],
|
||||
next_run: "2026-06-10T06:00:00",
|
||||
};
|
||||
var MODEL = {
|
||||
definition_id: "def1", alias: "fable-5", model: "claude-fable-5",
|
||||
provider: "anthropic", base_url: "", context_window: 200000,
|
||||
capabilities: JSON.stringify({ supports_vision: true }),
|
||||
enabled: true, temperature: null, max_tokens: null,
|
||||
reasoning_effort: null, surface_persisted_reasoning: true,
|
||||
replay_reasoning_to_model: false,
|
||||
};
|
||||
window.__putCount = 0;
|
||||
window.authFetch = function (url, opts) {
|
||||
var method = (opts && opts.method) || "GET";
|
||||
if (method === "PUT" && url.indexOf("/model-definitions/def1") >= 0) {
|
||||
window.__putCount++;
|
||||
document.title = "PUT-OK-" + window.__putCount;
|
||||
return reply({ ok: true });
|
||||
}
|
||||
if (url.indexOf("/schedules/preview") >= 0)
|
||||
return reply({
|
||||
valid: true, error: "",
|
||||
next: [
|
||||
"2026-06-10T06:00:00+00:00",
|
||||
"2026-06-12T06:00:00+00:00",
|
||||
"2026-06-15T06:00:00+00:00",
|
||||
],
|
||||
});
|
||||
if (url.indexOf("/schedules/t1") >= 0) return reply(SCHED);
|
||||
if (url.indexOf("/schedules") >= 0) return reply({ schedules: [SCHED] });
|
||||
if (url.indexOf("/model-capabilities/known") >= 0)
|
||||
return reply({ models: ["claude-fable-5", "claude-opus-4-8"] });
|
||||
if (url.indexOf("/model-capabilities?") >= 0)
|
||||
return reply({
|
||||
known: true,
|
||||
capabilities: {
|
||||
context_window: 200000, supports_tools: true,
|
||||
supports_streaming: true, supports_vision: true,
|
||||
supports_web_search: true, supports_temperature: true,
|
||||
supports_effort: true,
|
||||
},
|
||||
});
|
||||
if (url.indexOf("/model-definitions/def1") >= 0) return reply(MODEL);
|
||||
if (url.indexOf("/model-definitions") >= 0) return reply({ models: [] });
|
||||
if (url.indexOf("/api/models") >= 0)
|
||||
return reply({ models: [
|
||||
{ alias: "fable-5", model: "claude-fable-5" },
|
||||
{ alias: "gpt-5.2", model: "gpt-5.2" },
|
||||
] });
|
||||
if (url.indexOf("/skills") >= 0)
|
||||
return reply({ skills: [{ name: "daily-digest" }, { name: "ops-runbook" }] });
|
||||
if (url.indexOf("/policies") >= 0)
|
||||
return reply({ policies: [
|
||||
{ policy_id: "p1", name: "deny-rm", tool_pattern: "bash*rm*",
|
||||
action: "deny", priority: 900, enabled: true },
|
||||
{ policy_id: "p2", name: "default-ask", tool_pattern: "*",
|
||||
action: "ask", priority: 0, enabled: true },
|
||||
] });
|
||||
return reply({});
|
||||
};
|
||||
window.showToast = function (m) {
|
||||
console.log("toast:", m);
|
||||
var t = document.getElementById("toast");
|
||||
t.textContent = m;
|
||||
t.classList.add("show");
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="shared/utils.js"></script>
|
||||
<script type="module" src="shared/hatch.js"></script>
|
||||
<script src="console-static/admin.js"></script>
|
||||
<script src="console-static/governance.js"></script>
|
||||
<script>
|
||||
window.addEventListener("load", function () {
|
||||
var q = new URLSearchParams(location.search);
|
||||
if (q.get("theme") === "light")
|
||||
document.documentElement.dataset.theme = "light";
|
||||
var open = q.get("open") || "";
|
||||
// ?tall=1 — the scroll state: one panel visible with enough rows to
|
||||
// overflow the pane, so a screenshot shows .admin-content scrolling
|
||||
// (and a shelf staying docked above it). Mirrors switchAdminTab's
|
||||
// one-panel-visible invariant without booting the tab loaders.
|
||||
if (q.get("tall")) {
|
||||
var panels = document.querySelectorAll(".admin-panel");
|
||||
for (var i = 0; i < panels.length; i++)
|
||||
panels[i].style.display =
|
||||
panels[i].id === "admin-users" ? "" : "none";
|
||||
// No fallback: a fragment rename must fail loudly, not misplace rows.
|
||||
var rowHost = document.querySelector("#admin-users [role=list]");
|
||||
rowHost.textContent = ""; // drop the static "Loading users…" stub
|
||||
for (var r = 0; r < 90; r++) {
|
||||
var row = document.createElement("div");
|
||||
row.className = "admin-row"; // real row chrome — geometry tracks production
|
||||
row.textContent =
|
||||
"user-" + String(r).padStart(3, "0") + " \\u00b7 synthetic row";
|
||||
rowHost.appendChild(row);
|
||||
}
|
||||
var content = document.getElementById("admin-content");
|
||||
if (content && q.get("scrolled"))
|
||||
content.scrollTop =
|
||||
q.get("scrolled") === "bottom"
|
||||
? content.scrollHeight // the 24px scroll-tail state
|
||||
: content.scrollHeight / 2; // land mid-list
|
||||
}
|
||||
setTimeout(function () {
|
||||
if (open === "schedule-create") showCreateScheduleModal();
|
||||
else if (open === "schedule-edit") showEditScheduleModal("t1");
|
||||
else if (open === "model-create") showCreateModelModal();
|
||||
else if (open === "model-edit" || open === "model-save")
|
||||
showEditModelModal("def1");
|
||||
else if (open === "policy") {
|
||||
window._govPolicies && _govPolicies.length === 0 &&
|
||||
loadGovPolicies && loadGovPolicies();
|
||||
showCreatePolicyModal();
|
||||
} else if (open === "confirm")
|
||||
showConfirmModal(
|
||||
"Delete schedule",
|
||||
"Delete nightly-digest? Its run history is removed with it. This cannot be undone.",
|
||||
"Delete",
|
||||
function () {},
|
||||
);
|
||||
else if (open === "token")
|
||||
showTokenCreatedModal(
|
||||
"tsk_9f2e41c7a8b35d60e1f4a2b89c7d3e5f6a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d",
|
||||
);
|
||||
if (open === "model-save")
|
||||
setTimeout(function () {
|
||||
document.getElementById("model-create-submit").click();
|
||||
}, 900);
|
||||
if (q.get("busy"))
|
||||
setTimeout(function () {
|
||||
var d = document.querySelector("dialog[open]");
|
||||
if (d) window.TurnstoneHatch.setBusy(d, true);
|
||||
}, 400);
|
||||
// A driven state that ends with nothing open must fail LOUDLY in
|
||||
// the screenshot pipeline, not render a quietly dialog-less page.
|
||||
setTimeout(function () {
|
||||
var top = document.querySelector("dialog[open]");
|
||||
if (open && !top) document.title = "OPEN-FAILED-" + open;
|
||||
// &focuslast=1 — the displaced-dock regression probe: focus the
|
||||
// last form control in the shelf BODY (the visually-hidden
|
||||
// toggle/radio inputs live there). Only .sh-body may scroll;
|
||||
// the head/foot strips must stay pinned in the screenshot.
|
||||
if (top && q.get("focuslast")) {
|
||||
var els = top.querySelectorAll(
|
||||
".sh-body input, .sh-body select, .sh-body textarea",
|
||||
);
|
||||
if (els.length) els[els.length - 1].focus();
|
||||
}
|
||||
}, 600);
|
||||
}, 150);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def build(out: Path) -> None:
|
||||
ui = out / "ui"
|
||||
con = out / "console"
|
||||
ui.mkdir(parents=True, exist_ok=True)
|
||||
con.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
symlink(ui / "shared", ROOT / "turnstone/shared_static")
|
||||
symlink(ui / "static", ROOT / "turnstone/ui/static")
|
||||
blocks = extract_dialogs(UI_INDEX)
|
||||
# the coordinator batch dialog shares the cards.js builder — ride along
|
||||
blocks += extract_dialogs(CONSOLE_INDEX, only_id="coord-delete-dialog")
|
||||
(ui / "livepass.html").write_text(
|
||||
inject(UI_TEMPLATE, "DIALOGS", "\n".join(blocks)), encoding="utf-8"
|
||||
)
|
||||
print(f"{ui}/livepass.html — {len(blocks)} dialogs")
|
||||
|
||||
symlink(con / "shared", ROOT / "turnstone/shared_static")
|
||||
symlink(con / "console-static", ROOT / "turnstone/console/static")
|
||||
frag = extract_admin_fragment()
|
||||
# Dialog-tier markup living OUTSIDE #admin-layout (confirm, install,
|
||||
# coord-delete) would otherwise be silently absent — and ?open=confirm
|
||||
# would screenshot a dialog-less page while the gate stayed green.
|
||||
riders = [b for b in extract_dialogs(CONSOLE_INDEX) if b not in frag]
|
||||
page = inject(CONSOLE_TEMPLATE, "FRAGMENT", frag)
|
||||
page = inject(page, "RIDERS", "\n".join(riders))
|
||||
(con / "livepass.html").write_text(page, encoding="utf-8")
|
||||
print(f"{con}/livepass.html — admin fragment + {len(riders)} rider dialogs")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--out", type=Path, default=Path("/tmp/livepass"))
|
||||
ap.add_argument("--serve", type=int, metavar="PORT")
|
||||
args = ap.parse_args()
|
||||
build(args.out)
|
||||
if args.serve:
|
||||
import functools
|
||||
import http.server
|
||||
|
||||
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(args.out))
|
||||
print(f"serving {args.out} on http://localhost:{args.serve}/ — Ctrl+C stops")
|
||||
http.server.ThreadingHTTPServer(("127.0.0.1", args.serve), handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -50,14 +50,11 @@ update_refs() {
|
||||
local old_pattern="$1" # e.g. katex-0.16.38
|
||||
local new_pattern="$2" # e.g. katex-0.16.39
|
||||
|
||||
# Find all files with version references. Excludes the old versioned vendor
|
||||
# directory itself (about to be rm -rf'd anyway) so we don't bother rewriting
|
||||
# self-references inside it — but does NOT exclude all of shared_static/,
|
||||
# because shared_static/renderer.js loads the vendored libs and needs the bump.
|
||||
# Find all files with version references (excludes vendored JS and worktrees)
|
||||
local files
|
||||
files=$(grep -rl --include='*.toml' --include='*.html' --include='*.js' --include='*.md' --include='*.py' \
|
||||
files=$(grep -rl --include='*.toml' --include='*.html' --include='*.js' --include='*.md' \
|
||||
-F "$old_pattern" . \
|
||||
--exclude-dir='.claude' --exclude-dir='node_modules' --exclude-dir="$old_pattern" \
|
||||
--exclude-dir='.claude' --exclude-dir='node_modules' --exclude-dir='shared_static' \
|
||||
2>/dev/null || true)
|
||||
for f in $files; do
|
||||
sed -i "s|${old_pattern}|${new_pattern}|g" "$f"
|
||||
|
||||
+12
-4303
File diff suppressed because it is too large
Load Diff
+336
-1556
File diff suppressed because it is too large
Load Diff
Generated
+170
-210
@@ -1,37 +1,39 @@
|
||||
{
|
||||
"name": "@turnstone/sdk",
|
||||
"version": "0.4.0",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@turnstone/sdk",
|
||||
"version": "0.4.0",
|
||||
"license": "Apache-2.0",
|
||||
"version": "0.3.0",
|
||||
"license": "BUSL-1.1",
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -43,6 +45,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -55,9 +58,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
|
||||
"integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -74,9 +77,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||
"version": "0.122.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
|
||||
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -84,9 +87,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -101,9 +104,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -118,9 +121,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -135,9 +138,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -152,9 +155,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
|
||||
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -169,16 +172,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -189,16 +189,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -209,16 +206,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -229,16 +223,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -249,16 +240,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -269,16 +257,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -289,9 +274,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -306,9 +291,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
|
||||
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -316,18 +301,16 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.10.0",
|
||||
"@emnapi/runtime": "1.10.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||
"@napi-rs/wasm-runtime": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -342,9 +325,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -359,9 +342,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -373,9 +356,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -402,23 +385,23 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
|
||||
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
|
||||
"integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/spy": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -427,13 +410,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
|
||||
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
|
||||
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/spy": "4.1.2",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -454,9 +437,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
|
||||
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
|
||||
"integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -467,13 +450,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
|
||||
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
|
||||
"integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -481,14 +464,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
|
||||
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
|
||||
"integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -497,9 +480,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
|
||||
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
|
||||
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -507,13 +490,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
|
||||
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
|
||||
"integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -559,9 +542,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -761,9 +744,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -785,9 +765,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -809,9 +786,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -833,9 +807,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -902,9 +873,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -921,18 +892,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz",
|
||||
"integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
@@ -962,9 +930,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -982,7 +950,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -991,14 +959,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.133.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
"@oxc-project/types": "=0.122.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.12"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -1007,21 +975,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-x64": "1.0.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.3",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
@@ -1049,9 +1017,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
|
||||
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1063,9 +1031,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
|
||||
"integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1073,14 +1041,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -1108,9 +1076,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1122,17 +1090,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz",
|
||||
"integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.3",
|
||||
"tinyglobby": "^0.2.17"
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.12",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
@@ -1148,7 +1116,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.18",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -1200,19 +1168,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
|
||||
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
|
||||
"integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.8",
|
||||
"@vitest/mocker": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/runner": "4.1.8",
|
||||
"@vitest/snapshot": "4.1.8",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/expect": "4.1.2",
|
||||
"@vitest/mocker": "4.1.2",
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"@vitest/runner": "4.1.2",
|
||||
"@vitest/snapshot": "4.1.2",
|
||||
"@vitest/spy": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -1240,12 +1208,10 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.8",
|
||||
"@vitest/browser-preview": "4.1.8",
|
||||
"@vitest/browser-webdriverio": "4.1.8",
|
||||
"@vitest/coverage-istanbul": "4.1.8",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"@vitest/ui": "4.1.8",
|
||||
"@vitest/browser-playwright": "4.1.2",
|
||||
"@vitest/browser-preview": "4.1.2",
|
||||
"@vitest/browser-webdriverio": "4.1.2",
|
||||
"@vitest/ui": "4.1.2",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
@@ -1269,12 +1235,6 @@
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@turnstone/sdk",
|
||||
"version": "0.4.0",
|
||||
"version": "0.3.0",
|
||||
"description": "TypeScript client SDK for the turnstone AI orchestration platform",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -30,7 +30,7 @@
|
||||
"sdk",
|
||||
"client"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"license": "BUSL-1.1",
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.1"
|
||||
|
||||
+24
-77
@@ -29,12 +29,6 @@ export interface ClientOptions {
|
||||
export interface RequestOptions {
|
||||
json?: object;
|
||||
params?: Record<string, string | number>;
|
||||
/**
|
||||
* When set, send as multipart form-data with this body. The runtime's
|
||||
* fetch sets the Content-Type + boundary itself, so we deliberately do
|
||||
* not include a Content-Type header in this case.
|
||||
*/
|
||||
form?: FormData;
|
||||
}
|
||||
|
||||
export class BaseClient {
|
||||
@@ -53,82 +47,17 @@ export class BaseClient {
|
||||
path: string,
|
||||
options?: RequestOptions,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (!options?.form) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (this.token) {
|
||||
headers["Authorization"] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const url = this._buildUrl(path, options?.params);
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (options?.form) {
|
||||
body = options.form;
|
||||
} else if (options?.json) {
|
||||
body = JSON.stringify(options.json);
|
||||
}
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
let msg = "";
|
||||
try {
|
||||
const errBody = (await resp.json()) as Record<string, unknown>;
|
||||
msg = (errBody.error as string) ?? (errBody.detail as string) ?? "";
|
||||
} catch {
|
||||
msg = await resp.text().catch(() => "");
|
||||
}
|
||||
throw new TurnstoneAPIError(resp.status, msg || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
return (await resp.json()) as T;
|
||||
}
|
||||
|
||||
protected async requestBytes(
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { params?: Record<string, string | number> },
|
||||
): Promise<{ bytes: Uint8Array; contentType: string; filename: string }> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.token) {
|
||||
headers["Authorization"] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const url = this._buildUrl(path, options?.params);
|
||||
const resp = await this.fetchFn(url, { method, headers });
|
||||
if (!resp.ok) {
|
||||
let msg = "";
|
||||
try {
|
||||
const errBody = (await resp.json()) as Record<string, unknown>;
|
||||
msg = (errBody.error as string) ?? (errBody.detail as string) ?? "";
|
||||
} catch {
|
||||
msg = await resp.text().catch(() => "");
|
||||
}
|
||||
throw new TurnstoneAPIError(resp.status, msg || `HTTP ${resp.status}`);
|
||||
}
|
||||
const contentType =
|
||||
resp.headers.get("content-type") ?? "application/octet-stream";
|
||||
const disposition = resp.headers.get("content-disposition") ?? "";
|
||||
const match = /filename="?([^";]+)"?/.exec(disposition);
|
||||
const filename = match ? match[1] : "";
|
||||
const buf = await resp.arrayBuffer();
|
||||
return { bytes: new Uint8Array(buf), contentType, filename };
|
||||
}
|
||||
|
||||
private _buildUrl(
|
||||
path: string,
|
||||
params?: Record<string, string | number>,
|
||||
): string {
|
||||
let url = `${this.baseUrl}${path}`;
|
||||
if (params) {
|
||||
if (options?.params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
for (const [key, value] of Object.entries(options.params)) {
|
||||
if (value !== undefined && value !== "") {
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
@@ -136,7 +65,25 @@ export class BaseClient {
|
||||
const qs = searchParams.toString();
|
||||
if (qs) url += `?${qs}`;
|
||||
}
|
||||
return url;
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method,
|
||||
headers,
|
||||
body: options?.json ? JSON.stringify(options.json) : undefined,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
let msg = "";
|
||||
try {
|
||||
const body = (await resp.json()) as Record<string, unknown>;
|
||||
msg = (body.error as string) ?? (body.detail as string) ?? "";
|
||||
} catch {
|
||||
msg = await resp.text().catch(() => "");
|
||||
}
|
||||
throw new TurnstoneAPIError(resp.status, msg || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
return (await resp.json()) as T;
|
||||
}
|
||||
|
||||
protected async *streamSSE<T = Record<string, unknown>>(
|
||||
|
||||
@@ -4,8 +4,6 @@ import type {
|
||||
AdminListMemoriesOptions,
|
||||
AdminMemoryInfo,
|
||||
AdminSearchMemoriesOptions,
|
||||
AttachmentContent,
|
||||
AttachmentUpload,
|
||||
AuditQueryOptions,
|
||||
AuditResponse,
|
||||
AuthLoginResponse,
|
||||
@@ -18,9 +16,6 @@ import type {
|
||||
ConsoleCreateWsRequest,
|
||||
ConsoleCreateWsResponse,
|
||||
ConsoleHealthResponse,
|
||||
CreateWorkstreamRequest,
|
||||
CreateWorkstreamResponse,
|
||||
ListAttachmentsResponse,
|
||||
CreateMcpServerRequest,
|
||||
CreatePolicyOptions,
|
||||
CreateRoleOptions,
|
||||
@@ -60,37 +55,12 @@ import type {
|
||||
UpdateScheduleRequest,
|
||||
UpdateSettingOptions,
|
||||
UpdateSkillRequest,
|
||||
UploadAttachmentResponse,
|
||||
UsageQueryOptions,
|
||||
UsageResponse,
|
||||
UserRoleInfo,
|
||||
WorkstreamsOptions,
|
||||
} from "./types.js";
|
||||
|
||||
function generateConsoleWsId(): string {
|
||||
// 16 bytes => 32 hex chars; matches `secrets.token_hex(16)` server-side.
|
||||
const buf = new Uint8Array(16);
|
||||
crypto.getRandomValues(buf);
|
||||
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function consoleAttachmentToBlob(att: AttachmentUpload): Blob {
|
||||
if (att.data instanceof Blob) {
|
||||
return att.mimeType
|
||||
? new Blob([att.data], { type: att.mimeType })
|
||||
: att.data;
|
||||
}
|
||||
// Copy bytes into a fresh ArrayBuffer-backed Uint8Array. The Blob
|
||||
// BlobPart type rejects ArrayBufferLike views (could be backed by
|
||||
// SharedArrayBuffer); a freshly allocated buffer is plainly ArrayBuffer.
|
||||
const src = att.data;
|
||||
const fresh = new Uint8Array(new ArrayBuffer(src.byteLength));
|
||||
fresh.set(src);
|
||||
return new Blob([fresh], {
|
||||
type: att.mimeType ?? "application/octet-stream",
|
||||
});
|
||||
}
|
||||
|
||||
/** Async client for the turnstone console API. */
|
||||
export class TurnstoneConsole extends BaseClient {
|
||||
constructor(options: ClientOptions) {
|
||||
@@ -143,92 +113,6 @@ export class TurnstoneConsole extends BaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
// -- Routing proxy --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a workstream via the console rendezvous router.
|
||||
*
|
||||
* When `attachments` is non-empty the request is sent as
|
||||
* multipart/form-data and the console routes via `?ws_id=<hex>`
|
||||
* (auto-generated when not supplied) so the body lands on the
|
||||
* owning node directly.
|
||||
*/
|
||||
async routeCreateWorkstream(
|
||||
opts?: CreateWorkstreamRequest & { target_node?: string },
|
||||
): Promise<
|
||||
CreateWorkstreamResponse & { node_url?: string; node_id?: string }
|
||||
> {
|
||||
const attachments = opts?.attachments;
|
||||
if (attachments && attachments.length > 0) {
|
||||
// The console's multipart route_create routes by `?ws_id=` only —
|
||||
// it does not parse the body to honor `target_node`. Refuse the
|
||||
// combination at the SDK boundary so callers don't silently get
|
||||
// routed to the wrong node.
|
||||
if (opts?.target_node) {
|
||||
throw new Error(
|
||||
"target_node is not supported with attachments; " +
|
||||
"use ws_id (caller-generated to hash to the desired node) instead",
|
||||
);
|
||||
}
|
||||
const meta: Record<string, unknown> = { ...opts };
|
||||
delete (meta as { attachments?: unknown }).attachments;
|
||||
let wsId = (meta.ws_id as string | undefined) ?? "";
|
||||
if (!wsId) {
|
||||
wsId = generateConsoleWsId();
|
||||
meta.ws_id = wsId;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append("meta", JSON.stringify(meta));
|
||||
for (const att of attachments) {
|
||||
form.append("file", consoleAttachmentToBlob(att), att.filename);
|
||||
}
|
||||
return this.request("POST", "/v1/api/route/workstreams/new", {
|
||||
form,
|
||||
params: { ws_id: wsId },
|
||||
});
|
||||
}
|
||||
return this.request("POST", "/v1/api/route/workstreams/new", {
|
||||
json: opts ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
async routeUploadAttachment(
|
||||
wsId: string,
|
||||
file: AttachmentUpload,
|
||||
): Promise<UploadAttachmentResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", consoleAttachmentToBlob(file), file.filename);
|
||||
return this.request(
|
||||
"POST",
|
||||
`/v1/api/route/workstreams/${wsId}/attachments`,
|
||||
{ form },
|
||||
);
|
||||
}
|
||||
|
||||
async routeListAttachments(wsId: string): Promise<ListAttachmentsResponse> {
|
||||
return this.request("GET", `/v1/api/route/workstreams/${wsId}/attachments`);
|
||||
}
|
||||
|
||||
async routeGetAttachmentContent(
|
||||
wsId: string,
|
||||
attachmentId: string,
|
||||
): Promise<AttachmentContent> {
|
||||
return this.requestBytes(
|
||||
"GET",
|
||||
`/v1/api/route/workstreams/${wsId}/attachments/${attachmentId}/content`,
|
||||
);
|
||||
}
|
||||
|
||||
async routeDeleteAttachment(
|
||||
wsId: string,
|
||||
attachmentId: string,
|
||||
): Promise<StatusResponse> {
|
||||
return this.request(
|
||||
"DELETE",
|
||||
`/v1/api/route/workstreams/${wsId}/attachments/${attachmentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// -- Streaming ------------------------------------------------------------
|
||||
|
||||
async *clusterEvents(): AsyncIterableIterator<ClusterEvent> {
|
||||
|
||||
@@ -13,25 +13,6 @@ export interface ConnectedEvent {
|
||||
|
||||
export interface HistoryEvent {
|
||||
type: "history";
|
||||
/**
|
||||
* Per-message dicts the frontend consumes directly. Notable optional keys:
|
||||
* - `role`: "user" | "assistant" | "tool" | "system"
|
||||
* - `content`: string for text turns, list for image/document parts
|
||||
* - `tool_calls`: assistant turns — list of `{id, name, arguments, verdict?, output_assessment?}`
|
||||
* - `tool_call_id`: tool turns — id of the originating call
|
||||
* - `source`: the operator-context kind on a `system` turn (`output_guard` /
|
||||
* `user_interjection` / `tool_error` / ...), or `system_nudge` on a
|
||||
* wake-driven empty user turn
|
||||
* - `meta`: structured per-kind fields on an operator-context `system` turn
|
||||
* (e.g. `watch_triggered`'s `{watch_name, command, poll_count, max_polls,
|
||||
* is_final}`) so the renderer can rebuild per-kind UI (the watch-result
|
||||
* card); absent for kinds with no structured data
|
||||
* - `attachments`: per-attachment metadata `{kind, filename, mime_type}`
|
||||
* - `reasoning`: concatenated reasoning text for assistant turns that
|
||||
* round-tripped a thinking-block lane (Anthropic-with-thinking today;
|
||||
* OpenAI Responses + Gemini in later phases). Present only when the
|
||||
* active model's `surface_persisted_reasoning` flag is true.
|
||||
*/
|
||||
messages: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
@@ -57,14 +38,6 @@ export interface StreamEndEvent {
|
||||
type: "stream_end";
|
||||
}
|
||||
|
||||
/** One-shot replay of the in-progress turn's content + reasoning emitted
|
||||
* by the events SSE handler when a fresh subscriber connects mid-stream. */
|
||||
export interface InProgressSnapshotEvent {
|
||||
type: "in_progress_snapshot";
|
||||
content: string;
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
export interface StateChangeEvent {
|
||||
type: "state_change";
|
||||
state: "idle" | "thinking" | "running" | "attention" | "error";
|
||||
@@ -114,6 +87,11 @@ export interface StatusEvent {
|
||||
turn_count?: number;
|
||||
}
|
||||
|
||||
export interface PlanReviewEvent {
|
||||
type: "plan_review";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface InfoEvent {
|
||||
type: "info";
|
||||
message: string;
|
||||
@@ -179,7 +157,6 @@ export type ServerEvent =
|
||||
| ContentEvent
|
||||
| ReasoningEvent
|
||||
| StreamEndEvent
|
||||
| InProgressSnapshotEvent
|
||||
| StateChangeEvent
|
||||
| ToolInfoEvent
|
||||
| ApproveRequestEvent
|
||||
@@ -187,6 +164,7 @@ export type ServerEvent =
|
||||
| ToolResultEvent
|
||||
| ToolOutputChunkEvent
|
||||
| StatusEvent
|
||||
| PlanReviewEvent
|
||||
| InfoEvent
|
||||
| ErrorEvent
|
||||
| BusyErrorEvent
|
||||
@@ -277,12 +255,6 @@ export function isStreamEndEvent(e: ServerEvent): e is StreamEndEvent {
|
||||
return e.type === "stream_end";
|
||||
}
|
||||
|
||||
export function isInProgressSnapshotEvent(
|
||||
e: ServerEvent,
|
||||
): e is InProgressSnapshotEvent {
|
||||
return e.type === "in_progress_snapshot";
|
||||
}
|
||||
|
||||
export function isStateChangeEvent(e: ServerEvent): e is StateChangeEvent {
|
||||
return e.type === "state_change";
|
||||
}
|
||||
@@ -307,6 +279,10 @@ export function isApprovalResolvedEvent(
|
||||
return e.type === "approval_resolved";
|
||||
}
|
||||
|
||||
export function isPlanReviewEvent(e: ServerEvent): e is PlanReviewEvent {
|
||||
return e.type === "plan_review";
|
||||
}
|
||||
|
||||
export function isCancelledEvent(e: ServerEvent): e is CancelledEvent {
|
||||
return e.type === "cancelled";
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export type {
|
||||
ToolResultEvent,
|
||||
ToolOutputChunkEvent,
|
||||
StatusEvent,
|
||||
PlanReviewEvent,
|
||||
InfoEvent,
|
||||
ErrorEvent,
|
||||
BusyErrorEvent,
|
||||
@@ -70,6 +71,7 @@ export {
|
||||
isWsStateEvent,
|
||||
isApproveRequestEvent,
|
||||
isApprovalResolvedEvent,
|
||||
isPlanReviewEvent,
|
||||
isCancelledEvent,
|
||||
} from "./events.js";
|
||||
|
||||
@@ -78,6 +80,7 @@ export type {
|
||||
SendRequest,
|
||||
SendResponse,
|
||||
ApproveRequest,
|
||||
PlanFeedbackRequest,
|
||||
CommandRequest,
|
||||
CreateWorkstreamRequest,
|
||||
CreateWorkstreamResponse,
|
||||
@@ -180,12 +183,6 @@ export type {
|
||||
SkillInstallRequest,
|
||||
SkillInstallResponse,
|
||||
SkillInstallSkipped,
|
||||
// Attachment types
|
||||
AttachmentUpload,
|
||||
AttachmentInfo,
|
||||
UploadAttachmentResponse,
|
||||
ListAttachmentsResponse,
|
||||
AttachmentContent,
|
||||
} from "./types.js";
|
||||
|
||||
// SSE parser (for advanced usage)
|
||||
|
||||
+30
-150
@@ -1,8 +1,6 @@
|
||||
import { BaseClient, type ClientOptions } from "./base.js";
|
||||
import type { ServerEvent } from "./events.js";
|
||||
import type {
|
||||
AttachmentContent,
|
||||
AttachmentUpload,
|
||||
AuthLoginResponse,
|
||||
AuthSetupResponse,
|
||||
AuthStatusResponse,
|
||||
@@ -11,46 +9,20 @@ import type {
|
||||
DashboardResponse,
|
||||
DeleteMemoryOptions,
|
||||
HealthResponse,
|
||||
ListAttachmentsResponse,
|
||||
ListMemoriesOptions,
|
||||
ListMemoriesResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
SkillSummary,
|
||||
ListWorkstreamsResponse,
|
||||
MemoryInfo,
|
||||
SaveMemoryRequest,
|
||||
SearchMemoriesRequest,
|
||||
SendAndWaitOptions,
|
||||
SendResponse,
|
||||
SkillSummary,
|
||||
StatusResponse,
|
||||
TurnResult,
|
||||
UploadAttachmentResponse,
|
||||
} from "./types.js";
|
||||
|
||||
function generateWsId(): string {
|
||||
// 16 bytes => 32 hex chars; matches `secrets.token_hex(16)` server-side.
|
||||
const buf = new Uint8Array(16);
|
||||
crypto.getRandomValues(buf);
|
||||
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function attachmentToBlob(att: AttachmentUpload): Blob {
|
||||
if (att.data instanceof Blob) {
|
||||
return att.mimeType
|
||||
? new Blob([att.data], { type: att.mimeType })
|
||||
: att.data;
|
||||
}
|
||||
// Copy bytes into a fresh ArrayBuffer-backed Uint8Array. The Blob
|
||||
// BlobPart type rejects ArrayBufferLike views (could be backed by
|
||||
// SharedArrayBuffer); a freshly allocated buffer is plainly ArrayBuffer.
|
||||
const src = att.data;
|
||||
const fresh = new Uint8Array(new ArrayBuffer(src.byteLength));
|
||||
fresh.set(src);
|
||||
return new Blob([fresh], {
|
||||
type: att.mimeType ?? "application/octet-stream",
|
||||
});
|
||||
}
|
||||
|
||||
/** Async client for the turnstone server API. */
|
||||
export class TurnstoneServer extends BaseClient {
|
||||
constructor(options: ClientOptions) {
|
||||
@@ -70,114 +42,46 @@ export class TurnstoneServer extends BaseClient {
|
||||
async createWorkstream(
|
||||
opts?: CreateWorkstreamRequest,
|
||||
): Promise<CreateWorkstreamResponse> {
|
||||
const attachments = opts?.attachments;
|
||||
if (attachments && attachments.length > 0) {
|
||||
// Multipart variant: pre-generate ws_id so cluster routers can
|
||||
// hash to the owning node before this body lands. Server accepts
|
||||
// either a server-generated id (when meta.ws_id is empty) or the
|
||||
// caller-supplied one.
|
||||
const meta: Record<string, unknown> = { ...opts };
|
||||
delete (meta as { attachments?: unknown }).attachments;
|
||||
if (!meta.ws_id) {
|
||||
meta.ws_id = generateWsId();
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append("meta", JSON.stringify(meta));
|
||||
for (const att of attachments) {
|
||||
form.append("file", attachmentToBlob(att), att.filename);
|
||||
}
|
||||
return this.request("POST", "/v1/api/workstreams/new", { form });
|
||||
}
|
||||
return this.request("POST", "/v1/api/workstreams/new", {
|
||||
json: opts ?? {},
|
||||
});
|
||||
return this.request("POST", "/v1/api/workstreams/new", { json: opts });
|
||||
}
|
||||
|
||||
async closeWorkstream(
|
||||
wsId: string,
|
||||
opts?: { reason?: string },
|
||||
): Promise<StatusResponse> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (opts?.reason !== undefined) body.reason = opts.reason;
|
||||
return this.request(
|
||||
"POST",
|
||||
`/v1/api/workstreams/${encodeURIComponent(wsId)}/close`,
|
||||
{ json: body },
|
||||
);
|
||||
async closeWorkstream(wsId: string): Promise<StatusResponse> {
|
||||
return this.request("POST", "/v1/api/workstreams/close", {
|
||||
json: { ws_id: wsId },
|
||||
});
|
||||
}
|
||||
|
||||
// -- Chat interaction -----------------------------------------------------
|
||||
|
||||
async send(
|
||||
message: string,
|
||||
wsId: string,
|
||||
opts?: { attachmentIds?: string[] },
|
||||
): Promise<SendResponse> {
|
||||
const body: Record<string, unknown> = { message };
|
||||
if (opts?.attachmentIds !== undefined) {
|
||||
body.attachment_ids = opts.attachmentIds;
|
||||
}
|
||||
return this.request(
|
||||
"POST",
|
||||
`/v1/api/workstreams/${encodeURIComponent(wsId)}/send`,
|
||||
{ json: body },
|
||||
);
|
||||
}
|
||||
|
||||
// -- Attachments ----------------------------------------------------------
|
||||
|
||||
async uploadAttachment(
|
||||
wsId: string,
|
||||
file: AttachmentUpload,
|
||||
): Promise<UploadAttachmentResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", attachmentToBlob(file), file.filename);
|
||||
return this.request("POST", `/v1/api/workstreams/${wsId}/attachments`, {
|
||||
form,
|
||||
async send(message: string, wsId: string): Promise<SendResponse> {
|
||||
return this.request("POST", "/v1/api/send", {
|
||||
json: { message, ws_id: wsId },
|
||||
});
|
||||
}
|
||||
|
||||
async listAttachments(wsId: string): Promise<ListAttachmentsResponse> {
|
||||
return this.request("GET", `/v1/api/workstreams/${wsId}/attachments`);
|
||||
}
|
||||
|
||||
async getAttachmentContent(
|
||||
wsId: string,
|
||||
attachmentId: string,
|
||||
): Promise<AttachmentContent> {
|
||||
return this.requestBytes(
|
||||
"GET",
|
||||
`/v1/api/workstreams/${wsId}/attachments/${attachmentId}/content`,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAttachment(
|
||||
wsId: string,
|
||||
attachmentId: string,
|
||||
): Promise<StatusResponse> {
|
||||
return this.request(
|
||||
"DELETE",
|
||||
`/v1/api/workstreams/${wsId}/attachments/${attachmentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async approve(opts: {
|
||||
wsId: string;
|
||||
approved?: boolean;
|
||||
feedback?: string | null;
|
||||
always?: boolean;
|
||||
}): Promise<StatusResponse> {
|
||||
return this.request(
|
||||
"POST",
|
||||
`/v1/api/workstreams/${encodeURIComponent(opts.wsId)}/approve`,
|
||||
{
|
||||
json: {
|
||||
approved: opts.approved ?? true,
|
||||
feedback: opts.feedback,
|
||||
always: opts.always,
|
||||
},
|
||||
return this.request("POST", "/v1/api/approve", {
|
||||
json: {
|
||||
ws_id: opts.wsId,
|
||||
approved: opts.approved ?? true,
|
||||
feedback: opts.feedback,
|
||||
always: opts.always,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async planFeedback(opts: {
|
||||
wsId: string;
|
||||
feedback?: string;
|
||||
}): Promise<StatusResponse> {
|
||||
return this.request("POST", "/v1/api/plan", {
|
||||
json: { ws_id: opts.wsId, feedback: opts.feedback ?? "" },
|
||||
});
|
||||
}
|
||||
|
||||
async command(opts: {
|
||||
@@ -193,39 +97,15 @@ export class TurnstoneServer extends BaseClient {
|
||||
wsId: string,
|
||||
opts?: { force?: boolean },
|
||||
): Promise<StatusResponse> {
|
||||
const body: Record<string, unknown> = {};
|
||||
const body: Record<string, unknown> = { ws_id: wsId };
|
||||
if (opts?.force) body.force = true;
|
||||
return this.request(
|
||||
"POST",
|
||||
`/v1/api/workstreams/${encodeURIComponent(wsId)}/cancel`,
|
||||
{ json: body },
|
||||
);
|
||||
}
|
||||
|
||||
/** Drop the last `turns` conversation turns. Emits a `clear_ui` event. */
|
||||
async rewind(wsId: string, turns: number): Promise<StatusResponse> {
|
||||
return this.request(
|
||||
"POST",
|
||||
`/v1/api/workstreams/${encodeURIComponent(wsId)}/rewind`,
|
||||
{ json: { turns } },
|
||||
);
|
||||
}
|
||||
|
||||
/** Drop the last response and re-send the last user message. */
|
||||
async retry(wsId: string): Promise<StatusResponse> {
|
||||
return this.request(
|
||||
"POST",
|
||||
`/v1/api/workstreams/${encodeURIComponent(wsId)}/retry`,
|
||||
{ json: {} },
|
||||
);
|
||||
return this.request("POST", "/v1/api/cancel", { json: body });
|
||||
}
|
||||
|
||||
// -- Streaming ------------------------------------------------------------
|
||||
|
||||
async *streamEvents(wsId: string): AsyncIterableIterator<ServerEvent> {
|
||||
yield* this.streamSSE<ServerEvent>(
|
||||
`/v1/api/workstreams/${encodeURIComponent(wsId)}/events`,
|
||||
);
|
||||
yield* this.streamSSE<ServerEvent>("/v1/api/events", { ws_id: wsId });
|
||||
}
|
||||
|
||||
async *streamGlobalEvents(): AsyncIterableIterator<ServerEvent> {
|
||||
@@ -265,8 +145,8 @@ export class TurnstoneServer extends BaseClient {
|
||||
try {
|
||||
// Start consuming the per-workstream SSE stream first
|
||||
const events = this.streamSSE<ServerEvent>(
|
||||
`/v1/api/workstreams/${encodeURIComponent(wsId)}/events`,
|
||||
undefined,
|
||||
"/v1/api/events",
|
||||
{ ws_id: wsId },
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
|
||||
+9
-117
@@ -50,67 +50,10 @@ export interface AuthSetupResponse {
|
||||
export interface SendRequest {
|
||||
message: string;
|
||||
ws_id: string;
|
||||
/**
|
||||
* Explicit list of pending attachment ids to inject into this turn.
|
||||
* When omitted, any pending attachments for the caller on the
|
||||
* workstream are auto-consumed; an empty list disables auto-consume.
|
||||
*/
|
||||
attachment_ids?: string[];
|
||||
}
|
||||
|
||||
export interface SendResponse {
|
||||
/** "ok" | "busy" | "queued" | "queue_full". */
|
||||
status: string;
|
||||
/**
|
||||
* Attachment ids actually reserved onto this turn. Subset of the
|
||||
* request's `attachment_ids` (or the auto-consumed pending set).
|
||||
*/
|
||||
attached_ids?: string[];
|
||||
/**
|
||||
* Attachment ids the caller requested that the server could not
|
||||
* reserve (lost a race, already consumed, or cross-scope). The
|
||||
* request still proceeds with whatever was reserved.
|
||||
*/
|
||||
dropped_attachment_ids?: string[];
|
||||
/** Set on "queued" responses: relative priority of the queued message. */
|
||||
priority?: string | null;
|
||||
/** Set on "queued" responses: id used to dequeue the message. */
|
||||
msg_id?: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server API — Attachments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A file to upload as an attachment. */
|
||||
export interface AttachmentUpload {
|
||||
filename: string;
|
||||
/** Raw file bytes; use a `Blob` in browsers and a `Uint8Array` in Node. */
|
||||
data: Blob | Uint8Array;
|
||||
/** Optional advisory MIME type; the server applies its own validation. */
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface AttachmentInfo {
|
||||
attachment_id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
/** "image" or "text". */
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export type UploadAttachmentResponse = AttachmentInfo;
|
||||
|
||||
export interface ListAttachmentsResponse {
|
||||
attachments: AttachmentInfo[];
|
||||
}
|
||||
|
||||
/** Raw bytes returned from the attachment `/content` endpoint. */
|
||||
export interface AttachmentContent {
|
||||
bytes: Uint8Array;
|
||||
contentType: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface ApproveRequest {
|
||||
@@ -120,6 +63,11 @@ export interface ApproveRequest {
|
||||
ws_id: string;
|
||||
}
|
||||
|
||||
export interface PlanFeedbackRequest {
|
||||
feedback: string;
|
||||
ws_id: string;
|
||||
}
|
||||
|
||||
export interface CommandRequest {
|
||||
command: string;
|
||||
ws_id: string;
|
||||
@@ -131,20 +79,6 @@ export interface CreateWorkstreamRequest {
|
||||
auto_approve?: boolean;
|
||||
resume_ws?: string;
|
||||
skill?: string;
|
||||
/** First user message dispatched in a background worker after creation. */
|
||||
initial_message?: string;
|
||||
/**
|
||||
* Caller-supplied workstream id (32-hex). Auto-generated when omitted.
|
||||
* Required for cluster-routed multipart creates so the console can
|
||||
* hash to the owning node before the body lands.
|
||||
*/
|
||||
ws_id?: string;
|
||||
/**
|
||||
* Files to attach to the first turn. When non-empty the request is
|
||||
* sent as multipart/form-data and (with `initial_message`) reserved
|
||||
* onto that turn before the worker dispatches.
|
||||
*/
|
||||
attachments?: AttachmentUpload[];
|
||||
}
|
||||
|
||||
export interface CreateWorkstreamResponse {
|
||||
@@ -152,56 +86,24 @@ export interface CreateWorkstreamResponse {
|
||||
name: string;
|
||||
resumed?: boolean;
|
||||
message_count?: number;
|
||||
/** Ids of attachments saved by this request (multipart variant only). */
|
||||
attachment_ids?: string[];
|
||||
}
|
||||
|
||||
export interface CloseWorkstreamRequest {
|
||||
/**
|
||||
* Optional close reason persisted to `workstream_config` for
|
||||
* postmortem. Capped at 512 UTF-8 bytes server-side; credential
|
||||
* redaction is applied via the output guard.
|
||||
*/
|
||||
reason?: string;
|
||||
ws_id: string;
|
||||
}
|
||||
|
||||
export interface WorkstreamInfo {
|
||||
// Renamed `id` → `ws_id` and added kind/parent_ws_id/user_id in
|
||||
// the Stage 2 list-verb lift. Pre-1.5 readers branching on
|
||||
// `row.id` should swap to `row.ws_id`.
|
||||
ws_id: string;
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
kind: string;
|
||||
parent_ws_id: string | null;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface ListWorkstreamsResponse {
|
||||
workstreams: WorkstreamInfo[];
|
||||
}
|
||||
|
||||
export interface WorkstreamDetailResponse {
|
||||
// Lifted from coord-only into a shared verb in the Stage 2
|
||||
// history/detail verb lift. Both kinds populate every field; SDK
|
||||
// consumers don't branch on kind.
|
||||
ws_id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
user_id: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export interface WorkstreamHistoryResponse {
|
||||
ws_id: string;
|
||||
// Tail of the workstream's reconstructed message history
|
||||
// (provider-fidelity OpenAI-like shape). Bounded by the ?limit=
|
||||
// query param (default 100, max 500).
|
||||
messages: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface DashboardWorkstream {
|
||||
ws_id: string;
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
title?: string;
|
||||
@@ -240,16 +142,6 @@ export interface SavedWorkstreamInfo {
|
||||
created: string;
|
||||
updated: string;
|
||||
message_count: number;
|
||||
// Enriched fields — all optional (defaulted server-side, so an older
|
||||
// server may omit them).
|
||||
state?: string;
|
||||
kind?: string;
|
||||
node_id?: string;
|
||||
model_alias?: string | null;
|
||||
launch_skill?: string | null;
|
||||
child_count?: number;
|
||||
context_tokens?: number;
|
||||
context_ratio?: number;
|
||||
}
|
||||
|
||||
export interface ListSavedWorkstreamsResponse {
|
||||
@@ -987,7 +879,7 @@ export interface SkillDiscoverListing {
|
||||
install_count: number;
|
||||
tags: string[];
|
||||
installed: boolean;
|
||||
risk_level?: string;
|
||||
scan_status?: string;
|
||||
template_id?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,28 +62,6 @@ describe("TurnstoneConsole", () => {
|
||||
expect(url).toContain("page=2");
|
||||
});
|
||||
|
||||
it("routeCreateWorkstream rejects attachments + target_node", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(
|
||||
new Response("{}", {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const client = new TurnstoneConsole({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
const data = new TextEncoder().encode("hi");
|
||||
await expect(
|
||||
client.routeCreateWorkstream({
|
||||
name: "x",
|
||||
target_node: "n1",
|
||||
attachments: [{ filename: "a.txt", data }],
|
||||
}),
|
||||
).rejects.toThrow(/target_node/);
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("health returns parsed response", async () => {
|
||||
const fetchFn = mockFetch({
|
||||
status: "ok",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isWsStateEvent,
|
||||
isApproveRequestEvent,
|
||||
isApprovalResolvedEvent,
|
||||
isPlanReviewEvent,
|
||||
isReasoningEvent,
|
||||
} from "../src/events.js";
|
||||
import type { ServerEvent } from "../src/events.js";
|
||||
@@ -70,4 +71,9 @@ describe("event type guards", () => {
|
||||
};
|
||||
expect(isApprovalResolvedEvent(e)).toBe(true);
|
||||
});
|
||||
|
||||
it("isPlanReviewEvent", () => {
|
||||
const e: ServerEvent = { type: "plan_review", content: "## Plan" };
|
||||
expect(isPlanReviewEvent(e)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TurnstoneServer } from "../src/server.js";
|
||||
|
||||
function mockFetch(response: object, status = 200): typeof globalThis.fetch {
|
||||
return vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(response), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchBytes(
|
||||
body: Uint8Array,
|
||||
contentType: string,
|
||||
filename = "",
|
||||
): typeof globalThis.fetch {
|
||||
const headers: Record<string, string> = { "content-type": contentType };
|
||||
if (filename)
|
||||
headers["content-disposition"] = `inline; filename="${filename}"`;
|
||||
return vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(body, { status: 200, headers }));
|
||||
}
|
||||
|
||||
describe("TurnstoneServer attachments", () => {
|
||||
it("uploadAttachment sends multipart with filename", async () => {
|
||||
const fetchFn = mockFetch({
|
||||
attachment_id: "att-1",
|
||||
filename: "a.txt",
|
||||
mime_type: "text/plain",
|
||||
size_bytes: 5,
|
||||
kind: "text",
|
||||
});
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
const data = new TextEncoder().encode("hello");
|
||||
const result = await client.uploadAttachment("ws-X", {
|
||||
filename: "a.txt",
|
||||
data,
|
||||
mimeType: "text/plain",
|
||||
});
|
||||
expect(result.attachment_id).toBe("att-1");
|
||||
|
||||
const [url, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("http://test/v1/api/workstreams/ws-X/attachments");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.body).toBeInstanceOf(FormData);
|
||||
// Browser/Node fetch sets the Content-Type header from FormData itself
|
||||
expect(init.headers["Content-Type"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("listAttachments hits the GET endpoint", async () => {
|
||||
const fetchFn = mockFetch({ attachments: [] });
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
const resp = await client.listAttachments("ws-X");
|
||||
expect(resp.attachments).toEqual([]);
|
||||
const [url, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("http://test/v1/api/workstreams/ws-X/attachments");
|
||||
expect(init.method).toBe("GET");
|
||||
});
|
||||
|
||||
it("getAttachmentContent returns raw bytes + parsed headers", async () => {
|
||||
const bytes = new TextEncoder().encode("hello world");
|
||||
const fetchFn = mockFetchBytes(
|
||||
bytes,
|
||||
"text/plain; charset=utf-8",
|
||||
"notes.md",
|
||||
);
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
const result = await client.getAttachmentContent("ws-X", "att-1");
|
||||
expect(new TextDecoder().decode(result.bytes)).toBe("hello world");
|
||||
expect(result.contentType).toBe("text/plain; charset=utf-8");
|
||||
expect(result.filename).toBe("notes.md");
|
||||
});
|
||||
|
||||
it("deleteAttachment hits the DELETE endpoint", async () => {
|
||||
const fetchFn = mockFetch({ status: "deleted" });
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
const resp = await client.deleteAttachment("ws-X", "att-1");
|
||||
expect(resp.status).toBe("deleted");
|
||||
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("send threads attachment_ids when provided", async () => {
|
||||
const fetchFn = mockFetch({ status: "ok" });
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
await client.send("hi", "ws-X", { attachmentIds: ["a1", "a2"] });
|
||||
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
message: "hi",
|
||||
ws_id: "ws-X",
|
||||
attachment_ids: ["a1", "a2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("send omits attachment_ids when not supplied", async () => {
|
||||
const fetchFn = mockFetch({ status: "ok" });
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
await client.send("hi", "ws-X");
|
||||
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(JSON.parse(init.body)).toEqual({ message: "hi", ws_id: "ws-X" });
|
||||
});
|
||||
|
||||
it("createWorkstream with attachments sends multipart and auto-generates ws_id", async () => {
|
||||
const fetchFn = mockFetch({
|
||||
ws_id: "00ff00000000000000000000000000ff",
|
||||
name: "demo",
|
||||
attachment_ids: ["att-1"],
|
||||
});
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
const data = new TextEncoder().encode("hello");
|
||||
const resp = await client.createWorkstream({
|
||||
name: "demo",
|
||||
initial_message: "describe",
|
||||
attachments: [{ filename: "a.txt", data, mimeType: "text/plain" }],
|
||||
});
|
||||
expect(resp.attachment_ids).toEqual(["att-1"]);
|
||||
|
||||
const [url, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("http://test/v1/api/workstreams/new");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.body).toBeInstanceOf(FormData);
|
||||
|
||||
const form = init.body as FormData;
|
||||
const meta = JSON.parse(form.get("meta") as string);
|
||||
expect(meta.name).toBe("demo");
|
||||
expect(meta.initial_message).toBe("describe");
|
||||
expect(meta.ws_id).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(meta.attachments).toBeUndefined();
|
||||
|
||||
const file = form.get("file");
|
||||
expect(file).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it("createWorkstream without attachments uses JSON body", async () => {
|
||||
const fetchFn = mockFetch({ ws_id: "ws-json", name: "j" });
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
await client.createWorkstream({ name: "j" });
|
||||
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(init.headers["Content-Type"]).toBe("application/json");
|
||||
expect(JSON.parse(init.body)).toEqual({ name: "j" });
|
||||
});
|
||||
});
|
||||
@@ -26,16 +26,7 @@ function mockFetchError(
|
||||
describe("TurnstoneServer", () => {
|
||||
it("listWorkstreams returns parsed response", async () => {
|
||||
const fetchFn = mockFetch({
|
||||
workstreams: [
|
||||
{
|
||||
ws_id: "ws1",
|
||||
name: "test",
|
||||
state: "idle",
|
||||
kind: "interactive",
|
||||
parent_ws_id: null,
|
||||
user_id: "u1",
|
||||
},
|
||||
],
|
||||
workstreams: [{ id: "ws1", name: "test", state: "idle" }],
|
||||
});
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
@@ -43,9 +34,7 @@ describe("TurnstoneServer", () => {
|
||||
});
|
||||
const resp = await client.listWorkstreams();
|
||||
expect(resp.workstreams).toHaveLength(1);
|
||||
// Row key renamed id → ws_id in the Stage 2 list-verb lift.
|
||||
expect(resp.workstreams[0].ws_id).toBe("ws1");
|
||||
expect(resp.workstreams[0].kind).toBe("interactive");
|
||||
expect(resp.workstreams[0].id).toBe("ws1");
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
"http://test/v1/api/workstreams",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
|
||||
+80
@@ -290,6 +290,34 @@
|
||||
"Spin up a README.md with a project title and description in it"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "plan-when-asked",
|
||||
"description": "Call the plan tool when the user asks to plan",
|
||||
"user_prompt": "Plan how to add user authentication to this app.",
|
||||
"setup": {
|
||||
"files": {
|
||||
"app.py": "from flask import Flask, jsonify\n\napp = Flask(__name__)\n\n@app.route('/users')\ndef list_users():\n return jsonify([{'id': 1, 'name': 'Alice'}])\n\nif __name__ == '__main__':\n app.run(port=8000)\n"
|
||||
}
|
||||
},
|
||||
"expected_actions": [
|
||||
{
|
||||
"tool": "plan_agent"
|
||||
}
|
||||
],
|
||||
"match_mode": "subset",
|
||||
"user_prompts": [
|
||||
"Plan how to add user authentication to this app.",
|
||||
"Make a plan for adding pagination to the API endpoints.",
|
||||
"Plan out how to add error handling to this application.",
|
||||
"I need a plan for adding logging to this codebase.",
|
||||
"Plan the approach for adding unit tests to this app.",
|
||||
"How would you approach adding user authentication to this app? Lay out a plan.",
|
||||
"I'd like you to outline a strategy for implementing user authentication in this application.",
|
||||
"Could you come up with a plan for integrating user authentication into this app?",
|
||||
"Think through the steps needed to add user auth to this app and present a plan.",
|
||||
"Draft a plan for incorporating user authentication functionality into this application."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "edit-not-rewrite",
|
||||
"description": "Use edit_file for small changes, not write_file to rewrite the entire file",
|
||||
@@ -384,6 +412,58 @@
|
||||
"Hit https://example.com and let me know what's there in summary form"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "man-page-lookup",
|
||||
"description": "Use man tool to look up command documentation",
|
||||
"user_prompt": "Look up the man page for tar and tell me what the --xattrs flag does",
|
||||
"expected_actions": [
|
||||
{
|
||||
"tool": "man",
|
||||
"args_pattern": {
|
||||
"page": "tar"
|
||||
}
|
||||
}
|
||||
],
|
||||
"match_mode": "subset",
|
||||
"user_prompts": [
|
||||
"Look up the man page for tar and tell me what the --xattrs flag does",
|
||||
"What does the --xattrs flag do in tar? Check the man page for me.",
|
||||
"Could you pull up the man page for tar and explain the --xattrs option?",
|
||||
"I need to know what --xattrs does in tar \u2014 can you check the man page?",
|
||||
"Check tar's man page and let me know the purpose of the --xattrs flag.",
|
||||
"Please consult the tar man page and describe what the --xattrs flag is for.",
|
||||
"Hey, look at the tar man page real quick \u2014 what's --xattrs do?",
|
||||
"I'd like you to read the tar man page and summarize the --xattrs option for me.",
|
||||
"Would you mind checking the man page for tar to find out what --xattrs means?",
|
||||
"Look into the tar manual and explain the --xattrs flag to me."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "math-calculation",
|
||||
"description": "Use the math tool for precise calculations, not bash or mental math",
|
||||
"user_prompt": "What is 2^64 - 1? Use the math tool to calculate it precisely.",
|
||||
"expected_actions": [
|
||||
{
|
||||
"tool": "math",
|
||||
"args_pattern": {
|
||||
"code": "2.*64"
|
||||
}
|
||||
}
|
||||
],
|
||||
"match_mode": "subset",
|
||||
"user_prompts": [
|
||||
"What is 2^64 - 1? Use the math tool to calculate it precisely.",
|
||||
"Calculate 2^64 - 1 for me using the math tool, please.",
|
||||
"I need the exact value of 2^64 - 1. Please use the math tool.",
|
||||
"Could you use the math tool to compute 2^64 minus 1 precisely?",
|
||||
"Use the math tool to tell me what 2^64 - 1 equals.",
|
||||
"I'm curious: what's 2^64 - 1? Compute it with the math tool.",
|
||||
"Please precisely determine 2^64 - 1 via the math tool.",
|
||||
"Mind using the math tool to figure out 2^64 - 1 exactly?",
|
||||
"I'd like to know the precise result of 2^64 - 1 \u2014 use the math tool for this.",
|
||||
"Leverage the math tool to give me an exact answer for 2^64 - 1."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "web-search-query",
|
||||
"description": "Use web_search for general knowledge lookups, not web_fetch",
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Shared builders for the coordinator-endpoint test files.
|
||||
|
||||
The four coordinator test modules each ship a copy of the same
|
||||
``_AuthMiddleware`` / ``_FakeConfigStore`` / ``_fake_registry`` /
|
||||
``_build_mgr`` helpers — this module is the single home for them so
|
||||
future edits land once. Named with a leading underscore so pytest
|
||||
does not collect it.
|
||||
|
||||
``_make_client`` stays local to each test module because the route
|
||||
list differs per file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.coordinator_adapter import CoordinatorAdapter
|
||||
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
|
||||
from turnstone.core.auth import AuthResult
|
||||
from turnstone.core.session_manager import SessionManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
def _seed_children(
|
||||
adapter: CoordinatorAdapter, coord_ws_id: str, child_ws_ids: Iterable[str]
|
||||
) -> None:
|
||||
"""Seed the coordinator adapter's children registry directly.
|
||||
|
||||
The production path populates the registry via the cluster-event
|
||||
fan-out thread observing ``ws_created`` events. These tests just
|
||||
need a known-children set for the endpoint handlers to iterate —
|
||||
inject directly via the registry's bulk-merge surface rather than
|
||||
spinning up the collector + fan-out plumbing.
|
||||
"""
|
||||
adapter._registry.merge_children(coord_ws_id, child_ws_ids)
|
||||
|
||||
|
||||
class _AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Inject a configurable AuthResult from a header-based contract.
|
||||
|
||||
Tests set ``X-Test-Perms`` to a comma-separated permission list, and
|
||||
``X-Test-User`` to the user id. Empty or missing → no auth.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request, call_next): # type: ignore[no-untyped-def]
|
||||
perms = request.headers.get("X-Test-Perms", "")
|
||||
user_id = request.headers.get("X-Test-User", "")
|
||||
if perms or user_id:
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id=user_id,
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="test",
|
||||
permissions=frozenset(p for p in perms.split(",") if p),
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class _FakeConfigStore:
|
||||
"""Minimal ConfigStore stub — returns values from a dict."""
|
||||
|
||||
def __init__(self, values: dict[str, Any]) -> None:
|
||||
self._values = values
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self._values.get(key, default)
|
||||
|
||||
|
||||
def _fake_registry() -> MagicMock:
|
||||
"""MagicMock whose ``.resolve()`` succeeds so the 503 gate passes."""
|
||||
reg = MagicMock()
|
||||
reg.resolve.return_value = (MagicMock(), "gpt-4", MagicMock())
|
||||
return reg
|
||||
|
||||
|
||||
def _build_mgr_with_factory(storage: Any, session_factory: Any) -> SessionManager:
|
||||
"""Build a SessionManager(CoordinatorAdapter) with a caller-supplied factory.
|
||||
|
||||
Used by tests that need to capture or assert factory kwargs (e.g.
|
||||
per-call ``model`` / ``judge_model`` overrides). Plain :func:`_build_mgr`
|
||||
is the right entry point when the test doesn't care about the
|
||||
factory.
|
||||
"""
|
||||
adapter = CoordinatorAdapter(
|
||||
collector=MagicMock(),
|
||||
ui_factory=lambda ws: ConsoleCoordinatorUI(ws_id=ws.id, user_id=ws.user_id or ""),
|
||||
session_factory=session_factory,
|
||||
)
|
||||
mgr = SessionManager(
|
||||
adapter,
|
||||
storage=storage,
|
||||
max_active=3,
|
||||
node_id=ClusterCollector.CONSOLE_PSEUDO_NODE_ID,
|
||||
event_emitter=adapter,
|
||||
)
|
||||
adapter.attach(mgr)
|
||||
return mgr
|
||||
|
||||
|
||||
def _build_mgr(storage: Any) -> SessionManager:
|
||||
"""Build a SessionManager(CoordinatorAdapter) with stub factories (test default)."""
|
||||
|
||||
def _sf(ui, model_alias=None, ws_id=None, **kw): # type: ignore[no-untyped-def]
|
||||
s = MagicMock()
|
||||
s.send.return_value = None
|
||||
return s
|
||||
|
||||
return _build_mgr_with_factory(storage, _sf)
|
||||
|
||||
|
||||
class MockStorage:
|
||||
"""Minimal storage mock that implements ``list_services``.
|
||||
|
||||
Used by the collector tests + the console route-walk tests. The
|
||||
collector calls ``list_services("turnstone-server", ...)`` to
|
||||
discover nodes; tests that don't care about discovery push an
|
||||
empty list (the default).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.services: list[dict[str, str]] = []
|
||||
|
||||
def list_services(self, service_type: str, max_age_seconds: int = 120) -> list[dict[str, str]]:
|
||||
return list(self.services)
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Shared test helpers — kept out of conftest.py since these are factories,
|
||||
not fixtures, and several test files want to import them directly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def make_chat_session(**overrides: Any) -> Any:
|
||||
"""Build a minimal ``ChatSession`` with sane test defaults.
|
||||
|
||||
Caller passes any constructor arg as a kwarg to override the default —
|
||||
e.g. ``make_chat_session(memory_config=MemoryConfig(fetch_limit=5))``.
|
||||
"""
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
defaults: dict[str, Any] = {
|
||||
"client": MagicMock(),
|
||||
"model": "test-model",
|
||||
"ui": MagicMock(),
|
||||
"instructions": None,
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 4096,
|
||||
"tool_timeout": 30,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return ChatSession(**defaults)
|
||||
|
||||
|
||||
def patch_session_storage(
|
||||
monkeypatch: Any,
|
||||
*,
|
||||
active: bool = True,
|
||||
raise_on_is_active: bool = False,
|
||||
) -> list[str]:
|
||||
"""Patch ``session.get_storage`` to a stub whose ``is_watch_active``
|
||||
returns *active* (or raises if *raise_on_is_active*). Returns the
|
||||
list of ``watch_id``s the predicate was called with.
|
||||
"""
|
||||
from turnstone.core import session as session_mod
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
class _Stub:
|
||||
def is_watch_active(self, watch_id: str) -> bool:
|
||||
calls.append(watch_id)
|
||||
if raise_on_is_active:
|
||||
raise RuntimeError("storage down")
|
||||
return active
|
||||
|
||||
monkeypatch.setattr(session_mod, "get_storage", lambda: _Stub())
|
||||
return calls
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Shared mock factory for ``events_replay`` tests.
|
||||
|
||||
Both interactive (:func:`turnstone.server._interactive_events_replay`)
|
||||
and coord (:func:`turnstone.console.server._coord_events_replay`) drive
|
||||
the same shared preamble at
|
||||
:func:`turnstone.core.session_replay.session_replay_preamble`. Their
|
||||
test suites share the underlying mock surface (session.model,
|
||||
session.model_alias, session._last_usage, ui._pending_*, ui._ws_lock,
|
||||
counters); this module is the single home for that shape so a future
|
||||
field add lands once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def make_replay_mocks(
|
||||
*,
|
||||
last_usage: dict[str, Any] | None = None,
|
||||
**ui_overrides: Any,
|
||||
) -> tuple[Any, Any, Any]:
|
||||
"""Build ``(ws, ui, request)`` MagicMocks for events-replay tests.
|
||||
|
||||
Defaults match a fresh workstream that hasn't completed a turn
|
||||
(no ``last_usage``, no pending prompts).
|
||||
|
||||
Args:
|
||||
last_usage: Sets ``ws.session._last_usage`` directly so tests
|
||||
don't have to reach into the nested mock; when ``None``
|
||||
(default), the status replay branch stays inert.
|
||||
**ui_overrides: Additional attributes set directly on the ``ui``
|
||||
mock (e.g. ``_pending_approval``, ``_llm_verdicts``,
|
||||
``_ws_turn_tool_calls``, ``_ws_messages``).
|
||||
"""
|
||||
session = MagicMock()
|
||||
session.model = "gpt-5"
|
||||
session.model_alias = "default"
|
||||
session._last_usage = last_usage
|
||||
session.context_window = 100000
|
||||
session.reasoning_effort = "medium"
|
||||
session.messages = []
|
||||
ui = MagicMock()
|
||||
ui.auto_approve = False
|
||||
ui._pending_approval = None
|
||||
ui._llm_verdicts = {}
|
||||
ui._ws_lock = threading.Lock()
|
||||
ui._ws_turn_tool_calls = 0
|
||||
ui._ws_messages = 0
|
||||
for key, value in ui_overrides.items():
|
||||
setattr(ui, key, value)
|
||||
ws = MagicMock()
|
||||
ws.session = session
|
||||
request = MagicMock()
|
||||
return ws, ui, request
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Shared session-test helpers.
|
||||
|
||||
Two reasoning-test modules (``test_session_replay_reasoning.py`` and
|
||||
``test_session_synth_reasoning_block.py``) need the same minimal
|
||||
``ChatSession`` factory + a ``SessionUIBase`` no-op subclass. Hoisting
|
||||
keeps a future third caller from drifting on the defaults — the third
|
||||
existing ``_make_session`` (``test_model_registry.py``) deliberately
|
||||
takes a different signature (registry / model_alias / reasoning_effort
|
||||
+ ``_FakeUI``) and is NOT a candidate for sharing this helper.
|
||||
|
||||
Module is named with a leading underscore so pytest doesn't try to
|
||||
collect it as a test file — it's an importable utility, not a test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.session_ui_base import SessionUIBase
|
||||
|
||||
|
||||
class NullUI(SessionUIBase):
|
||||
"""Bare-bones UI satisfying the SessionUIBase contract for tests
|
||||
that don't care about UI side effects."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
|
||||
def make_session(**kwargs: Any) -> ChatSession:
|
||||
"""Build a ChatSession with minimal defaults; tests override
|
||||
individual fields via kwargs."""
|
||||
defaults: dict[str, Any] = {
|
||||
"client": MagicMock(),
|
||||
"model": "test-model",
|
||||
"ui": NullUI(),
|
||||
"instructions": None,
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 4096,
|
||||
"tool_timeout": 30,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return ChatSession(**defaults)
|
||||
@@ -1,79 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.core.mcp_client import MCPClientManager, StaticServerState
|
||||
from turnstone.core.mcp_crypto import MCPTokenCipher
|
||||
from turnstone.core.oidc import OIDCConfig
|
||||
|
||||
|
||||
def make_mcp_token_cipher() -> MCPTokenCipher:
|
||||
"""Build a single-key MCP token cipher for tests.
|
||||
|
||||
Used by test files that need to exercise ``MCPTokenStore`` round-
|
||||
trips without the lifespan-side configuration loader; centralised
|
||||
here so the key/material defaults stay aligned across files.
|
||||
"""
|
||||
import base64
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from turnstone.core.mcp_crypto import MCPTokenCipher, MCPTokenCipherConfig
|
||||
|
||||
raw = base64.urlsafe_b64decode(Fernet.generate_key())
|
||||
return MCPTokenCipher(MCPTokenCipherConfig(keys=(raw,)))
|
||||
|
||||
|
||||
def _seed_static_state(mgr: MCPClientManager, name: str, **overrides: Any) -> StaticServerState:
|
||||
"""Get-or-create a ``StaticServerState`` on ``mgr`` and apply ``overrides``.
|
||||
|
||||
Shared across MCP test files so the helper stays in one place. Imported
|
||||
where needed; ``StaticServerState`` is constructed lazily so non-MCP
|
||||
tests don't pay the import cost.
|
||||
"""
|
||||
from turnstone.core.mcp_client import StaticServerState
|
||||
|
||||
state = mgr._static_servers.get(name)
|
||||
if state is None:
|
||||
state = StaticServerState(name=name)
|
||||
mgr._static_servers[name] = state
|
||||
for k, v in overrides.items():
|
||||
setattr(state, k, v)
|
||||
return state
|
||||
|
||||
|
||||
def make_oidc_test_config(**overrides: Any) -> OIDCConfig:
|
||||
"""Build a test ``OIDCConfig`` with sensible defaults.
|
||||
|
||||
Shared between ``test_oidc.py`` and ``test_oidc_handlers.py`` so the
|
||||
defaults (including the now-required ``redirect_base``) stay aligned.
|
||||
"""
|
||||
from turnstone.core.oidc import OIDCConfig
|
||||
|
||||
defaults: dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"issuer": "https://idp.example.com",
|
||||
"client_id": "my-client",
|
||||
"client_secret": "my-secret",
|
||||
"scopes": "openid email profile",
|
||||
"provider_name": "TestIDP",
|
||||
"role_claim": "",
|
||||
"role_map": {},
|
||||
"password_enabled": True,
|
||||
"redirect_base": "https://app.example.com",
|
||||
"authorization_endpoint": "https://idp.example.com/authorize",
|
||||
"token_endpoint": "https://idp.example.com/token",
|
||||
"userinfo_endpoint": "https://idp.example.com/userinfo",
|
||||
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return OIDCConfig(**defaults)
|
||||
|
||||
|
||||
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||
parser.addoption(
|
||||
@@ -164,21 +95,3 @@ def mock_openai_client():
|
||||
client = MagicMock()
|
||||
client.models.list.return_value.data = [MagicMock(id="test-model")]
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_policy_cache():
|
||||
"""Drop the in-process tool-policy cache between tests.
|
||||
|
||||
The cache is keyed by org_id (default ``""``), so without this
|
||||
autouse hook a policy created in test A would leak into test B's
|
||||
``evaluate_tool_policy`` call — distinct storage instances, same
|
||||
cache slot. Production singleton storage doesn't see the leak
|
||||
because there's only one storage instance for the process lifetime;
|
||||
the test isolation requirement is what motivates the autouse.
|
||||
"""
|
||||
from turnstone.core.policy import invalidate_policy_cache
|
||||
|
||||
invalidate_policy_cache()
|
||||
yield
|
||||
invalidate_policy_cache()
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
{
|
||||
"cache_control": {
|
||||
"type": "ephemeral"
|
||||
},
|
||||
"max_tokens": 4096,
|
||||
"messages": [
|
||||
{
|
||||
"content": "Weather in Paris and London?",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"input": {
|
||||
"city": "Paris"
|
||||
},
|
||||
"name": "get_weather",
|
||||
"type": "tool_use"
|
||||
},
|
||||
{
|
||||
"id": "call_2",
|
||||
"input": {
|
||||
"city": "London"
|
||||
},
|
||||
"name": "get_weather",
|
||||
"type": "tool_use"
|
||||
}
|
||||
],
|
||||
"role": "assistant"
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": "18C, clear.",
|
||||
"tool_use_id": "call_1",
|
||||
"type": "tool_result"
|
||||
},
|
||||
{
|
||||
"content": "Tool execution was cancelled.",
|
||||
"is_error": true,
|
||||
"tool_use_id": "call_2",
|
||||
"type": "tool_result"
|
||||
},
|
||||
{
|
||||
"text": "Actually, never mind London.",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up the weather for a city.",
|
||||
"input_schema": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"name": "get_weather"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"cache_control": {
|
||||
"type": "ephemeral"
|
||||
},
|
||||
"max_tokens": 4096,
|
||||
"messages": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "What's in this image?",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"source": {
|
||||
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"media_type": "image/png",
|
||||
"type": "base64"
|
||||
},
|
||||
"type": "image"
|
||||
}
|
||||
],
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user