mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
331 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c32e89de3 | |||
| 06310c74ee | |||
| dfad58a3d2 | |||
| e31197d64a | |||
| 5f9200f6a0 | |||
| 84b0d5615c | |||
| d41621877f | |||
| a4f3d205d1 | |||
| 6078a88533 | |||
| 843fa04e65 | |||
| 473298199d | |||
| c251e2dac8 | |||
| ac47476d0a | |||
| 055bd5a88f | |||
| a7d9461735 | |||
| 9de77c3ee3 | |||
| 0cfe521ce7 | |||
| c2750de7a4 | |||
| bd782f804e | |||
| 87b69a318b | |||
| a315cabe71 | |||
| be17d8c5d0 | |||
| 62ce450b06 | |||
| d19dad05bd | |||
| 262a6a9918 | |||
| 2bb55590bf | |||
| 0e02d1b52c | |||
| 9b29453e9b | |||
| c4ff1caf09 | |||
| 22245145db | |||
| 8eacc4d632 | |||
| 23fed785c4 | |||
| 688c27e68a | |||
| 405baf7cb2 | |||
| 1027c22333 | |||
| 381651049b | |||
| 322b7dabc4 | |||
| d5e86c8493 | |||
| c154ea3966 | |||
| 755ab51802 | |||
| 9df8ab836f | |||
| 8d88e6a7eb | |||
| cce292f793 | |||
| 6adc577d30 | |||
| 02c50b81c1 | |||
| 753cd04b4e | |||
| c3217748dc | |||
| 8cbff49694 | |||
| 4f26d63c14 | |||
| 74347fb29f | |||
| 2ace8cccc8 | |||
| e95b8f5ca1 | |||
| 7a32c51a1c | |||
| dce663105b | |||
| cfef3616e6 | |||
| c22d39a798 | |||
| 63921450b1 | |||
| 929fad63be | |||
| 976e9df3b6 | |||
| 7cb21b84f1 | |||
| 7263edd48d | |||
| 6742c7e405 | |||
| 1aa6982868 | |||
| 42d1abbd04 | |||
| f543ed714a | |||
| 2c6abb0fde | |||
| 491fc6748a | |||
| 9a996f0067 | |||
| a465ac6383 | |||
| a4539923e4 | |||
| 42e99d6990 | |||
| a0ff22e137 | |||
| de4b3b3909 | |||
| 120d229b5f | |||
| c6f4c11870 | |||
| 979fab37a9 | |||
| da5bf90a4b | |||
| 70c18467cb | |||
| 801774bc4a | |||
| 497984b452 | |||
| bdc1eba34c | |||
| 028c77cae5 | |||
| 76d007d83f | |||
| 3f432b8a42 | |||
| f74aa2264e | |||
| d00aae2429 | |||
| 5e09940745 | |||
| 72bd62d3d8 | |||
| d31f89b2e3 | |||
| 9bae8f1a10 | |||
| a012561195 | |||
| 4198b59a0f | |||
| 4f6ef13ce9 | |||
| 52716ed611 | |||
| 6c9a7d7351 | |||
| 3518f7953c | |||
| 48769e5a97 | |||
| adb4ff6399 | |||
| 131a1ec943 | |||
| 1cded9b430 | |||
| 62c741eb8a | |||
| 3362917e1e | |||
| 698cbbf988 | |||
| e47a08b7bc | |||
| aaa427debd | |||
| 611af76971 | |||
| bbe28ecab3 | |||
| 93a9fd3c28 | |||
| 62ff3217d0 | |||
| b086390558 | |||
| d08a57dfc2 | |||
| 9fdf51ff3d | |||
| 45471894da | |||
| c0b5952573 | |||
| b9d5b5b671 | |||
| 274c97135e | |||
| e87f8e19c2 | |||
| bb221f4dab | |||
| 361876b17a | |||
| 5d26cd6593 | |||
| 2d4420e00d | |||
| b20548583d | |||
| f63b2915cc | |||
| bf85bbea94 | |||
| 803d8ee8f9 | |||
| 17b5961a70 | |||
| 037308f3b1 | |||
| d7a9895855 | |||
| 0ba49b8bb7 | |||
| 51b5b3ee74 | |||
| c3b0ddeba7 | |||
| a533e1c783 | |||
| d8bc78556f | |||
| 4f5854e768 | |||
| bf2dc04cb3 | |||
| bb894d073b | |||
| b3934a2d14 | |||
| 3d02cf66b4 | |||
| 7631b88792 | |||
| a07172b0c0 | |||
| f3d33bf44a | |||
| fdb1a189e8 | |||
| 58e2d9348f | |||
| 771d03b8e6 | |||
| d147aaea36 | |||
| 8b747178e0 | |||
| d57280d807 | |||
| b9870f279c | |||
| 71ee340bc6 | |||
| c5d5d0b7cd | |||
| 1f9d03c3e0 | |||
| 24e082df05 | |||
| 4a78d20eea | |||
| e0d17e0f99 | |||
| cd6c49dd01 | |||
| 8454e961ba | |||
| 4ae38bc2ae | |||
| ab1a71c86c | |||
| ce57df6888 | |||
| 275f40eebb | |||
| 2c510f8617 | |||
| 2afb9c7f72 | |||
| 5b8ab94446 | |||
| 30828e9f9c | |||
| 6d0dc6df94 | |||
| 7e680ee883 | |||
| e950219246 | |||
| b3764a8035 | |||
| 756c4d8929 | |||
| 04c50568e9 | |||
| 3bf220c503 | |||
| 4b853e329e | |||
| 29ffdc36d0 | |||
| ada8b80509 | |||
| 1f47ca62de | |||
| 83d9233304 | |||
| bf06102d37 | |||
| e015b4512d | |||
| 0c1afff7fc | |||
| a94051a995 | |||
| 2f906ea1f9 | |||
| b61bfd1aa6 | |||
| 19c3a48b10 | |||
| 414eb52d67 | |||
| 86b404177b | |||
| 10165bb8a1 | |||
| 5d478573cc | |||
| 1b24e4717f | |||
| 9a2db63c07 | |||
| e159837b74 | |||
| ec3454ee2e | |||
| 5cbb832162 | |||
| 4e4ae2a91d | |||
| c7d0bac638 | |||
| e86305c143 | |||
| d0fc42195a | |||
| 760321f7ee | |||
| 693e51f782 | |||
| ba07409724 | |||
| c76a61841e | |||
| 341d2f604f | |||
| 3f7f8495d6 | |||
| dc464ac313 | |||
| 52d59cf7b7 | |||
| 2dc885ab4d | |||
| 14488f43e0 | |||
| 90f2070146 | |||
| 1e551830ea | |||
| 84cc212ecd | |||
| da4025d338 | |||
| 88085c29ff | |||
| 3152667a0c | |||
| 4b44d88401 | |||
| 8957b9ce0e | |||
| 28a6b0dd33 | |||
| 7bc17cc072 | |||
| 10a1800492 | |||
| 1010f163f0 | |||
| c28bfc1e58 | |||
| e71ea38953 | |||
| 5378b33641 | |||
| 9b605f81a3 | |||
| f05e6bddad | |||
| 75eda9a096 | |||
| 4c00d71150 | |||
| 80e1924d7f | |||
| 471bf89c8b | |||
| 35785d3a0e | |||
| 2ff0cd8240 | |||
| f9f0ff0b53 | |||
| df8a36ced4 | |||
| ef6cac6428 | |||
| f4c3b3a9c4 | |||
| d06db3feee | |||
| 50544c0d1b | |||
| d1c484737f | |||
| c2c6689a8e | |||
| f5af4875ba | |||
| 0d77d65266 | |||
| c11991819e | |||
| fc948d711d | |||
| d9722f3578 | |||
| f494553020 | |||
| c766c81f25 | |||
| 3b746fb28d | |||
| b82fa4923c | |||
| 4ac316dc0e | |||
| 387ef06da7 | |||
| e4e2200c33 | |||
| 8b94d553e4 | |||
| cf16724137 | |||
| 22402e89de | |||
| e7743fd079 | |||
| 27349e1c13 | |||
| 37a48bb30d | |||
| 730f5704ff | |||
| 9b3b1c1ddd | |||
| a9ed8a954b | |||
| 1efcbcf2ba | |||
| 1d36d80fe5 | |||
| e603a6a7d1 | |||
| 2e95f2ac73 | |||
| 5f27ed9fca | |||
| 20df7b3034 | |||
| 68c991fbdd | |||
| 376da3d084 | |||
| e2a199c9c3 | |||
| 4152ea2352 | |||
| 44cc14b46f | |||
| 83b0cde32f | |||
| 2ef8a8711b | |||
| 3658b77de8 | |||
| 83577739e0 | |||
| 71d13936fe | |||
| 0cd061196c | |||
| 19abc0cc65 | |||
| c5cdfc8f44 | |||
| 8895bf07eb | |||
| 101afd84da | |||
| efd98712e9 | |||
| 67f43a7ee0 | |||
| 2888e8ce0a | |||
| d1a248b413 | |||
| 723cad24bb | |||
| 73cacc8ad6 | |||
| ccd1c1a9ad | |||
| 1295919613 | |||
| 09ea3d164d | |||
| 02d9c5c797 | |||
| f1f448277f | |||
| 2f7f70825b | |||
| 4866c9873c | |||
| 8b2e2130fc | |||
| f81c06761d | |||
| be165c1971 | |||
| 3264fdefca | |||
| 28cb3a5c51 | |||
| 8b11e0a6f9 | |||
| 648ba477e1 | |||
| 7960784786 | |||
| e06554d1ec | |||
| 8eb8722346 | |||
| a2e2ffacd8 | |||
| c6ba8d59b0 | |||
| 087f5b49f6 | |||
| fd507c6a3c | |||
| 562c3c8ab7 | |||
| 4773535bb8 | |||
| 7492816ab2 | |||
| d6ba1d5e25 | |||
| 41d1b27d34 | |||
| 8bc284c60e | |||
| a322d6b1d1 | |||
| 70d495aa5b | |||
| de64535221 | |||
| 187d004033 | |||
| 7ea150fa71 | |||
| 4d665a5f62 | |||
| 3bc3250869 | |||
| db937486cf | |||
| 554257ac4d | |||
| 5f0004dc91 | |||
| 6cc1b3a5bd | |||
| cc9afe94cd | |||
| 136b75fdef | |||
| 4d1107839b | |||
| 7d66bc2159 | |||
| 165cbb2d29 | |||
| c79c47b940 | |||
| 660c273e8e | |||
| c7586abd0a |
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
"helpers:pinGitHubActionDigests",
|
||||
":separateMajorReleases"
|
||||
],
|
||||
"gitIgnoredAuthors": [
|
||||
"41898282+github-actions[bot]@users.noreply.github.com"
|
||||
],
|
||||
"labels": ["dependencies"],
|
||||
"prConcurrentLimit": 5,
|
||||
"prHourlyLimit": 2,
|
||||
"schedule": ["before 9am on Monday"],
|
||||
"timezone": "America/New_York",
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"schedule": ["before 9am on Monday"]
|
||||
},
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track vendored KaTeX version",
|
||||
"managerFilePatterns": ["/pyproject\\.toml$/"],
|
||||
"matchStrings": ["katex-(?<currentValue>[\\d.]+)/"],
|
||||
"depNameTemplate": "katex",
|
||||
"datasourceTemplate": "npm"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track vendored Highlight.js version",
|
||||
"managerFilePatterns": ["/pyproject\\.toml$/"],
|
||||
"matchStrings": ["hljs-(?<currentValue>[\\d.]+)/"],
|
||||
"depNameTemplate": "highlight.js",
|
||||
"datasourceTemplate": "npm"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track vendored Mermaid version",
|
||||
"managerFilePatterns": ["/pyproject\\.toml$/"],
|
||||
"matchStrings": ["mermaid-(?<currentValue>[\\d.]+)/"],
|
||||
"depNameTemplate": "mermaid",
|
||||
"datasourceTemplate": "npm"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "LLM SDKs — always review manually",
|
||||
"groupName": "LLM SDKs",
|
||||
"matchPackageNames": ["openai", "anthropic", "mcp"],
|
||||
"schedule": ["before 9am on Monday"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "Web framework stack",
|
||||
"groupName": "Web Framework",
|
||||
"matchPackageNames": [
|
||||
"starlette",
|
||||
"uvicorn",
|
||||
"sse-starlette",
|
||||
"httpx",
|
||||
"httpx-sse",
|
||||
"pydantic"
|
||||
],
|
||||
"schedule": ["before 9am on Wednesday"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "Database layer",
|
||||
"groupName": "Database",
|
||||
"matchPackageNames": ["sqlalchemy", "alembic", "psycopg"],
|
||||
"schedule": ["before 9am on Wednesday"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "Security-critical — always review manually",
|
||||
"groupName": "Security",
|
||||
"matchPackageNames": ["PyJWT", "pyjwt", "bcrypt"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "Infrastructure dependencies",
|
||||
"groupName": "Infrastructure",
|
||||
"matchPackageNames": ["structlog", "redis", "croniter", "discord.py"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "Vendored JS — CI workflow downloads files automatically",
|
||||
"groupName": "Vendored JS",
|
||||
"matchPackageNames": ["katex", "highlight.js", "mermaid"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "Dev/test tooling",
|
||||
"groupName": "Tooling",
|
||||
"matchPackageNames": [
|
||||
"ruff",
|
||||
"mypy",
|
||||
"types-redis",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pre-commit"
|
||||
],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "Docker base images",
|
||||
"groupName": "Docker Images",
|
||||
"matchManagers": ["dockerfile", "docker-compose"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "TypeScript SDK dev dependencies",
|
||||
"groupName": "TypeScript SDK",
|
||||
"matchFileNames": ["sdk/typescript/**"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "GitHub Actions — group all action updates",
|
||||
"groupName": "GitHub Actions",
|
||||
"matchManagers": ["github-actions"],
|
||||
"automerge": false
|
||||
}
|
||||
]
|
||||
}
|
||||
+75
-12
@@ -10,21 +10,21 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install ruff
|
||||
- run: ruff check turnstone/ tests/
|
||||
- run: ruff format --check turnstone/ tests/
|
||||
python-version: "3.14"
|
||||
- run: pip install pre-commit
|
||||
# mypy runs separately in typecheck job with full project deps
|
||||
- run: SKIP=mypy pre-commit run --all-files
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version: "3.14"
|
||||
- run: pip install mypy types-redis
|
||||
- run: pip install -e ".[mq]"
|
||||
- run: mypy turnstone/
|
||||
@@ -35,14 +35,77 @@ jobs:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: pip install -e ".[test,mq]"
|
||||
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}
|
||||
path: coverage.xml
|
||||
|
||||
test-postgres:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: turnstone_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U postgres"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: pip install -e ".[test,mq,postgres]"
|
||||
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
|
||||
env:
|
||||
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
|
||||
|
||||
lock-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- run: uv lock --check
|
||||
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: uv sync --frozen --all-extras
|
||||
- run: uv pip install pip-audit
|
||||
- name: Security audit (dependencies)
|
||||
run: uv export --no-emit-project --frozen | uv run pip-audit --strict --desc -r /dev/stdin
|
||||
|
||||
security-ts:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: sdk/typescript
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- run: npm ci
|
||||
- run: npm audit --audit-level=moderate
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Docker Security Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
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"
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
@@ -12,10 +13,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version: "3.14"
|
||||
- run: pip install build
|
||||
- run: python -m build
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref, '-') }}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
name: Complete Vendored JS Updates
|
||||
|
||||
# When Renovate bumps a vendored JS version in pyproject.toml, this
|
||||
# workflow downloads the actual files and commits them to the PR branch
|
||||
# so the PR is merge-ready without manual intervention.
|
||||
#
|
||||
# Note: the commit is made with GITHUB_TOKEN, so it won't re-trigger CI
|
||||
# automatically. The reviewer should re-run CI once this workflow passes,
|
||||
# or Renovate's next rebase will trigger it.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- pyproject.toml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "PR number to update"
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
vendor-js:
|
||||
if: github.actor == 'renovate[bot]' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve PR head ref
|
||||
id: ref
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
ref=$(gh pr view "${{ inputs.pr_number }}" --repo "${{ github.repository }}" --json headRefName -q .headRefName)
|
||||
else
|
||||
ref="${{ github.head_ref }}"
|
||||
fi
|
||||
echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ steps.ref.outputs.head_ref }}
|
||||
|
||||
- name: Detect vendored JS changes
|
||||
id: detect
|
||||
run: |
|
||||
updates=()
|
||||
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
|
||||
updates+=("${lib}:${version}")
|
||||
done
|
||||
if [[ ${#updates[@]} -eq 0 ]]; then
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "${updates[@]}" > /tmp/updates.txt
|
||||
echo "Libs to update:"
|
||||
cat /tmp/updates.txt
|
||||
fi
|
||||
|
||||
- name: Download vendored files
|
||||
if: steps.detect.outputs.found == 'true'
|
||||
run: |
|
||||
while IFS=: read -r lib version; do
|
||||
echo "::group::Updating ${lib} to ${version}"
|
||||
bash scripts/update-vendored-js.sh "$lib" "$version"
|
||||
echo "::endgroup::"
|
||||
done < /tmp/updates.txt
|
||||
|
||||
- name: Commit and push
|
||||
if: steps.detect.outputs.found == 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: download vendored JS files"
|
||||
git push
|
||||
@@ -19,3 +19,5 @@ venv/
|
||||
.hypothesis/
|
||||
PROGRESS.md
|
||||
.coverage
|
||||
tools/skill_audit_analysis/data/
|
||||
tools/skill_audit_analysis/output/
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.10
|
||||
rev: v0.15.6
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.14.1
|
||||
rev: v1.19.1
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-redis>=4.6, redis>=7.2]
|
||||
additional_dependencies: []
|
||||
args: [--config-file=pyproject.toml]
|
||||
pass_filenames: false
|
||||
entry: mypy turnstone/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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
|
||||
+28
-26
@@ -1,41 +1,43 @@
|
||||
# =============================================================================
|
||||
# Turnstone — multi-stage Docker build
|
||||
# Single image for all services: server, bridge, console, sim, eval
|
||||
# Turnstone — Docker build with uv for reproducible, locked installs
|
||||
# Single image for all services: server, console, channel, eval
|
||||
# =============================================================================
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Stage 1: Builder — build the wheel
|
||||
# ----------------------------------------------------------------------------
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN pip install --no-cache-dir hatchling
|
||||
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
COPY turnstone/ turnstone/
|
||||
|
||||
RUN pip wheel --no-deps --wheel-dir /build/wheels .
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Stage 2: Runtime — slim image with the installed package
|
||||
# ----------------------------------------------------------------------------
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.14-slim
|
||||
|
||||
LABEL org.opencontainers.image.title="turnstone" \
|
||||
org.opencontainers.image.description="Multi-node AI orchestration platform"
|
||||
|
||||
# System dependencies for psycopg (PostgreSQL client library)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.2 /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
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||
libpq5 git curl jq man-db manpages procps file \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user
|
||||
RUN useradd --create-home --shell /bin/bash turnstone
|
||||
|
||||
# Install the wheel with all optional extras
|
||||
COPY --from=builder /build/wheels/*.whl /tmp/wheels/
|
||||
RUN pip install --no-cache-dir "$(ls /tmp/wheels/*.whl)[mq,console,sim,postgres,discord]" \
|
||||
&& rm -rf /tmp/wheels
|
||||
WORKDIR /app
|
||||
|
||||
# Compile bytecode for faster startup
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
|
||||
# Install dependencies first (cached layer — only re-runs when deps change)
|
||||
COPY pyproject.toml uv.lock README.md LICENSE ./
|
||||
RUN uv sync --frozen --no-install-project --no-dev \
|
||||
--extra all
|
||||
|
||||
# Install the project itself
|
||||
COPY turnstone/ turnstone/
|
||||
RUN uv sync --frozen --no-dev \
|
||||
--extra all
|
||||
|
||||
# Add venv to PATH so entry points are found
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Health check script (stdlib only, no pip deps needed)
|
||||
COPY docker/healthcheck.py /usr/local/bin/healthcheck.py
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Bootstrap Wizard
|
||||
|
||||
Interactive, AI-guided setup for Turnstone deployments. Instead of manually
|
||||
editing `.env` files and reading deployment docs, the wizard walks you through
|
||||
every decision conversationally and generates all the config files for you.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
turnstone-bootstrap
|
||||
```
|
||||
|
||||
That's it — no flags, no arguments. The wizard prompts for everything.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Pick a model** — Choose OpenAI, Anthropic, or a local/vLLM endpoint to
|
||||
power the wizard. Local endpoints auto-detect available models.
|
||||
2. **Answer questions** — The AI walks you through deployment mode, LLM
|
||||
provider, database, authentication, ports, and optional features.
|
||||
3. **Review generated files** — Each file is previewed before writing. You
|
||||
confirm or reject every write.
|
||||
4. **Start the stack** — The wizard prints the exact `docker compose` command
|
||||
and a `setup.sh` script to create your first admin user, roles, and policies.
|
||||
|
||||
## What Gets Generated
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `.env` | All environment variables for `compose.yaml` |
|
||||
| `setup.sh` | Post-start script: creates admin user, roles, tool policies, prompt templates via the API |
|
||||
| `docker-compose.override.yaml` | Only if customizations beyond env vars are needed |
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python 3.11+** with turnstone installed (`pip install turnstone`)
|
||||
- **An LLM API key** — for the wizard itself (OpenAI, Anthropic, or a local
|
||||
model). This can differ from the LLM your deployment will use.
|
||||
- **Docker & Docker Compose** — needed to run the stack. The wizard detects
|
||||
whether Docker is installed and gives platform-specific install instructions
|
||||
if it's missing. You can still generate config files without Docker.
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
The wizard supports two deployment modes:
|
||||
|
||||
- **Single-node production** (`docker compose --profile production up`) —
|
||||
1 server + 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 v0.5.4
|
||||
────────────────────────────────────────────────
|
||||
|
||||
Which provider for this wizard?
|
||||
[1] OpenAI
|
||||
[2] Anthropic
|
||||
[3] OpenAI-compatible (local/vLLM)
|
||||
|
||||
> 3
|
||||
|
||||
Base URL [http://localhost:8000/v1]:
|
||||
API key (press Enter for 'none'):
|
||||
|
||||
Querying http://localhost:8000/v1 for available models...
|
||||
Found model: Qwen/Qwen3-32B
|
||||
|
||||
Connected to Qwen/Qwen3-32B. Handing off to AI assistant...
|
||||
|
||||
> (AI walks you through the rest interactively)
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- **Re-run safely** — running the wizard again detects your existing `.env`
|
||||
and offers to update it rather than overwriting.
|
||||
- **Duplicate writes are skipped** — if the LLM tries to write the same file
|
||||
twice with identical content, it's silently ignored.
|
||||
- **Type `quit` to exit** at any time during the conversation.
|
||||
- **Ctrl+C** is handled gracefully — press once to interrupt, twice to exit.
|
||||
|
||||
## See Also
|
||||
|
||||
- [Docker Deployment](docker.md) — manual compose setup and profiles
|
||||
- [Security](security.md) — auth architecture and token types
|
||||
- [Governance](governance.md) — roles, policies, and templates
|
||||
@@ -5,348 +5,125 @@
|
||||
[](https://pypi.org/project/turnstone/)
|
||||
[](LICENSE)
|
||||
|
||||
Multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers, driven by message queues or interactive interfaces.
|
||||
Multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers with direct HTTP routing, interactive interfaces, and enterprise governance.
|
||||
|
||||
Named after the [Ruddy Turnstone](https://en.wikipedia.org/wiki/Ruddy_turnstone) — a bird that flips rocks to expose what's hiding underneath.
|
||||
> **Beta — Use at your own risk.** APIs, configuration formats, and database schemas may change between versions without migration paths.
|
||||
|
||||
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.
|
||||
|
||||
## What it does
|
||||
|
||||
Turnstone gives LLMs tools — shell, files, search, web, planning — and orchestrates multi-turn conversations where the model investigates, acts, and reports. It runs as:
|
||||
Turnstone gives LLMs tools — shell, files, search, web, planning — and orchestrates multi-turn conversations where the model investigates, acts, and reports.
|
||||
|
||||
- **Interactive sessions** — terminal CLI or browser UI with parallel workstreams
|
||||
- **Queue-driven agents** — trigger workstreams via message queue, stream progress, approve or auto-approve tool use
|
||||
- **Multi-node clusters** — generic work load-balances across nodes, directed work routes to a specific server
|
||||
- **Cluster dashboard** — real-time view of all nodes and workstreams, workstream creation with node targeting, reverse proxy for server UIs (only the console port needs network access)
|
||||
- **Cluster simulator** — test the stack at scale (up to 1000 nodes) without an LLM backend
|
||||
- **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) and Anthropic Messages API
|
||||
- **MCP support** — external tool servers with native deferred loading (Anthropic/OpenAI) or BM25 fallback
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/architecture-overview.svg" alt="Turnstone system architecture — data flow from clients through gateways, Redis MQ, cluster nodes, to LLM providers" width="960"/>
|
||||
<img src="docs/diagrams/architecture-overview.svg" alt="Turnstone system architecture" width="960"/>
|
||||
</p>
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Interactive (terminal)
|
||||
|
||||
```bash
|
||||
pip install turnstone
|
||||
|
||||
# Terminal REPL
|
||||
turnstone --base-url http://localhost:8000/v1
|
||||
```
|
||||
|
||||
### Interactive (browser)
|
||||
|
||||
```bash
|
||||
# Browser UI
|
||||
turnstone-server --port 8080 --base-url http://localhost:8000/v1
|
||||
```
|
||||
|
||||
### Queue-driven (programmatic)
|
||||
|
||||
```bash
|
||||
pip install turnstone[mq]
|
||||
turnstone-bridge --server-url http://localhost:8080 --redis-host localhost
|
||||
```
|
||||
|
||||
```python
|
||||
from turnstone.mq import TurnstoneClient
|
||||
|
||||
with TurnstoneClient() as client:
|
||||
# Generic — any available node picks it up
|
||||
result = client.send_and_wait("Analyze the error logs", auto_approve=True)
|
||||
print(result.content)
|
||||
|
||||
# Directed — must run on a specific server
|
||||
result = client.send_and_wait(
|
||||
"Check disk I/O on this server",
|
||||
target_node="server-12",
|
||||
auto_approve=True,
|
||||
)
|
||||
```
|
||||
|
||||
### Cluster dashboard
|
||||
|
||||
```bash
|
||||
# Cluster dashboard
|
||||
pip install turnstone[console]
|
||||
turnstone-console --redis-host localhost --port 8090
|
||||
turnstone-console --port 8090
|
||||
```
|
||||
|
||||
Then open `http://localhost:8090` for the cluster-wide dashboard. Create workstreams from the console and interact with any node's server UI through the built-in reverse proxy — no direct server port access required.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
cp .env.example .env # edit LLM_BASE_URL, OPENAI_API_KEY, etc.
|
||||
docker compose up # starts redis + server + bridge + console (SQLite)
|
||||
docker compose --profile production up
|
||||
```
|
||||
|
||||
For production with PostgreSQL:
|
||||
See [QUICKSTART.md](QUICKSTART.md) for the bootstrap wizard and [docs/docker.md](docs/docker.md) for Docker configuration and profiles.
|
||||
|
||||
```bash
|
||||
# Requires POSTGRES_PASSWORD and DB_BACKEND=postgresql in .env (or exported)
|
||||
docker compose --profile production up # adds PostgreSQL, uses it as database
|
||||
### Programmatic (SDK)
|
||||
|
||||
```python
|
||||
from turnstone.sdk import TurnstoneServer
|
||||
|
||||
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
ws = client.create_workstream(name="demo")
|
||||
result = client.send_and_wait("Analyze the error logs", ws.ws_id, auto_approve=True)
|
||||
print(result.content)
|
||||
```
|
||||
|
||||
Console dashboard at http://localhost:8090. See [docs/docker.md](docs/docker.md) for configuration, scaling, and profiles.
|
||||
|
||||
### Simulator
|
||||
|
||||
Test the multi-node stack at scale without an LLM backend:
|
||||
|
||||
```bash
|
||||
docker compose --profile sim up redis console sim
|
||||
```
|
||||
|
||||
Or standalone:
|
||||
|
||||
```bash
|
||||
pip install turnstone[sim]
|
||||
turnstone-sim --nodes 100 --scenario steady --duration 60 --mps 10
|
||||
```
|
||||
|
||||
See [docs/simulator.md](docs/simulator.md) for scenarios, CLI reference, and metrics.
|
||||
|
||||
All frontends connect to any OpenAI-compatible API (vLLM, NVIDIA NIM/NGC, llama.cpp, OpenAI, etc.) or Anthropic's native Messages API, and auto-detect the model.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Diagrams
|
||||
|
||||
Detailed UML diagrams are available in [`docs/diagrams/`](docs/diagrams/):
|
||||
|
||||
| Diagram | Description |
|
||||
|---------|-------------|
|
||||
| [System Context](docs/diagrams/png/01-system-context.png) | Top-level components and external dependencies |
|
||||
| [Package Structure](docs/diagrams/png/02-package-structure.png) | Python modules and dependency graph |
|
||||
| [Core Engine Classes](docs/diagrams/png/03-core-engine-classes.png) | SessionUI protocol, ChatSession, LLMProvider, WorkstreamManager |
|
||||
| [Conversation Turn](docs/diagrams/png/04-conversation-turn.png) | Full message lifecycle through the engine (provider-agnostic) |
|
||||
| [Tool Pipeline](docs/diagrams/png/05-tool-pipeline.png) | Three-phase prepare/approve/execute |
|
||||
| [MQ Protocol](docs/diagrams/png/06-mq-protocol.png) | 9 inbound + 19 outbound message types |
|
||||
| [Message Routing](docs/diagrams/png/07-message-routing.png) | Multi-node routing scenarios |
|
||||
| [Redis Key Schema](docs/diagrams/png/08-redis-key-schema.png) | All Redis keys, types, and TTLs |
|
||||
| [Workstream States](docs/diagrams/png/09-workstream-states.png) | State machine transitions |
|
||||
| [Simulator](docs/diagrams/png/10-simulator-architecture.png) | SimCluster, dispatchers, scenarios |
|
||||
| [Console Data Flow](docs/diagrams/png/11-console-data-flow.png) | Dashboard data collection threads |
|
||||
| [Deployment](docs/diagrams/png/12-deployment.png) | Docker Compose service topology |
|
||||
| [SDK Architecture](docs/diagrams/png/13-sdk-architecture.png) | Python + TypeScript client libraries |
|
||||
| [Storage Architecture](docs/diagrams/png/14-storage-architecture.png) | Pluggable database backends (SQLite + PostgreSQL) |
|
||||
|
||||
## Multi-node routing
|
||||
|
||||
Each Turnstone server runs a bridge process. Bridges share a Redis instance for coordination:
|
||||
|
||||
| Redis Key | Purpose |
|
||||
|-----------|---------|
|
||||
| `turnstone:inbound` | Shared work queue — generic tasks, any node |
|
||||
| `turnstone:inbound:{node_id}` | Per-node queue — directed tasks |
|
||||
| `turnstone:ws:{ws_id}` | Workstream ownership — auto-routes follow-ups |
|
||||
| `turnstone:node:{node_id}` | Node heartbeat + metadata for discovery |
|
||||
| `turnstone:events:{ws_id}` | Per-workstream event pub/sub |
|
||||
| `turnstone:events:global` | Global event pub/sub |
|
||||
| `turnstone:events:cluster` | Cluster-wide state changes (for turnstone-console) |
|
||||
|
||||
**Routing rules:**
|
||||
1. Message has `target_node` → routes to that node's queue
|
||||
2. Message has `ws_id` → looks up owner, routes to owning node
|
||||
3. Neither → shared queue, next available bridge picks it up
|
||||
|
||||
Bridges BLPOP from their per-node queue (priority) then the shared queue. Directed work always takes precedence.
|
||||
|
||||
## Tools
|
||||
|
||||
14 built-in tools, 2 agent tools, plus external tools via MCP:
|
||||
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.
|
||||
|
||||
| Tool | Description | Auto-approved |
|
||||
|------|-------------|:---:|
|
||||
| `bash` | Execute shell commands | |
|
||||
| `read_file` | Read file contents | yes |
|
||||
| `write_file` | Write/create files | |
|
||||
| `edit_file` | Fuzzy-match file editing | |
|
||||
| `search` | Search files by name/content | yes |
|
||||
| `math` | Sandboxed Python evaluation | |
|
||||
| `man` | Read man pages | yes |
|
||||
| `web_fetch` | Fetch URL content | |
|
||||
| `web_search` | Web search (provider-native or Tavily) | |
|
||||
| `remember` | Save persistent facts | yes |
|
||||
| `recall` | Search memories and history | yes |
|
||||
| `forget` | Remove a memory | yes |
|
||||
| `task` | Spawn autonomous sub-agent | |
|
||||
| `plan` | Explore codebase, write .plan.md | |
|
||||
| `mcp__*` | External tools from MCP servers | |
|
||||
## Architecture
|
||||
|
||||
### MCP Tool Servers
|
||||
**Single-node**: Client → Server (direct HTTP + SSE). No external dependencies beyond the database.
|
||||
|
||||
Turnstone supports the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) for connecting external tool servers. MCP tools are discovered at startup, converted to OpenAI function-calling format, and merged with built-in tools. Each MCP tool is prefixed with `mcp__{server}__{tool}` to avoid name collisions.
|
||||
**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.
|
||||
|
||||
Configure via `config.toml` or `--mcp-config`:
|
||||
| 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, 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 |
|
||||
|
||||
```toml
|
||||
[mcp.servers.github]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
### Diagrams
|
||||
|
||||
[mcp.servers.github.env]
|
||||
GITHUB_TOKEN = "ghp_..."
|
||||
```
|
||||
UML diagrams in [`docs/diagrams/`](docs/diagrams/):
|
||||
|
||||
Or use a standard MCP JSON config file:
|
||||
| Diagram | Description |
|
||||
|---------|-------------|
|
||||
| [System Context](docs/diagrams/png/01-system-context.png) | Components and external dependencies |
|
||||
| [Package Structure](docs/diagrams/png/02-package-structure.png) | Python modules and dependency graph |
|
||||
| [Core Engine](docs/diagrams/png/03-core-engine-classes.png) | SessionUI, ChatSession, LLMProvider |
|
||||
| [Conversation Turn](docs/diagrams/png/04-conversation-turn.png) | Message lifecycle through the engine |
|
||||
| [Tool Pipeline](docs/diagrams/png/05-tool-pipeline.png) | Prepare / approve / execute |
|
||||
| [Workstream States](docs/diagrams/png/09-workstream-states.png) | State machine transitions |
|
||||
| [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 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 |
|
||||
|
||||
```bash
|
||||
turnstone --mcp-config ~/.config/turnstone/mcp.json
|
||||
turnstone-server --mcp-config ~/.config/turnstone/mcp.json
|
||||
```
|
||||
## Documentation
|
||||
|
||||
Use `/mcp` in the REPL to list connected tools. MCP tools require user approval by default (overridden by `--skip-permissions` or UI auto-approve).
|
||||
|
||||
### Multi-Model and Multi-Provider Support
|
||||
|
||||
Turnstone supports multiple model backends per server instance, including different LLM providers. `ChatSession` delegates all API communication to pluggable `LLMProvider` adapters — the internal message format stays OpenAI-like, and each provider translates at the API boundary. Define named models in `config.toml` and select per-workstream or switch mid-session with `/model <alias>`.
|
||||
|
||||
```toml
|
||||
[models.local]
|
||||
base_url = "http://localhost:8000/v1"
|
||||
model = "qwen3-32b"
|
||||
# provider defaults to "openai" (works with vLLM, llama.cpp, etc.)
|
||||
|
||||
[models.claude]
|
||||
provider = "anthropic"
|
||||
api_key = "sk-ant-..."
|
||||
model = "claude-opus-4-6"
|
||||
context_window = 200000
|
||||
|
||||
[models.openai]
|
||||
base_url = "https://api.openai.com/v1"
|
||||
api_key = "sk-..."
|
||||
model = "gpt-5"
|
||||
context_window = 400000
|
||||
|
||||
[model]
|
||||
default = "local" # which model to use by default
|
||||
fallback = ["claude", "openai"] # try these if the primary is unreachable
|
||||
agent_model = "claude" # optional: separate model for plan/task sub-agents
|
||||
```
|
||||
|
||||
Supported providers: `"openai"` (default -- OpenAI, vLLM, llama.cpp, any OpenAI-compatible API) and `"anthropic"` (Anthropic Messages API, requires `pip install turnstone[anthropic]`).
|
||||
|
||||
Use `/model` to show available models, `/model claude` to switch. Workstreams created via the API accept an optional `model` parameter.
|
||||
|
||||
## Configuration
|
||||
|
||||
All entry points read `~/.config/turnstone/config.toml`. CLI flags override config values.
|
||||
|
||||
```toml
|
||||
[api]
|
||||
base_url = "http://localhost:8000/v1"
|
||||
api_key = ""
|
||||
tavily_key = "" # only needed for local/vLLM models without native search
|
||||
|
||||
[model]
|
||||
name = "" # empty = auto-detect
|
||||
temperature = 0.5
|
||||
reasoning_effort = "medium"
|
||||
default = "default" # model alias for new workstreams
|
||||
fallback = [] # ordered list of fallback model aliases
|
||||
agent_model = "" # model alias for plan/task sub-agents
|
||||
|
||||
[tools]
|
||||
timeout = 30
|
||||
skip_permissions = false
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
max_workstreams = 10 # auto-evicts oldest idle when full
|
||||
|
||||
[redis]
|
||||
host = "localhost"
|
||||
port = 6379
|
||||
password = ""
|
||||
|
||||
[bridge]
|
||||
server_url = "http://localhost:8080"
|
||||
node_id = "" # empty = hostname_xxxx
|
||||
|
||||
[console]
|
||||
host = "0.0.0.0"
|
||||
port = 8090
|
||||
url = "http://localhost:8090" # used by CLI /cluster commands
|
||||
poll_interval = 10
|
||||
|
||||
[health]
|
||||
backend_probe_interval = 30
|
||||
backend_probe_timeout = 5
|
||||
circuit_breaker_threshold = 5
|
||||
circuit_breaker_cooldown = 60
|
||||
|
||||
[ratelimit]
|
||||
enabled = true
|
||||
requests_per_second = 10.0
|
||||
burst = 20
|
||||
|
||||
[database]
|
||||
backend = "sqlite" # "sqlite" (default) or "postgresql"
|
||||
path = ".turnstone.db" # SQLite file path (relative to working directory)
|
||||
# url = "postgresql+psycopg://user:pass@host:5432/turnstone" # PostgreSQL
|
||||
# pool_size = 5 # PostgreSQL connection pool size
|
||||
|
||||
[mcp]
|
||||
config_path = "" # path to MCP JSON config file (alternative to TOML sections)
|
||||
|
||||
[mcp.servers.example] # one section per MCP server
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-example"]
|
||||
# type = "stdio" # "stdio" (default) or "http"
|
||||
# url = "" # for HTTP transport
|
||||
```
|
||||
|
||||
Precedence: CLI args > environment variables > config.toml > defaults.
|
||||
|
||||
## Workstreams
|
||||
|
||||
Parallel independent conversations, each with its own session and state:
|
||||
|
||||
| Symbol | State | Meaning |
|
||||
|--------|-------|---------|
|
||||
| `·` | idle | Waiting for input |
|
||||
| `◌` | thinking | Model is generating |
|
||||
| `▸` | running | Tool execution in progress |
|
||||
| `◆` | attention | Waiting for approval |
|
||||
| `✖` | error | Something went wrong |
|
||||
|
||||
Idle workstreams are automatically cleaned up after 2 hours (configurable). In multi-node deployments, workstream ownership is tracked in Redis — follow-up messages auto-route to the owning node.
|
||||
|
||||
## Monitoring
|
||||
|
||||
`/metrics` endpoint exposes Prometheus-format metrics:
|
||||
|
||||
- `turnstone_tokens_total{direction}` — prompt/completion token counters
|
||||
- `turnstone_tool_calls_total{tool}` — per-tool invocation counts
|
||||
- `turnstone_workstream_context_ratio{ws_id}` — per-workstream context utilization
|
||||
- `turnstone_http_request_duration_seconds` — request latency histogram
|
||||
- `turnstone_workstreams_by_state{state}` — workstream state gauges
|
||||
- `turnstone_sse_connections_active` — current open SSE connections
|
||||
- `turnstone_ratelimit_rejected_total` — requests rejected by rate limiter
|
||||
- `turnstone_backend_up` — LLM backend reachability (0/1)
|
||||
- `turnstone_circuit_state` — circuit breaker state (0=closed, 1=open, 2=half_open)
|
||||
- `turnstone_workstreams_evicted_total` — workstreams auto-evicted at capacity
|
||||
|
||||
Per-workstream metrics are labeled by `ws_id` (bounded to 10 max workstreams).
|
||||
|
||||
### Health & Rate Limiting
|
||||
|
||||
**Health degradation.** A background `BackendHealthMonitor` probes the LLM backend every `backend_probe_interval` seconds. When the backend is unreachable, `/health` reports `"status": "degraded"` (HTTP 200) and the `turnstone_backend_up` gauge drops to 0.
|
||||
|
||||
**Circuit breaker.** After `circuit_breaker_threshold` consecutive probe failures the circuit opens (CLOSED -> OPEN). While open, `ChatSession._create_stream_with_retry` skips the backend entirely and returns an error. After `circuit_breaker_cooldown` seconds the circuit enters HALF_OPEN, allowing a single probe. A successful probe closes the circuit; a failure re-opens it.
|
||||
|
||||
**Per-IP rate limiting.** When `[ratelimit].enabled` is true, each client IP is tracked with a token-bucket limiter (`requests_per_second` / `burst`). Rate limiting is applied in `do_GET`/`do_POST` after authentication but before route dispatch. `/health` and `/metrics` are exempt. Requests that exceed the limit receive HTTP 429 with a `Retry-After` header.
|
||||
|
||||
**Workstream eviction.** When `WorkstreamManager.create()` would exceed `max_workstreams`, the oldest IDLE workstream is automatically evicted and the `turnstone_workstreams_evicted_total` counter is incremented. Configure via `[server].max_workstreams` (default 10).
|
||||
| Topic | Link |
|
||||
|-------|------|
|
||||
| Configuration reference | [docs/settings.md](docs/settings.md) |
|
||||
| API reference | [docs/api-reference.md](docs/api-reference.md) |
|
||||
| Docker deployment | [docs/docker.md](docs/docker.md) |
|
||||
| Intent validation (judge) | [docs/judge.md](docs/judge.md) |
|
||||
| Governance & RBAC | [docs/governance.md](docs/governance.md) |
|
||||
| OIDC SSO | [docs/oidc.md](docs/oidc.md) |
|
||||
| TLS / mTLS | [docs/tls.md](docs/tls.md) |
|
||||
| Channel integrations | [docs/channels.md](docs/channels.md) |
|
||||
| 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.md](docs/mcp.md) |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11+
|
||||
- An OpenAI-compatible API endpoint ([vLLM](https://github.com/vllm-project/vllm), [NVIDIA NIM](https://build.nvidia.com/), [llama.cpp](https://github.com/ggml-org/llama.cpp), etc.) or an Anthropic API key
|
||||
- Redis (for message queue bridge — `pip install turnstone[mq]`)
|
||||
- Anthropic provider (optional — `pip install turnstone[anthropic]`)
|
||||
- PostgreSQL (optional, for production — `pip install turnstone[postgres]`)
|
||||
- [Git LFS](https://git-lfs.com/) (for cloning — diagram PNGs are stored in LFS)
|
||||
- An OpenAI-compatible API endpoint or Anthropic API key
|
||||
- Optional: PostgreSQL (`pip install turnstone[postgres]`), Anthropic (`pip install turnstone[anthropic]`)
|
||||
- [Git LFS](https://git-lfs.com/) for cloning (diagram PNGs)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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 BUSL-1.1 license does not apply to these components.
|
||||
|
||||
================================================================================
|
||||
|
||||
KaTeX 0.16.38
|
||||
https://katex.org/
|
||||
https://github.com/KaTeX/KaTeX
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2020 Khan Academy and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
|
||||
highlight.js 11.11.1
|
||||
https://highlightjs.org/
|
||||
https://github.com/highlightjs/highlight.js
|
||||
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2006, Ivan Sagalaev.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
================================================================================
|
||||
|
||||
Mermaid 11.13.0
|
||||
https://mermaid.js.org/
|
||||
https://github.com/mermaid-js/mermaid
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2022 Knut Sveidqvist
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+77
-263
@@ -2,11 +2,11 @@
|
||||
# Turnstone Docker Compose Stack
|
||||
#
|
||||
# Usage:
|
||||
# Default (SQLite): docker compose up
|
||||
# Infra only: docker compose up
|
||||
# Single node: docker compose --profile production up
|
||||
# Production (PG): DB_BACKEND=postgresql docker compose --profile production up
|
||||
# (or set DB_BACKEND=postgresql in .env)
|
||||
# 10-node cluster: docker compose --profile cluster up
|
||||
# With simulator: docker compose --profile sim up
|
||||
# Cluster + DDG: docker compose --profile ddgCluster up
|
||||
# =============================================================================
|
||||
|
||||
name: turnstone
|
||||
@@ -16,7 +16,6 @@ networks:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
turnstone-data:
|
||||
postgres-data:
|
||||
|
||||
@@ -25,14 +24,22 @@ services:
|
||||
# PostgreSQL — production database (profile: production)
|
||||
# -------------------------------------------------------------------
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: pgautoupgrade/pgautoupgrade:18-alpine
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
- ddgCluster
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- max_connections=${POSTGRES_MAX_CONNECTIONS:-300}
|
||||
- -c
|
||||
- shared_buffers=128MB
|
||||
environment:
|
||||
POSTGRES_DB: turnstone
|
||||
POSTGRES_USER: ${POSTGRES_USER:-turnstone}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required for production profile}
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
@@ -42,45 +49,14 @@ services:
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
memory: 1G
|
||||
cpus: '1.0'
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Redis — message broker, pub/sub, node registry
|
||||
# -------------------------------------------------------------------
|
||||
redis:
|
||||
image: redis:7.4-alpine
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
redis-server
|
||||
--save 60 1
|
||||
--loglevel warning
|
||||
$${REDIS_PASSWORD:+--requirepass $$REDIS_PASSWORD}
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
environment:
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- turnstone-net
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- redis-cli $${REDIS_PASSWORD:+-a $$REDIS_PASSWORD} ping | grep -q PONG
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# turnstone-server — Web UI + chat workstreams + LLM interaction
|
||||
# -------------------------------------------------------------------
|
||||
@@ -88,6 +64,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
profiles:
|
||||
- production
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -99,10 +77,12 @@ services:
|
||||
--api-key "$${OPENAI_API_KEY}"
|
||||
$${MODEL:+--model $$MODEL}
|
||||
$${SKIP_PERMISSIONS:+--skip-permissions}
|
||||
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
|
||||
ports:
|
||||
- "${SERVER_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ./docker/mcp-ddg.json:/etc/turnstone/mcp-ddg.json:ro
|
||||
environment:
|
||||
- LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-dummy}
|
||||
@@ -112,53 +92,28 @@ services:
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
- MODEL=${MODEL:-}
|
||||
- MCP_CONFIG=${MCP_CONFIG:-}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-}
|
||||
- TURNSTONE_NODE_ID=${TURNSTONE_NODE_ID:-}
|
||||
- TURNSTONE_ADVERTISE_URL=${TURNSTONE_ADVERTISE_URL:-http://server:8080}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
ddg-search:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# turnstone-bridge — Redis <-> HTTP bridge for multi-node routing
|
||||
# Node ID auto-generated from container hostname (no --node-id needed)
|
||||
# -------------------------------------------------------------------
|
||||
bridge:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
environment:
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -172,23 +127,18 @@ services:
|
||||
- turnstone-console
|
||||
- --host=0.0.0.0
|
||||
- --port=8090
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --poll-interval=${CONSOLE_POLL_INTERVAL:-10}
|
||||
ports:
|
||||
- "${CONSOLE_PORT:-8090}:8090"
|
||||
environment:
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-}
|
||||
- TURNSTONE_CONSOLE_URL=http://console:8090
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8090/health"]
|
||||
interval: 10s
|
||||
@@ -207,78 +157,69 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
- ddgCluster
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
turnstone-channel
|
||||
--redis-host=redis
|
||||
--redis-port=6379
|
||||
--http-host=0.0.0.0
|
||||
$${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD}
|
||||
environment:
|
||||
- TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-}
|
||||
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-postgresql}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
|
||||
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# turnstone-sim — Multi-node cluster simulator (no LLM needed)
|
||||
# Start with: docker compose --profile sim up
|
||||
# ddg-search — DuckDuckGo Search MCP server (HTTP transport)
|
||||
# Provides web search + content fetch tools to turnstone via MCP.
|
||||
# No API key required.
|
||||
#
|
||||
# Start with: MCP_CONFIG=/etc/turnstone/mcp-ddg.json \
|
||||
# docker compose --profile ddgCluster up
|
||||
# -------------------------------------------------------------------
|
||||
sim:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ddg-search:
|
||||
image: python:3.14-slim
|
||||
profiles:
|
||||
- sim
|
||||
- ddgCluster
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
turnstone-sim
|
||||
--nodes "$${SIM_NODES}"
|
||||
--scenario "$${SIM_SCENARIO}"
|
||||
--duration "$${SIM_DURATION}"
|
||||
--mps "$${SIM_MPS}"
|
||||
--redis-host redis
|
||||
--redis-port 6379
|
||||
--log-level "$${SIM_LOG_LEVEL}"
|
||||
$${SIM_SEED:+--seed $$SIM_SEED}
|
||||
$${SIM_METRICS_FILE:+--metrics-file $$SIM_METRICS_FILE}
|
||||
environment:
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||
- SIM_NODES=${SIM_NODES:-100}
|
||||
- SIM_SCENARIO=${SIM_SCENARIO:-steady}
|
||||
- SIM_DURATION=${SIM_DURATION:-60}
|
||||
- SIM_MPS=${SIM_MPS:-5.0}
|
||||
- SIM_LOG_LEVEL=${SIM_LOG_LEVEL:-INFO}
|
||||
- SIM_SEED=${SIM_SEED:-}
|
||||
- SIM_METRICS_FILE=${SIM_METRICS_FILE:-}
|
||||
pip install --no-cache-dir duckduckgo-mcp-server &&
|
||||
python -c "from mcp.server.transport_security import TransportSecuritySettings; import duckduckgo_mcp_server.server as s; s.safe_search=s.SafeSearchMode.OFF; s.mcp.settings.host='0.0.0.0'; s.mcp.settings.port=3000; s.mcp.settings.transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False); s.mcp.run(transport='streamable-http')"
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import socket; s=socket.create_connection(('0.0.0.0',3000),2); s.close()\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
cpus: '0.25'
|
||||
restart: unless-stopped
|
||||
|
||||
# ===================================================================
|
||||
# 10-node cluster (profile: cluster)
|
||||
#
|
||||
# Each node is a server + bridge pair. All share the same PostgreSQL
|
||||
# and Redis instances. Access via console at :8090.
|
||||
# All nodes share the same PostgreSQL instance.
|
||||
# Access via console at :8090.
|
||||
#
|
||||
# Start: docker compose --profile cluster up
|
||||
# ===================================================================
|
||||
@@ -286,9 +227,10 @@ services:
|
||||
# -- cluster servers ------------------------------------------------
|
||||
|
||||
server-1: &cluster-server
|
||||
image: turnstone:local
|
||||
build: { context: ., dockerfile: Dockerfile }
|
||||
profiles: [cluster]
|
||||
command: &cluster-server-cmd
|
||||
profiles: [cluster, ddgCluster]
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
@@ -299,7 +241,10 @@ services:
|
||||
--api-key "$${OPENAI_API_KEY}"
|
||||
$${MODEL:+--model $$MODEL}
|
||||
$${SKIP_PERMISSIONS:+--skip-permissions}
|
||||
volumes: [turnstone-data:/data]
|
||||
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ./docker/mcp-ddg.json:/etc/turnstone/mcp-ddg.json:ro
|
||||
environment: &cluster-server-env
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy}
|
||||
@@ -309,20 +254,22 @@ services:
|
||||
TURNSTONE_AUTH_TOKEN: ${TURNSTONE_AUTH_TOKEN:-}
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:-}
|
||||
MODEL: ${MODEL:-}
|
||||
MCP_CONFIG: ${MCP_CONFIG:-}
|
||||
TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql}
|
||||
TURNSTONE_DB_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone}
|
||||
TURNSTONE_NODE_ID: node-1
|
||||
TURNSTONE_ADVERTISE_URL: http://server-1:8080
|
||||
extra_hosts: ["host.docker.internal:host-gateway"]
|
||||
networks: [turnstone-net]
|
||||
depends_on:
|
||||
redis: { condition: service_healthy }
|
||||
postgres: { condition: service_healthy }
|
||||
ddg-search: { condition: service_healthy, required: false }
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
deploy:
|
||||
resources:
|
||||
limits: { memory: 384M, cpus: '0.5' }
|
||||
@@ -330,162 +277,29 @@ services:
|
||||
|
||||
server-2:
|
||||
<<: *cluster-server
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-2 }
|
||||
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 }
|
||||
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 }
|
||||
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 }
|
||||
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 }
|
||||
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 }
|
||||
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 }
|
||||
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 }
|
||||
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 }
|
||||
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-10, TURNSTONE_ADVERTISE_URL: "http://server-10:8080" }
|
||||
|
||||
# -- cluster bridges ------------------------------------------------
|
||||
|
||||
bridge-1: &cluster-bridge
|
||||
build: { context: ., dockerfile: Dockerfile }
|
||||
profiles: [cluster]
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-1:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
environment: &cluster-bridge-env
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
TURNSTONE_AUTH_TOKEN: ${TURNSTONE_AUTH_TOKEN:-}
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:-}
|
||||
networks: [turnstone-net]
|
||||
depends_on:
|
||||
server-1: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
deploy:
|
||||
resources:
|
||||
limits: { memory: 256M, cpus: '0.25' }
|
||||
restart: unless-stopped
|
||||
|
||||
bridge-2:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-2:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-2: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-3:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-3:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-3: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-4:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-4:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-4: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-5:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-5:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-5: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-6:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-6:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-6: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-7:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-7:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-7: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-8:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-8:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-8: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-9:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-9:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-9: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
bridge-10:
|
||||
<<: *cluster-bridge
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server-10:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
depends_on:
|
||||
server-10: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 520" font-family="ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace" font-size="13">
|
||||
<style>
|
||||
@keyframes pulse-green { 0%,100% { opacity:0.5 } 50% { opacity:1 } }
|
||||
@keyframes pulse-yellow { 0%,100% { opacity:0.4 } 50% { opacity:1 } }
|
||||
@keyframes pulse-blue { 0%,100% { opacity:0.3 } 50% { opacity:1 } }
|
||||
@keyframes fadein { from { opacity:0 } to { opacity:1 } }
|
||||
.pg { animation: pulse-green 2s infinite }
|
||||
.py { animation: pulse-yellow 1.8s infinite }
|
||||
.pb { animation: pulse-blue 2.2s infinite }
|
||||
.f1 { animation: fadein 0.4s 0.2s both }
|
||||
.f2 { animation: fadein 0.4s 0.4s both }
|
||||
.f3 { animation: fadein 0.4s 0.6s both }
|
||||
.f4 { animation: fadein 0.4s 0.8s both }
|
||||
.f5 { animation: fadein 0.4s 1.0s both }
|
||||
.f6 { animation: fadein 0.4s 1.3s both }
|
||||
.f7 { animation: fadein 0.4s 1.5s both }
|
||||
.f8 { animation: fadein 0.4s 1.7s both }
|
||||
.f9 { animation: fadein 0.4s 1.9s both }
|
||||
.f10 { animation: fadein 0.4s 2.1s both }
|
||||
.f11 { animation: fadein 0.4s 2.3s both }
|
||||
.f12 { animation: fadein 0.4s 2.5s both }
|
||||
</style>
|
||||
|
||||
<!-- Window chrome -->
|
||||
<rect rx="10" width="860" height="520" fill="#1a1b26"/>
|
||||
<rect width="860" height="36" rx="10" fill="#16161e"/>
|
||||
<rect y="26" width="860" height="10" fill="#16161e"/>
|
||||
<circle cx="20" cy="18" r="6" fill="#f7768e"/>
|
||||
<circle cx="40" cy="18" r="6" fill="#e0af68"/>
|
||||
<circle cx="60" cy="18" r="6" fill="#9ece6a"/>
|
||||
<text x="430" y="22" text-anchor="middle" fill="#565f89" font-size="12">turnstone — console</text>
|
||||
|
||||
<!-- Header -->
|
||||
<rect y="36" width="860" height="30" fill="#24283b"/>
|
||||
<rect y="66" width="860" height="1" fill="#3b4261"/>
|
||||
<text x="16" y="56" fill="#7aa2f7" font-size="14" font-weight="bold">turnstone console</text>
|
||||
<text x="200" y="56" fill="#565f89" font-size="12">6 nodes · 10 workstreams</text>
|
||||
|
||||
<!-- ====== State cards ====== -->
|
||||
<g transform="translate(16, 78)" class="f1" opacity="0">
|
||||
<!-- RUN card -->
|
||||
<rect x="0" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="0" y="0" width="156" height="3" rx="6" fill="#9ece6a"/>
|
||||
<text x="78" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">3</text>
|
||||
<text x="78" y="50" text-anchor="middle" fill="#565f89" font-size="10">▸ RUN</text>
|
||||
|
||||
<!-- THINK card -->
|
||||
<rect x="168" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="168" y="0" width="156" height="3" rx="6" fill="#7aa2f7"/>
|
||||
<text x="246" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">2</text>
|
||||
<text x="246" y="50" text-anchor="middle" fill="#565f89" font-size="10">◌ THINK</text>
|
||||
|
||||
<!-- ATTN card -->
|
||||
<rect x="336" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="336" y="0" width="156" height="3" rx="6" fill="#e0af68"/>
|
||||
<text x="414" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">1</text>
|
||||
<text x="414" y="50" text-anchor="middle" fill="#565f89" font-size="10">◆ ATTN</text>
|
||||
|
||||
<!-- ERR card -->
|
||||
<rect x="504" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="504" y="0" width="156" height="3" rx="6" fill="#f7768e"/>
|
||||
<text x="582" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">0</text>
|
||||
<text x="582" y="50" text-anchor="middle" fill="#565f89" font-size="10">✖ ERR</text>
|
||||
|
||||
<!-- IDLE card -->
|
||||
<rect x="672" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
|
||||
<rect x="672" y="0" width="156" height="3" rx="6" fill="#565f89"/>
|
||||
<text x="750" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">4</text>
|
||||
<text x="750" y="50" text-anchor="middle" fill="#565f89" font-size="10">· IDLE</text>
|
||||
</g>
|
||||
|
||||
<!-- Aggregate bar -->
|
||||
<text x="16" y="160" fill="#565f89" font-size="11" class="f2" opacity="0">197k tokens · 42 tool calls</text>
|
||||
|
||||
<!-- ====== NODES section ====== -->
|
||||
<text x="16" y="182" fill="#7aa2f7" font-size="12" font-weight="bold" class="f3" opacity="0">NODES</text>
|
||||
|
||||
<!-- Node column headers -->
|
||||
<g transform="translate(0, 190)" class="f4" opacity="0">
|
||||
<rect width="860" height="20" fill="#24283b"/>
|
||||
<rect y="20" width="860" height="1" fill="#3b4261"/>
|
||||
<text y="14" fill="#565f89" font-size="10" letter-spacing="0.5">
|
||||
<tspan x="36">NODE</tspan>
|
||||
<tspan x="560">WS</tspan>
|
||||
<tspan x="610">RUN</tspan>
|
||||
<tspan x="660">ATTN</tspan>
|
||||
<tspan x="710">TOKENS</tspan>
|
||||
<tspan x="790">LOAD</tspan>
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Node rows -->
|
||||
<g transform="translate(0, 214)">
|
||||
|
||||
<!-- Node 1: db-west-04 — 3 ws, 1 running, has-running bar -->
|
||||
<g class="f5" opacity="0">
|
||||
<rect y="0" width="860" height="38" fill="#1a1b26"/>
|
||||
<rect y="0" width="3" height="38" fill="#9ece6a"/>
|
||||
<circle cx="22" cy="19" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="23" fill="#a9b1d6" font-size="12" font-weight="bold">db-west-04</text>
|
||||
<text x="566" y="23" fill="#a9b1d6" font-size="11">3</text>
|
||||
<text x="616" y="23" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="666" y="23" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="23" fill="#565f89" font-size="11">57.6k</text>
|
||||
<!-- Load bar: 3/10 = 30% -->
|
||||
<rect x="770" y="15" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="15" width="18" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="23" fill="#565f89" font-size="11">30%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 2: api-east-01 — 3 ws, 1 attention, has-attention bar -->
|
||||
<g class="f6" opacity="0">
|
||||
<rect y="40" width="860" height="38" fill="#24283b"/>
|
||||
<rect y="40" width="3" height="38" fill="#e0af68"/>
|
||||
<circle cx="22" cy="59" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="63" fill="#a9b1d6" font-size="12" font-weight="bold">api-east-01</text>
|
||||
<text x="566" y="63" fill="#a9b1d6" font-size="11">3</text>
|
||||
<text x="616" y="63" fill="#565f89" font-size="11">0</text>
|
||||
<text x="666" y="63" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="710" y="63" fill="#565f89" font-size="11">109k</text>
|
||||
<!-- Load bar: 3/10 = 30% -->
|
||||
<rect x="770" y="55" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="55" width="18" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="63" fill="#565f89" font-size="11">30%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 3: sre-node-03 — 2 ws, 1 running, has-running bar -->
|
||||
<g class="f7" opacity="0">
|
||||
<rect y="80" width="860" height="38" fill="#1a1b26"/>
|
||||
<rect y="80" width="3" height="38" fill="#9ece6a"/>
|
||||
<circle cx="22" cy="99" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="103" fill="#a9b1d6" font-size="12" font-weight="bold">sre-node-03</text>
|
||||
<text x="566" y="103" fill="#a9b1d6" font-size="11">2</text>
|
||||
<text x="616" y="103" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="666" y="103" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="103" fill="#565f89" font-size="11">64.4k</text>
|
||||
<!-- Load bar: 2/10 = 20% -->
|
||||
<rect x="770" y="95" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="95" width="12" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="103" fill="#565f89" font-size="11">20%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 4: analytics-02 — 1 ws, thinking, has-thinking bar -->
|
||||
<g class="f8" opacity="0">
|
||||
<rect y="120" width="860" height="38" fill="#24283b"/>
|
||||
<rect y="120" width="3" height="38" fill="#7aa2f7"/>
|
||||
<circle cx="22" cy="139" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="143" fill="#a9b1d6" font-size="12" font-weight="bold">analytics-02</text>
|
||||
<text x="566" y="143" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="616" y="143" fill="#565f89" font-size="11">0</text>
|
||||
<text x="666" y="143" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="143" fill="#565f89" font-size="11">18.3k</text>
|
||||
<!-- Load bar: 1/10 = 10% -->
|
||||
<rect x="770" y="135" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="135" width="6" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="143" fill="#565f89" font-size="11">10%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 5: data-ops-05 — 1 ws, thinking, has-thinking bar -->
|
||||
<g class="f9" opacity="0">
|
||||
<rect y="160" width="860" height="38" fill="#1a1b26"/>
|
||||
<rect y="160" width="3" height="38" fill="#7aa2f7"/>
|
||||
<circle cx="22" cy="179" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="183" fill="#a9b1d6" font-size="12" font-weight="bold">data-ops-05</text>
|
||||
<text x="566" y="183" fill="#a9b1d6" font-size="11">1</text>
|
||||
<text x="616" y="183" fill="#565f89" font-size="11">0</text>
|
||||
<text x="666" y="183" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="183" fill="#565f89" font-size="11">8.7k</text>
|
||||
<!-- Load bar: 1/10 = 10% -->
|
||||
<rect x="770" y="175" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<rect x="770" y="175" width="6" height="6" rx="3" fill="#9ece6a"/>
|
||||
<text x="838" y="183" fill="#565f89" font-size="11">10%</text>
|
||||
</g>
|
||||
|
||||
<!-- Node 6: ml-gpu-07 — 0 ws, empty, no bar -->
|
||||
<g class="f10" opacity="0">
|
||||
<rect y="200" width="860" height="38" fill="#24283b"/>
|
||||
<rect y="200" width="3" height="38" fill="transparent"/>
|
||||
<circle cx="22" cy="219" r="4" fill="#9ece6a"/>
|
||||
<text x="36" y="223" fill="#a9b1d6" font-size="12" font-weight="bold">ml-gpu-07</text>
|
||||
<text x="566" y="223" fill="#565f89" font-size="11">0</text>
|
||||
<text x="616" y="223" fill="#565f89" font-size="11">0</text>
|
||||
<text x="666" y="223" fill="#565f89" font-size="11">0</text>
|
||||
<text x="710" y="223" fill="#565f89" font-size="11">0</text>
|
||||
<!-- Load bar: 0/10 = 0% (empty track) -->
|
||||
<rect x="770" y="215" width="60" height="6" rx="3" fill="#292e42"/>
|
||||
<text x="842" y="223" fill="#565f89" font-size="11">0%</text>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
|
||||
<!-- ====== Footer ====== -->
|
||||
<g transform="translate(0, 468)" class="f12" opacity="0">
|
||||
<rect width="860" height="1" fill="#3b4261"/>
|
||||
<rect y="1" width="860" height="24" fill="#16161e"/>
|
||||
|
||||
<circle cx="20" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="28" y="18" fill="#565f89" font-size="10">db-west-04</text>
|
||||
|
||||
<circle cx="120" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="128" y="18" fill="#565f89" font-size="10">api-east-01</text>
|
||||
|
||||
<circle cx="225" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="233" y="18" fill="#565f89" font-size="10">sre-node-03</text>
|
||||
|
||||
<circle cx="335" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="343" y="18" fill="#565f89" font-size="10">analytics-02</text>
|
||||
|
||||
<circle cx="450" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="458" y="18" fill="#565f89" font-size="10">data-ops-05</text>
|
||||
|
||||
<circle cx="560" cy="14" r="3" fill="#9ece6a"/>
|
||||
<text x="568" y="18" fill="#565f89" font-size="10">ml-gpu-07</text>
|
||||
|
||||
<text x="680" y="18" fill="#3b4261" font-size="10">258k tokens · 42 calls · 12m</text>
|
||||
</g>
|
||||
|
||||
<!-- Bottom edge -->
|
||||
<rect y="493" width="860" height="27" fill="#16161e"/>
|
||||
<rect y="510" width="860" height="10" rx="10" fill="#16161e"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,85 @@
|
||||
# TLS overlay — enables mTLS across the turnstone cluster.
|
||||
#
|
||||
# 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
|
||||
# console's ACME endpoint.
|
||||
|
||||
services:
|
||||
# Bootstrap: create CA before anything starts.
|
||||
# Runs as root to create directories in the volume, then chowns
|
||||
# to turnstone:turnstone with restrictive perms (keys 0600).
|
||||
tls-init:
|
||||
build: .
|
||||
user: root
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
turnstone-admin tls-bootstrap --out /certs
|
||||
chown -R turnstone:turnstone /certs
|
||||
find /certs -type d -exec chmod 750 {} +
|
||||
find /certs -type f -name '*key.pem' -exec chmod 600 {} +
|
||||
find /certs -type f ! -name '*key.pem' -exec chmod 640 {} +
|
||||
volumes:
|
||||
- tls-certs:/certs
|
||||
networks:
|
||||
- turnstone-net
|
||||
restart: "no"
|
||||
|
||||
# Console: runs the internal CA + ACME server
|
||||
console:
|
||||
depends_on:
|
||||
tls-init:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
environment:
|
||||
TURNSTONE_TLS_ENABLED: "true"
|
||||
TURNSTONE_TLS_SANS: "console"
|
||||
TURNSTONE_CONSOLE_URL: "http://console:8090"
|
||||
command:
|
||||
- turnstone-console
|
||||
- --host=0.0.0.0
|
||||
- --port=8090
|
||||
- --poll-interval=${CONSOLE_POLL_INTERVAL:-10}
|
||||
|
||||
# Server: auto-provisions certs via console ACME, serves HTTPS
|
||||
server:
|
||||
depends_on:
|
||||
console:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
environment:
|
||||
TURNSTONE_TLS_ENABLED: "true"
|
||||
TURNSTONE_TLS_SANS: "server"
|
||||
# Disable healthcheck — server serves HTTPS with mTLS which the
|
||||
# stdlib healthcheck script can't satisfy. The base compose
|
||||
# healthcheck uses plain HTTP which won't work on an HTTPS listener.
|
||||
healthcheck:
|
||||
disable: true
|
||||
|
||||
# Channel: TLS
|
||||
channel:
|
||||
depends_on:
|
||||
console:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
environment:
|
||||
TURNSTONE_TLS_ENABLED: "true"
|
||||
TURNSTONE_TLS_SANS: "channel"
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
turnstone-channel
|
||||
--http-host=0.0.0.0
|
||||
$${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD}
|
||||
|
||||
volumes:
|
||||
tls-certs:
|
||||
@@ -7,10 +7,6 @@ appVersion: "0.3.0"
|
||||
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: ~16.0
|
||||
version: ~18.5.0
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
condition: postgresql.enabled
|
||||
- name: redis
|
||||
version: ~20.0
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
condition: redis.enabled
|
||||
|
||||
@@ -25,14 +25,10 @@ Then open: http://localhost:{{ .Values.console.service.port }}
|
||||
|
||||
Components deployed:
|
||||
- Server: {{ include "turnstone.fullname" . }}-server ({{ .Values.server.replicas }} replica(s))
|
||||
- Bridge: {{ include "turnstone.fullname" . }}-bridge ({{ .Values.bridge.replicas }} replica(s))
|
||||
- Console: {{ include "turnstone.fullname" . }}-console ({{ .Values.console.replicas }} replica(s))
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
- PostgreSQL (bitnami subchart)
|
||||
{{- end }}
|
||||
{{- if .Values.redis.enabled }}
|
||||
- Redis (bitnami subchart)
|
||||
{{- end }}
|
||||
|
||||
{{- if not .Values.llm.apiKey }}
|
||||
{{- if not .Values.llm.existingSecret }}
|
||||
|
||||
@@ -110,28 +110,6 @@ Determine the PostgreSQL username.
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Determine the Redis host.
|
||||
*/}}
|
||||
{{- define "turnstone.redis.host" -}}
|
||||
{{- if .Values.redis.enabled }}
|
||||
{{- printf "%s-redis-master" .Release.Name }}
|
||||
{{- else }}
|
||||
{{- .Values.redis.external.host }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Determine the Redis port.
|
||||
*/}}
|
||||
{{- define "turnstone.redis.port" -}}
|
||||
{{- if .Values.redis.enabled }}
|
||||
{{- printf "6379" }}
|
||||
{{- else }}
|
||||
{{- .Values.redis.external.port | toString }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Determine the secret name for LLM API keys.
|
||||
*/}}
|
||||
|
||||
@@ -14,8 +14,6 @@ data:
|
||||
TURNSTONE_SERVER_PORT: {{ .Values.server.service.port | quote }}
|
||||
TURNSTONE_CONSOLE_HOST: "0.0.0.0"
|
||||
TURNSTONE_CONSOLE_PORT: {{ .Values.console.service.port | quote }}
|
||||
TURNSTONE_REDIS_HOST: {{ include "turnstone.redis.host" . | quote }}
|
||||
TURNSTONE_REDIS_PORT: {{ include "turnstone.redis.port" . | quote }}
|
||||
TURNSTONE_POLL_INTERVAL: "5"
|
||||
{{- if .Values.llm.baseUrl }}
|
||||
TURNSTONE_LLM_BASE_URL: {{ .Values.llm.baseUrl | quote }}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "turnstone.fullname" . }}-bridge
|
||||
labels:
|
||||
{{- include "turnstone.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: bridge
|
||||
spec:
|
||||
replicas: {{ .Values.bridge.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "turnstone.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: bridge
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "turnstone.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: bridge
|
||||
spec:
|
||||
serviceAccountName: {{ include "turnstone.serviceAccountName" . }}
|
||||
containers:
|
||||
- name: bridge
|
||||
image: {{ include "turnstone.image" . }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url={{ printf "http://%s-server:%s" (include "turnstone.fullname" .) (.Values.server.service.port | toString) }}
|
||||
- --redis-host={{ include "turnstone.redis.host" . }}
|
||||
- --redis-port={{ include "turnstone.redis.port" . }}
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "turnstone.fullname" . }}-config
|
||||
- secretRef:
|
||||
name: {{ include "turnstone.llm.secretName" . }}
|
||||
optional: true
|
||||
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
|
||||
env:
|
||||
- name: TURNSTONE_AUTH_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.auth.existingSecret }}
|
||||
key: TURNSTONE_AUTH_TOKEN
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.bridge.resources | nindent 12 }}
|
||||
@@ -26,8 +26,6 @@ spec:
|
||||
- turnstone-console
|
||||
- --host=0.0.0.0
|
||||
- --port={{ .Values.console.service.port }}
|
||||
- --redis-host={{ include "turnstone.redis.host" . }}
|
||||
- --redis-port={{ include "turnstone.redis.port" . }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.console.service.port }}
|
||||
|
||||
@@ -18,11 +18,4 @@ data:
|
||||
{{- if and .Values.auth.enabled .Values.auth.token (not .Values.auth.existingSecret) }}
|
||||
TURNSTONE_AUTH_TOKEN: {{ .Values.auth.token | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if and .Values.redis.enabled .Values.redis.auth }}
|
||||
{{- if .Values.redis.auth.password }}
|
||||
REDIS_PASSWORD: {{ .Values.redis.auth.password | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- else if and (not .Values.redis.enabled) .Values.redis.external.password }}
|
||||
REDIS_PASSWORD: {{ .Values.redis.external.password | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -24,16 +24,6 @@ postgresql:
|
||||
database: turnstone
|
||||
username: turnstone
|
||||
|
||||
# -- Redis configuration
|
||||
redis:
|
||||
enabled: true
|
||||
architecture: standalone
|
||||
# External Redis settings (used when redis.enabled is false)
|
||||
external:
|
||||
host: ""
|
||||
port: 6379
|
||||
existingSecret: ""
|
||||
|
||||
# -- Turnstone server (main API + web UI)
|
||||
server:
|
||||
replicas: 1
|
||||
@@ -48,17 +38,6 @@ server:
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
|
||||
# -- Turnstone bridge (Redis MQ connector)
|
||||
bridge:
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 512Mi
|
||||
|
||||
# -- Turnstone console (cluster dashboard)
|
||||
console:
|
||||
replicas: 1
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# OpenShell inference routing for Turnstone.
|
||||
#
|
||||
# When using inference routing, the sandbox process connects to
|
||||
# https://inference.local instead of the real LLM API. The OpenShell
|
||||
# proxy intercepts, rewrites credentials, and forwards to the backend.
|
||||
#
|
||||
# This keeps real API keys out of the sandbox entirely — the process
|
||||
# only sees opaque placeholder tokens in its environment.
|
||||
#
|
||||
# Usage:
|
||||
# openshell sandbox run \
|
||||
# --inference-routes deploy/openshell/routes.yaml \
|
||||
# ...
|
||||
#
|
||||
# Then start turnstone with:
|
||||
# python3 -m turnstone.server --base-url https://inference.local
|
||||
#
|
||||
# CUSTOMIZE: uncomment one of the provider blocks below.
|
||||
|
||||
routes:
|
||||
|
||||
# --- OpenAI ---
|
||||
# - name: inference.local
|
||||
# endpoint: https://api.openai.com/v1
|
||||
# model: gpt-5
|
||||
# provider_type: openai
|
||||
# protocols:
|
||||
# - openai_chat_completions
|
||||
# - model_discovery
|
||||
# api_key_env: OPENAI_API_KEY
|
||||
|
||||
# --- Anthropic ---
|
||||
# - name: inference.local
|
||||
# endpoint: https://api.anthropic.com
|
||||
# model: claude-sonnet-4-6
|
||||
# provider_type: anthropic
|
||||
# protocols:
|
||||
# - anthropic_messages
|
||||
# api_key_env: ANTHROPIC_API_KEY
|
||||
|
||||
# --- Local model server (vLLM / llama.cpp) ---
|
||||
# No secret resolution needed — local servers typically have no auth.
|
||||
# Omit both api_key and api_key_env to skip credential injection.
|
||||
# - name: inference.local
|
||||
# endpoint: http://localhost:8000/v1
|
||||
# model: meta-llama/Llama-3.1-70B-Instruct
|
||||
# protocols:
|
||||
# - openai_chat_completions
|
||||
# - model_discovery
|
||||
@@ -0,0 +1,318 @@
|
||||
# OpenShell sandbox policy for Turnstone AI orchestration platform.
|
||||
#
|
||||
# This policy wraps a turnstone-server process (the primary sandbox target).
|
||||
# The console and channel gateway are separate processes that would each
|
||||
# need their own sandbox with a tailored policy variant.
|
||||
#
|
||||
# Usage:
|
||||
# openshell sandbox run \
|
||||
# --policy deploy/openshell/turnstone-policy.yaml \
|
||||
# --workdir /project \
|
||||
# -- python3 -m turnstone.server --host 0.0.0.0 --port 8080
|
||||
#
|
||||
# For inference routing (keeps real API keys out of the sandbox):
|
||||
# openshell sandbox run \
|
||||
# --policy deploy/openshell/turnstone-policy.yaml \
|
||||
# --inference-routes deploy/openshell/routes.yaml \
|
||||
# --workdir /project \
|
||||
# -- python3 -m turnstone.server --host 0.0.0.0 --port 8080 \
|
||||
# --base-url https://inference.local
|
||||
#
|
||||
# Note: inference.local is intercepted by the OpenShell proxy before
|
||||
# network policy evaluation — no network_policies entry is needed for it.
|
||||
#
|
||||
# Customization points (search for "CUSTOMIZE"):
|
||||
# - OIDC issuer endpoint
|
||||
# - MCP HTTP server endpoints
|
||||
# - Additional tool binaries
|
||||
# - web_fetch domain allowlist
|
||||
|
||||
version: 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filesystem: Landlock kernel enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static — cannot be changed after sandbox creation.
|
||||
# include_workdir adds the --workdir path to read_write automatically.
|
||||
|
||||
filesystem_policy:
|
||||
include_workdir: true
|
||||
|
||||
read_only:
|
||||
# Python runtime + installed packages (includes turnstone package)
|
||||
- /usr
|
||||
- /lib
|
||||
- /lib64
|
||||
# System essentials
|
||||
- /etc
|
||||
- /proc
|
||||
- /dev/urandom
|
||||
# Turnstone config (read-only — writes go to database)
|
||||
# CUSTOMIZE: adjust if config lives elsewhere
|
||||
- /home/sandbox/.config/turnstone
|
||||
|
||||
read_write:
|
||||
# Working directory is added via include_workdir
|
||||
# Temp files (bash tool scripts, eval workdirs)
|
||||
- /tmp
|
||||
# Shell redirections (2>/dev/null)
|
||||
- /dev/null
|
||||
# SQLite database (default location is workdir, covered by include_workdir)
|
||||
# Logs
|
||||
- /var/log
|
||||
|
||||
landlock:
|
||||
# best_effort: degrade gracefully on kernels without Landlock (< 5.13)
|
||||
# Change to hard_requirement for production hardened deployments
|
||||
compatibility: best_effort
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process: privilege separation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
process:
|
||||
run_as_user: sandbox
|
||||
run_as_group: sandbox
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Network: per-endpoint, per-binary allowlisting
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default-deny. Only listed host:port pairs are reachable.
|
||||
# Child processes (MCP servers, bash subcommands) inherit the network
|
||||
# namespace — they cannot bypass the proxy.
|
||||
|
||||
network_policies:
|
||||
|
||||
# --- LLM API providers ---
|
||||
|
||||
openai_api:
|
||||
name: openai-api
|
||||
endpoints:
|
||||
- host: api.openai.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
anthropic_api:
|
||||
name: anthropic-api
|
||||
endpoints:
|
||||
- host: api.anthropic.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- Web search fallback (Tavily) ---
|
||||
|
||||
tavily_api:
|
||||
name: tavily-search
|
||||
endpoints:
|
||||
- host: api.tavily.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- Skill discovery ---
|
||||
|
||||
skills_registry:
|
||||
name: skills-registry
|
||||
endpoints:
|
||||
- host: skills.sh
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
github_api:
|
||||
name: github-api
|
||||
endpoints:
|
||||
- host: api.github.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
access: read-only
|
||||
- host: raw.githubusercontent.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
mcp_registry:
|
||||
name: mcp-registry
|
||||
endpoints:
|
||||
- host: registry.modelcontextprotocol.io
|
||||
port: 443
|
||||
protocol: rest
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
access: read-only
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- OIDC SSO ---
|
||||
# CUSTOMIZE: replace with your identity provider's hostname
|
||||
|
||||
# oidc_provider:
|
||||
# name: oidc-provider
|
||||
# endpoints:
|
||||
# - host: login.example.com
|
||||
# port: 443
|
||||
# binaries:
|
||||
# - path: /usr/bin/python3*
|
||||
# - path: /usr/local/bin/python3*
|
||||
|
||||
# --- Discord (channel integration) ---
|
||||
# Uncomment if using turnstone-channel with Discord adapter.
|
||||
|
||||
# discord:
|
||||
# name: discord
|
||||
# endpoints:
|
||||
# - host: discord.com
|
||||
# port: 443
|
||||
# - host: gateway.discord.gg
|
||||
# port: 443
|
||||
# - host: cdn.discordapp.com
|
||||
# port: 443
|
||||
# binaries:
|
||||
# - path: /usr/bin/python3*
|
||||
# - path: /usr/local/bin/python3*
|
||||
|
||||
# --- web_fetch tool: curated domain allowlist ---
|
||||
#
|
||||
# This is the hard tradeoff. Turnstone's web_fetch tool lets the LLM
|
||||
# fetch arbitrary public URLs. OpenShell cannot allow "all HTTPS" —
|
||||
# every domain must be enumerated.
|
||||
#
|
||||
# Strategy: allowlist the domains your workloads actually need.
|
||||
# The web_fetch tool will return a connection error for unlisted domains,
|
||||
# which the LLM handles gracefully (it tells the user it can't reach
|
||||
# that site).
|
||||
#
|
||||
# CUSTOMIZE: add domains your workstreams need to fetch from.
|
||||
|
||||
web_fetch_common:
|
||||
name: web-fetch-common
|
||||
endpoints:
|
||||
# Documentation sites
|
||||
- host: "**.readthedocs.io"
|
||||
port: 443
|
||||
- host: docs.python.org
|
||||
port: 443
|
||||
- host: "**.github.io"
|
||||
port: 443
|
||||
# Package registries (metadata lookups)
|
||||
- host: pypi.org
|
||||
port: 443
|
||||
- host: www.npmjs.com
|
||||
port: 443
|
||||
# Stack Overflow / reference
|
||||
- host: stackoverflow.com
|
||||
port: 443
|
||||
- host: "**.stackexchange.com"
|
||||
port: 443
|
||||
# Wikipedia
|
||||
- host: "**.wikipedia.org"
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- MCP HTTP servers ---
|
||||
# CUSTOMIZE: add endpoints for any MCP servers using streamable-http
|
||||
# transport. stdio-transport MCP servers need no network entry (they
|
||||
# communicate via stdin/stdout pipes within the sandbox).
|
||||
|
||||
# mcp_http_servers:
|
||||
# name: mcp-http
|
||||
# endpoints:
|
||||
# - host: mcp.internal.example.com
|
||||
# port: 443
|
||||
# binaries:
|
||||
# - path: /usr/bin/python3*
|
||||
# - path: /usr/local/bin/python3*
|
||||
|
||||
# --- Bash tool: curl/wget ---
|
||||
# The bash tool can run curl/wget. These inherit the network namespace
|
||||
# so they can only reach allowed endpoints. But they need binary entries
|
||||
# to pass the proxy's identity check.
|
||||
|
||||
bash_network_tools:
|
||||
name: bash-network-tools
|
||||
endpoints:
|
||||
# Mirrors web_fetch_common — curl/wget should have the same reach.
|
||||
- host: "**.readthedocs.io"
|
||||
port: 443
|
||||
- host: docs.python.org
|
||||
port: 443
|
||||
- host: "**.github.io"
|
||||
port: 443
|
||||
- host: pypi.org
|
||||
port: 443
|
||||
- host: www.npmjs.com
|
||||
port: 443
|
||||
- host: stackoverflow.com
|
||||
port: 443
|
||||
- host: "**.stackexchange.com"
|
||||
port: 443
|
||||
- host: "**.wikipedia.org"
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/curl
|
||||
- path: /usr/bin/wget
|
||||
|
||||
# --- Package installation ---
|
||||
# pip install / uv add from the bash tool.
|
||||
|
||||
package_registries:
|
||||
name: package-install
|
||||
endpoints:
|
||||
- host: pypi.org
|
||||
port: 443
|
||||
- host: files.pythonhosted.org
|
||||
port: 443
|
||||
- host: "**.pypi.org"
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/pip*
|
||||
- path: /usr/local/bin/pip*
|
||||
- path: /usr/bin/uv
|
||||
- path: /usr/local/bin/uv
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- Git operations ---
|
||||
# read-only: clone, fetch, pull. No push (L7 enforcement).
|
||||
|
||||
git_operations:
|
||||
name: git-read-only
|
||||
endpoints:
|
||||
- host: github.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
rules:
|
||||
- allow:
|
||||
method: GET
|
||||
path: "/**/info/refs*"
|
||||
- allow:
|
||||
method: POST
|
||||
path: "/**/git-upload-pack"
|
||||
- host: gitlab.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
rules:
|
||||
- allow:
|
||||
method: GET
|
||||
path: "/**/info/refs*"
|
||||
- allow:
|
||||
method: POST
|
||||
path: "/**/git-upload-pack"
|
||||
binaries:
|
||||
- path: /usr/bin/git
|
||||
@@ -22,8 +22,3 @@ output "rds_endpoint" {
|
||||
description = "RDS PostgreSQL endpoint."
|
||||
value = module.turnstone.rds_endpoint
|
||||
}
|
||||
|
||||
output "redis_endpoint" {
|
||||
description = "ElastiCache Redis endpoint."
|
||||
value = module.turnstone.redis_endpoint
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ variable "vpc_id" {
|
||||
}
|
||||
|
||||
variable "private_subnet_ids" {
|
||||
description = "List of private subnet IDs for ECS tasks, RDS, and ElastiCache."
|
||||
description = "List of private subnet IDs for ECS tasks and RDS."
|
||||
type = list(string)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# ---------- ElastiCache Subnet Group ----------
|
||||
|
||||
resource "aws_elasticache_subnet_group" "this" {
|
||||
name = "${var.name_prefix}-${var.environment}"
|
||||
subnet_ids = var.private_subnet_ids
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
# ---------- ElastiCache Redis Replication Group ----------
|
||||
|
||||
resource "aws_elasticache_replication_group" "this" {
|
||||
replication_group_id = "${var.name_prefix}-${var.environment}"
|
||||
description = "Turnstone Redis for MQ and session state"
|
||||
|
||||
engine = "redis"
|
||||
engine_version = "7.1"
|
||||
node_type = var.redis_node_type
|
||||
num_cache_clusters = 1
|
||||
port = 6379
|
||||
|
||||
subnet_group_name = aws_elasticache_subnet_group.this.name
|
||||
security_group_ids = [aws_security_group.redis.id]
|
||||
|
||||
at_rest_encryption_enabled = true
|
||||
transit_encryption_enabled = true
|
||||
|
||||
automatic_failover_enabled = false
|
||||
|
||||
tags = local.common_tags
|
||||
}
|
||||
@@ -27,7 +27,6 @@ locals {
|
||||
{ name = "TURNSTONE_ENV", value = var.environment },
|
||||
{ name = "TURNSTONE_DB_BACKEND", value = "postgresql" },
|
||||
{ name = "TURNSTONE_LLM_BASE_URL", value = var.llm_base_url },
|
||||
{ name = "TURNSTONE_REDIS_URL", value = "redis://${aws_elasticache_replication_group.this.primary_endpoint_address}:6379/0" },
|
||||
]
|
||||
|
||||
# Secrets pulled from Secrets Manager at container start.
|
||||
@@ -187,57 +186,6 @@ resource "aws_ecs_service" "server" {
|
||||
depends_on = [aws_lb_target_group.server]
|
||||
}
|
||||
|
||||
# ---------- Bridge Task Definition + Service ----------
|
||||
|
||||
resource "aws_ecs_task_definition" "bridge" {
|
||||
family = "${var.name_prefix}-bridge"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
network_mode = "awsvpc"
|
||||
cpu = var.bridge_cpu
|
||||
memory = var.bridge_memory
|
||||
execution_role_arn = aws_iam_role.ecs_execution.arn
|
||||
task_role_arn = aws_iam_role.ecs_task.arn
|
||||
tags = local.common_tags
|
||||
|
||||
container_definitions = jsonencode([
|
||||
{
|
||||
name = "bridge"
|
||||
image = local.full_image
|
||||
essential = true
|
||||
command = ["turnstone-bridge"]
|
||||
|
||||
environment = concat(local.common_env, local.auth_env)
|
||||
secrets = concat(local.common_secrets, local.auth_secrets)
|
||||
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
"awslogs-group" = aws_cloudwatch_log_group.this.name
|
||||
"awslogs-region" = data.aws_region.current.name
|
||||
"awslogs-stream-prefix" = "bridge"
|
||||
}
|
||||
}
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
resource "aws_ecs_service" "bridge" {
|
||||
name = "${var.name_prefix}-bridge"
|
||||
cluster = aws_ecs_cluster.this.id
|
||||
task_definition = aws_ecs_task_definition.bridge.arn
|
||||
desired_count = 1
|
||||
launch_type = "FARGATE"
|
||||
tags = local.common_tags
|
||||
|
||||
network_configuration {
|
||||
subnets = var.private_subnet_ids
|
||||
security_groups = [aws_security_group.ecs_tasks.id]
|
||||
assign_public_ip = false
|
||||
}
|
||||
|
||||
depends_on = [aws_ecs_service.server]
|
||||
}
|
||||
|
||||
# ---------- Console Task Definition + Service ----------
|
||||
|
||||
resource "aws_ecs_task_definition" "console" {
|
||||
|
||||
@@ -22,8 +22,3 @@ output "rds_endpoint" {
|
||||
description = "Endpoint of the RDS PostgreSQL instance (host:port)."
|
||||
value = aws_db_instance.this.endpoint
|
||||
}
|
||||
|
||||
output "redis_endpoint" {
|
||||
description = "Primary endpoint of the ElastiCache Redis replication group."
|
||||
value = aws_elasticache_replication_group.this.primary_endpoint_address
|
||||
}
|
||||
|
||||
@@ -112,22 +112,3 @@ resource "aws_vpc_security_group_ingress_rule" "rds_from_ecs" {
|
||||
referenced_security_group_id = aws_security_group.ecs_tasks.id
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
# ---------- Redis Security Group ----------
|
||||
|
||||
resource "aws_security_group" "redis" {
|
||||
name = "${var.name_prefix}-redis-${var.environment}"
|
||||
description = "Allow Redis access from ECS tasks"
|
||||
vpc_id = var.vpc_id
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
resource "aws_vpc_security_group_ingress_rule" "redis_from_ecs" {
|
||||
security_group_id = aws_security_group.redis.id
|
||||
description = "Redis from ECS tasks"
|
||||
from_port = 6379
|
||||
to_port = 6379
|
||||
ip_protocol = "tcp"
|
||||
referenced_security_group_id = aws_security_group.ecs_tasks.id
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ variable "vpc_id" {
|
||||
}
|
||||
|
||||
variable "private_subnet_ids" {
|
||||
description = "List of private subnet IDs for ECS tasks, RDS, and ElastiCache."
|
||||
description = "List of private subnet IDs for ECS tasks and RDS."
|
||||
type = list(string)
|
||||
}
|
||||
|
||||
@@ -50,14 +50,6 @@ variable "db_instance_class" {
|
||||
default = "db.t4g.micro"
|
||||
}
|
||||
|
||||
# --- ElastiCache ---
|
||||
|
||||
variable "redis_node_type" {
|
||||
description = "ElastiCache node type for Redis."
|
||||
type = string
|
||||
default = "cache.t4g.micro"
|
||||
}
|
||||
|
||||
# --- ECS Task Sizing ---
|
||||
|
||||
variable "server_cpu" {
|
||||
@@ -72,18 +64,6 @@ variable "server_memory" {
|
||||
default = 1024
|
||||
}
|
||||
|
||||
variable "bridge_cpu" {
|
||||
description = "CPU units for the bridge task."
|
||||
type = number
|
||||
default = 256
|
||||
}
|
||||
|
||||
variable "bridge_memory" {
|
||||
description = "Memory (MiB) for the bridge task."
|
||||
type = number
|
||||
default = 512
|
||||
}
|
||||
|
||||
variable "console_cpu" {
|
||||
description = "CPU units for the console task."
|
||||
type = number
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ddg": {
|
||||
"url": "http://ddg-search:3000/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
+968
-20
File diff suppressed because it is too large
Load Diff
+245
-121
@@ -3,7 +3,7 @@
|
||||
Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent
|
||||
memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) or
|
||||
Anthropic's native Messages API via pluggable provider adapters, and gives the
|
||||
model 14 built-in tools plus external tools via MCP (Model Context Protocol) for
|
||||
model 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**.
|
||||
@@ -18,10 +18,9 @@ plugs in.
|
||||
|---------|--------|----------|---------|
|
||||
| `turnstone` | `turnstone.cli` | `TerminalUI` | Interactive terminal REPL |
|
||||
| `turnstone-server` | `turnstone.server` | `WebUI` | Browser-based chat (HTTP + SSE) |
|
||||
| `turnstone-bridge` | `turnstone.mq.bridge` | Bridge | Message queue ↔ HTTP API bridge |
|
||||
| `turnstone-console` | `turnstone.console.server` | ClusterCollector | Cluster dashboard (aggregates all nodes) |
|
||||
| `turnstone-eval` | `turnstone.eval` | `NullUI` | Headless evaluation and prompt optimization |
|
||||
| `turnstone-channel` | `turnstone.channels.cli` | ChannelAdapter | Channel gateway (Discord, Slack, etc.) via Redis MQ |
|
||||
| `turnstone-channel` | `turnstone.channels.cli` | ChannelAdapter | Channel gateway (Discord, Slack, etc.) |
|
||||
| `turnstone-admin` | `turnstone.core.admin_cli` | — | Offline user and API token management |
|
||||
|
||||
---
|
||||
@@ -42,9 +41,15 @@ turnstone/
|
||||
__init__.py create_provider() + create_client() factory functions
|
||||
workstream.py Parallel workstream manager (WorkstreamState, Workstream, WorkstreamManager)
|
||||
tools.py Tool schema loader (JSON -> OpenAI function-calling format)
|
||||
mcp_client.py MCPClientManager — MCP server connections, tool discovery, async-sync bridge
|
||||
mcp_client.py MCPClientManager — MCP server connections, tool discovery, dynamic refresh
|
||||
tool_search.py Dynamic tool search — BM25 index, session-scoped tool visibility
|
||||
watch.py WatchRunner daemon — periodic command polling, condition DSL, result dispatch
|
||||
judge.py Intent validation — heuristic rules + LLM judge, advisory verdicts
|
||||
model_registry.py ModelRegistry — named model configs, lazy client creation, fallback routing
|
||||
memory.py Persistence facade (delegates to storage backend)
|
||||
memory.py Persistence facade + structured memory API (delegates to storage backend)
|
||||
config.py Config file loader (config.toml), apply_config(), warn_migrated_settings()
|
||||
config_store.py ConfigStore — database-backed settings with in-memory cache, thread-safe get/set
|
||||
settings_registry.py SettingDef catalog (~40 settings), validation, type coercion, serialization
|
||||
storage/ Pluggable storage: StorageBackend protocol, SQLite + PostgreSQL
|
||||
metrics.py Prometheus-compatible metrics collector (MetricsCollector)
|
||||
healthcheck.py BackendHealthMonitor — periodic probe + circuit breaker
|
||||
@@ -68,33 +73,30 @@ turnstone/
|
||||
_base.py Shared httpx async client, auth, error handling
|
||||
_sync.py Background event loop for sync wrappers
|
||||
_types.py TurnResult + TurnstoneAPIError
|
||||
mq/
|
||||
protocol.py Inbound/outbound message dataclasses (JSON serialization)
|
||||
broker.py Abstract MessageBroker protocol + RedisBroker
|
||||
bridge.py Bridge service (queue ↔ turnstone-server HTTP API)
|
||||
client.py TurnstoneClient library + TurnResult for MQ-based access
|
||||
console/
|
||||
collector.py ClusterCollector — aggregates state from all nodes via Redis + HTTP
|
||||
scheduler.py TaskScheduler — background cron/at scheduler, dispatches via MQ
|
||||
collector.py ClusterCollector — aggregates state from all nodes via HTTP
|
||||
scheduler.py TaskScheduler — background cron/at scheduler, dispatches via HTTP
|
||||
server.py Cluster dashboard HTTP server + SSE + CLI entry point
|
||||
static/ Cluster dashboard web UI (page-specific HTML, CSS, JS)
|
||||
channels/
|
||||
cli.py Unified channel gateway entry point (turnstone-channel)
|
||||
_protocol.py ChannelAdapter protocol, ChannelEvent dataclass
|
||||
_routing.py ChannelRouter — channel/thread ↔ workstream mapping via MQ
|
||||
_routing.py ChannelRouter — channel/thread ↔ workstream mapping via HTTP
|
||||
_config.py Base ChannelConfig dataclass
|
||||
discord/ Discord adapter (bot, cog, views, streaming, config)
|
||||
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
|
||||
katex-0.16.44/ 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)
|
||||
spinner.py Braille character spinner (daemon thread)
|
||||
static/
|
||||
index.html Single-page app shell (links to CSS and JS)
|
||||
style.css Page-specific UI styles (dashboard layout, approval blocks)
|
||||
app.js Page-specific client-side JavaScript (SSE, workstreams, markdown)
|
||||
style.css Page-specific UI styles (dashboard, markdown elements, approval blocks)
|
||||
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 14 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||||
*.json 15 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||||
```
|
||||
|
||||
Both UIs share a common design system extracted into `turnstone/shared_static/`: design tokens, login overlay, toast notifications, theme toggle, keyboard shortcuts, and utility functions. Each UI imports `base.css` and the shared JS modules at `/shared/`, then adds only page-specific code at `/static/`.
|
||||
@@ -127,6 +129,7 @@ A user message flows through the system as follows:
|
||||
| on_reasoning_token() / on_content_token()
|
||||
| accumulate tool_calls from deltas
|
||||
| track finish_reason
|
||||
| _check_cancelled() per chunk (cooperative cancel)
|
||||
v
|
||||
finish_reason check:
|
||||
+--- "length" --> warn, discard partial tool_calls
|
||||
@@ -172,11 +175,13 @@ Phase 2: APPROVE (serial, blocking)
|
||||
_emit_state("running")
|
||||
|
||||
Phase 3: EXECUTE (parallel)
|
||||
_check_cancelled() <-- cancellation checkpoint before execution starts
|
||||
if len(items) == 1:
|
||||
run_one(items[0])
|
||||
else:
|
||||
ThreadPoolExecutor(max_workers=4).map(run_one, items)
|
||||
Bash tool streams stdout line-by-line via ui.on_tool_output_chunk(call_id, line)
|
||||
(cancel_event also checked per line — kills process group on cancel)
|
||||
Final output (stdout + stderr) delivered via ui.on_tool_result(call_id, name, output)
|
||||
call_id links tool_info items → streaming chunks → final result
|
||||
For plan tool: post-execution gate via ui.on_plan_review()
|
||||
@@ -207,6 +212,11 @@ The engine emits state changes via `_emit_state()` which calls
|
||||
"idle" ---> no more tool calls, turn complete
|
||||
|
|
||||
(or "error" ---> exception or KeyboardInterrupt)
|
||||
|
||||
cancel() may be called from any state. It sets a cooperative flag
|
||||
checked at each streaming chunk, before tool execution, and inside
|
||||
bash commands. The session transitions to "idle" with partial
|
||||
content preserved, emitting on_info("[Generation cancelled]").
|
||||
```
|
||||
|
||||
---
|
||||
@@ -226,7 +236,7 @@ class SessionUI(Protocol):
|
||||
def on_content_token(self, text: str) -> None: ...
|
||||
def on_stream_end(self) -> None: ...
|
||||
def approve_tools(self, items: list[dict]) -> tuple[bool, str | None]: ...
|
||||
def on_tool_result(self, call_id: str, name: str, output: str) -> None: ...
|
||||
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: ...
|
||||
@@ -243,7 +253,7 @@ class SessionUI(Protocol):
|
||||
| Class | Module | Notes |
|
||||
|-------|--------|-------|
|
||||
| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval |
|
||||
| `WebUI` | `turnstone.server` | SSE event queue per workstream, `threading.Event` for blocking on approval/plan |
|
||||
| `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
|
||||
@@ -337,7 +347,7 @@ remove the tab immediately. Controlled by `--workstream-idle-timeout` (default:
|
||||
|
||||
**Workstream eviction at capacity:** When `WorkstreamManager.create()` would
|
||||
exceed `max_workstreams` (configurable via `[server].max_workstreams`, default
|
||||
10), the oldest IDLE workstream is automatically evicted to make room. The
|
||||
50), the oldest IDLE workstream is automatically evicted to make room. The
|
||||
`turnstone_workstreams_evicted_total` counter is incremented on each eviction.
|
||||
If no IDLE workstream is available the create request fails as before.
|
||||
|
||||
@@ -359,12 +369,19 @@ non-idle background workstreams above the input prompt.
|
||||
### Web Workstreams
|
||||
|
||||
- **Tab bar**: Each workstream renders as a tab with a colored state indicator
|
||||
(CSS `@keyframes pulse` animation per state).
|
||||
- **Per-tab SSE**: `connectContentSSE(wsId)` opens
|
||||
`/v1/api/events?ws_id=<id>` for the active tab's event stream.
|
||||
(CSS `@keyframes pulse` animation per state). Clicking a tab switches the
|
||||
focused pane's workstream (or focuses an existing pane showing that ws).
|
||||
- **Split panes**: The UI supports tiling multiple workstreams side-by-side or
|
||||
stacked via a binary layout tree. Each `Pane` instance encapsulates its own
|
||||
SSE connection, message area, input, and state (busy, approval, streaming).
|
||||
Split via right-click context menu, pane header buttons, or keyboard
|
||||
(`Ctrl+\`, `Ctrl+Shift+\`). Max 6 panes; no duplicate workstreams across panes.
|
||||
Layout persisted to `localStorage`.
|
||||
- **Per-pane SSE**: `Pane.connectSSE(wsId)` opens
|
||||
`/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 without switching.
|
||||
indicators and pane headers without switching.
|
||||
- **New tab / close**: POST `/v1/api/workstreams/new`, POST `/v1/api/workstreams/close`.
|
||||
|
||||
### Thread Safety
|
||||
@@ -425,13 +442,13 @@ from each schema and builds:
|
||||
- `PRIMARY_KEY_MAP` -- `{name: primary_key}` for JSON fallback recovery
|
||||
- `merge_mcp_tools(builtin, mcp_tools)` -- merges built-in + MCP tools at session init
|
||||
|
||||
### 14 Tools by Category
|
||||
### 13 Tools by Category
|
||||
|
||||
**Read-only (auto-approve)**:
|
||||
- `read_file` -- read file contents with optional offset/limit
|
||||
- `search` -- ripgrep-based codebase search
|
||||
- `man` -- read man pages
|
||||
- `recall` -- retrieve stored memories
|
||||
- `recall` -- search conversation history
|
||||
|
||||
**Write (requires approval)**:
|
||||
- `bash` -- execute shell commands (with safety checks via `turnstone.core.safety`)
|
||||
@@ -445,9 +462,8 @@ from each schema and builds:
|
||||
- `task` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
|
||||
- `plan` -- explore codebase and write a structured plan (`AGENT_TOOLS`)
|
||||
|
||||
**Memory (persistent key-value store)**:
|
||||
- `remember` -- save a fact
|
||||
- `forget` -- delete a fact
|
||||
**Memory (structured persistent store)**:
|
||||
- `memory` -- save, search, delete, or list memories (typed and scoped)
|
||||
|
||||
### Prepare / Execute Pattern
|
||||
|
||||
@@ -493,23 +509,59 @@ independently, then returns the final content as the tool result.
|
||||
and exposes their tools alongside built-in tools. The MCP SDK is fully async; turnstone
|
||||
bridges this with a background asyncio event loop in a daemon thread.
|
||||
|
||||
**Configuration sources:** MCP servers can be defined in config files (TOML/JSON)
|
||||
or in the database via the admin UI. Database-backed definitions are managed
|
||||
through the console admin panel's MCP Servers tab and stored in the
|
||||
`mcp_servers` table. On startup, `load_mcp_config(storage=)` uses
|
||||
first-match-wins priority: DB rows (if any enabled) take precedence over
|
||||
config files. The console can trigger a cluster-wide reload (`POST
|
||||
/_internal/mcp-reload`) that causes each node to call `reconcile_sync()`,
|
||||
which diffs the running MCP connections against the current DB state and
|
||||
adds, removes, or reconnects servers as needed.
|
||||
|
||||
**Lifecycle:**
|
||||
1. `create_mcp_client()` reads server configs from TOML or JSON
|
||||
1. `create_mcp_client()` reads server configs from TOML/JSON and database
|
||||
2. `MCPClientManager.start()` launches the background event loop thread
|
||||
3. `_connect_all()` connects to each server (stdio subprocess or HTTP), runs
|
||||
`initialize()` + `list_tools()`, converts schemas to OpenAI format
|
||||
4. `ChatSession.__init__` receives the manager and builds `self._tools` (built-in + MCP)
|
||||
`initialize()` + `list_tools()`, converts schemas to OpenAI format, detects
|
||||
`tools.listChanged` capability for push notification support
|
||||
4. `ChatSession.__init__` receives the manager, builds `self._tools` (built-in + MCP),
|
||||
and registers a listener callback for tool-change notifications
|
||||
5. `_prepare_tool()` routes MCP tools to `_prepare_mcp_tool()` / `_exec_mcp_tool()`
|
||||
6. `_exec_mcp_tool()` calls `call_tool_sync()` which dispatches to the async loop
|
||||
via `asyncio.run_coroutine_threadsafe()`
|
||||
|
||||
**Tool refresh:** Three mechanisms keep tools up-to-date without restart:
|
||||
- **Push:** Servers declaring `tools.listChanged` send `ToolListChangedNotification`;
|
||||
the registered `message_handler` triggers immediate single-server refresh.
|
||||
- **Periodic:** Servers without push support are polled on a staggered interval
|
||||
(default 4 h, configurable via `[mcp] refresh_interval` or `--mcp-refresh-interval`).
|
||||
- **Manual:** `/mcp refresh [server]` calls `refresh_sync()` for on-demand refresh
|
||||
(also attempts reconnection for disconnected servers).
|
||||
|
||||
When tools change, `_rebuild_tools()` creates new `_tools`/`_tool_map` objects
|
||||
(copy-on-write for thread safety) and notifies listener callbacks. Each `ChatSession`
|
||||
rebuilds its merged tool lists and reconstructs `ToolSearchManager` (preserving
|
||||
expanded tools).
|
||||
|
||||
**Tool naming:** `mcp__{server}__{tool}` — double underscore delimiter, validated
|
||||
at connection time (server names with `__` are rejected).
|
||||
|
||||
**Error isolation:** Per-server connection failures are caught and logged; other
|
||||
servers still connect. Tool execution errors return error strings to the LLM
|
||||
**Error isolation:** Per-server connection/refresh failures are caught and logged; other
|
||||
servers are unaffected. Tool execution errors return error strings to the LLM
|
||||
rather than crashing the session.
|
||||
|
||||
**Registry discovery:** The console admin panel provides a registry discovery
|
||||
surface backed by the official MCP Registry (registry.modelcontextprotocol.io).
|
||||
`MCPRegistryClient` (`turnstone/core/mcp_registry.py`) is a standalone httpx
|
||||
async client that queries the registry's v0.1 API for server discovery. Search
|
||||
results are annotated with installed status by cross-referencing the
|
||||
`mcp_servers` table. Installation creates a DB row with `registry_name`,
|
||||
`registry_version`, and `registry_meta` columns (migration 019), then triggers
|
||||
cluster-wide node reload via `_notify_nodes_mcp_reload()`. The registry URL is
|
||||
configurable via the `mcp.registry_url` setting for enterprise/private
|
||||
registries.
|
||||
|
||||
### Provider Adapter Layer
|
||||
|
||||
> See also: [Core Engine Classes diagram](diagrams/png/03-core-engine-classes.png)
|
||||
@@ -544,29 +596,40 @@ LLMProvider (protocol)
|
||||
|------|--------|
|
||||
| `StreamChunk` | `content_delta`, `reasoning_delta`, `tool_call_deltas`, `info_delta`, `usage`, `finish_reason` |
|
||||
| `CompletionResult` | `content`, `tool_calls`, `finish_reason`, `usage` |
|
||||
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search` |
|
||||
| `UsageInfo` | `prompt_tokens`, `completion_tokens`, `total_tokens` |
|
||||
| `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
|
||||
already in OpenAI format). Model capability lookup table covers
|
||||
GPT-5/5.1/5.2, O-series, and search models (`gpt-5-search-api`).
|
||||
already in OpenAI format), including multi-part content blocks (text + images)
|
||||
in tool results. Model capability lookup table covers GPT-5/5.1/5.2/5.3/5.4,
|
||||
O-series, and search models (`gpt-5-search-api`) — all with `supports_vision`.
|
||||
For search models, injects `web_search_options` and removes the `web_search`
|
||||
function tool (the model always searches). Citations from `url_citation`
|
||||
annotations are formatted as footnotes. Unknown models (local servers) get
|
||||
permissive defaults and use Tavily for web search.
|
||||
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 Tavily for web search.
|
||||
|
||||
**AnthropicProvider** (`_anthropic.py`): converts OpenAI-format messages to
|
||||
Anthropic content blocks, maps `system`/`developer` roles to the `system`
|
||||
parameter, groups consecutive `tool` result messages into user-role content
|
||||
blocks, and translates tool schemas from OpenAI function-calling format to
|
||||
blocks (converting `image_url` parts to Anthropic's `image` source format),
|
||||
and translates tool schemas from OpenAI function-calling format to
|
||||
Anthropic's `input_schema` format. Supports both manual and adaptive thinking
|
||||
modes, with effort parameter support for models like Claude Opus 4.6 and
|
||||
Sonnet 4.6. Replaces the `web_search` function tool with Anthropic's native
|
||||
`web_search_20250305` server-side tool — Claude decides when to search, the
|
||||
API executes it, and results stream back as `server_tool_use` /
|
||||
`web_search_tool_result` content blocks (emitted as `info_delta` for UI
|
||||
display). The `anthropic` SDK is imported lazily so it remains an optional
|
||||
dependency (`pip install turnstone[anthropic]`).
|
||||
display). Automatic prompt caching is enabled via top-level `cache_control:
|
||||
{"type": "ephemeral"}` — the API places the cache breakpoint on the last
|
||||
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 imported
|
||||
lazily so it remains an optional dependency (`pip install
|
||||
turnstone[anthropic]`).
|
||||
|
||||
**Factory functions** (`__init__.py`): `create_provider(name)` returns a
|
||||
singleton provider instance (thread-safe). `create_client(name, base_url,
|
||||
@@ -604,6 +667,18 @@ agent_model = "claude"
|
||||
|
||||
Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
|
||||
(default: `"openai"`). Supported values: `"openai"` and `"anthropic"`.
|
||||
An optional `[models.*.capabilities]` sub-table overrides per-model
|
||||
`ModelCapabilities` flags (useful for local models whose capabilities
|
||||
cannot be detected programmatically):
|
||||
|
||||
```toml
|
||||
[models.qwen-vl]
|
||||
base_url = "http://localhost:8000/v1"
|
||||
model = "qwen-3.5-vl"
|
||||
|
||||
[models.qwen-vl.capabilities]
|
||||
supports_vision = true
|
||||
```
|
||||
|
||||
**Lifecycle:**
|
||||
1. `load_model_registry()` reads `[models.*]` sections from config.toml and
|
||||
@@ -623,8 +698,8 @@ Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
|
||||
sub-agents, allowing a cheaper model for autonomous loops
|
||||
|
||||
**Per-workstream selection:** `POST /v1/api/workstreams/new` accepts an optional
|
||||
`"model"` field. The bridge `CreateWorkstreamMessage` carries the same field
|
||||
through the MQ protocol.
|
||||
`"model"` field, along with `skill` (skill name)
|
||||
which can override the model before workstream creation.
|
||||
|
||||
### Tool Output Truncation
|
||||
|
||||
@@ -753,10 +828,16 @@ and are the single source of truth for both backends and Alembic migrations.
|
||||
backend = "sqlite" # "sqlite" | "postgresql"
|
||||
path = ".turnstone.db" # SQLite file path
|
||||
url = "" # PostgreSQL connection URL
|
||||
pool_size = 5 # PostgreSQL connection pool size
|
||||
pool_size = 2 # PostgreSQL connection pool size (per process)
|
||||
```
|
||||
|
||||
Environment variables: `TURNSTONE_DB_BACKEND`, `TURNSTONE_DB_URL`, `TURNSTONE_DB_PATH`.
|
||||
Environment variables: `TURNSTONE_DB_BACKEND`, `TURNSTONE_DB_URL`, `TURNSTONE_DB_PATH`,
|
||||
`TURNSTONE_DB_POOL_SIZE`.
|
||||
|
||||
The default pool is intentionally small (2 base + 3 overflow = 5 per process)
|
||||
because all database operations are short-burst queries that hold connections for
|
||||
milliseconds. For clusters with many nodes sharing a PostgreSQL instance, use
|
||||
[PgBouncer](pgbouncer.md) in transaction pooling mode.
|
||||
|
||||
### Persistence and Resume
|
||||
|
||||
@@ -878,6 +959,9 @@ warns if the summary was truncated.
|
||||
seeded with `history.replaceState({turnstone: 'dashboard'})` on load. The
|
||||
`popstate` listener restores the correct tab or shows the dashboard,
|
||||
guarded by `_historyNavigation = true` to prevent re-entrant pushState.
|
||||
- **Pane focus**: `mousedown` and `focusin` events on pane containers update
|
||||
`focusedPaneId`. Approval shortcuts (y/n/a) apply to the focused pane.
|
||||
`Ctrl+Alt+Arrow` cycles focus between panes.
|
||||
|
||||
### Eval Resilience
|
||||
|
||||
@@ -975,8 +1059,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** (Users and Tokens tabs) for managing
|
||||
credentials 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).
|
||||
@@ -1082,7 +1166,7 @@ context manager handles startup/shutdown (health monitor, MCP client,
|
||||
registry).
|
||||
|
||||
Each workstream's `WebUI` has:
|
||||
- `_event_queue` (per-workstream SSE events, `queue.Queue`)
|
||||
- `_listeners` (per-client SSE queues, fan-out on `_enqueue()`)
|
||||
- `_approval_event` / `_plan_event` (`threading.Event` for blocking)
|
||||
- `_global_queue` (class variable, shared, for state broadcasts)
|
||||
|
||||
@@ -1114,81 +1198,44 @@ calls `_fg_event.wait()`, which blocks the worker thread until the user
|
||||
switches to that workstream. The `_bg_attention_notify` callback writes a
|
||||
bell + status line to stderr to alert the user.
|
||||
|
||||
### Message Queue Bridge
|
||||
|
||||
```
|
||||
Main thread Global SSE thread Per-WS SSE threads (×N)
|
||||
+------------------+ +------------------+ +-------------------+
|
||||
| Inbound loop | | GET /events/glob | | GET /events?ws_id |
|
||||
| BLPOP on Redis | | Parse SSE via | | Parse SSE via |
|
||||
| | | httpx-sse | | httpx-sse |
|
||||
| Dispatch to | | Forward state | | Forward content, |
|
||||
| handler | | changes | | tool results |
|
||||
| POST to server | | Detect turn | | Handle approval |
|
||||
| Publish ACK | | completion | | forwarding |
|
||||
+------------------+ +------------------+ +-------------------+
|
||||
| | |
|
||||
+-- Redis inbound queue +-- Redis pub/sub +-- Redis pub/sub
|
||||
(RPUSH/BLPOP) (PUBLISH) (PUBLISH)
|
||||
+ response queue
|
||||
(BLPOP on
|
||||
approval)
|
||||
```
|
||||
|
||||
**Approval flow:** When a per-WS SSE thread receives an `approve_request`, it checks
|
||||
the workstream's `auto_approve_tools` set. If all requested tools are in the set, the
|
||||
bridge auto-approves via `POST /v1/api/approve`. Otherwise, it publishes an
|
||||
`ApprovalRequestEvent` to the outbound channel with a `request_id`, then blocks on
|
||||
`BLPOP` of a Redis response queue (`turnstone:resp:{request_id}`) until the client pushes
|
||||
a response or the approval timeout (default 3600s / 1 hour) expires.
|
||||
|
||||
**Completion detection:** The bridge tracks which `correlation_id` maps to which
|
||||
`ws_id` for active sends. When the global SSE reports `ws_state → idle` for a tracked
|
||||
workstream, the bridge emits a synthetic `TurnCompleteEvent` with the correlation ID.
|
||||
|
||||
**Multi-node routing:** Each bridge retrieves its `node_id` from the server's
|
||||
`/health` endpoint on startup (with exponential backoff retry). The server
|
||||
generates the `node_id` (`{hostname}_{4hex}`) and is the sole authority for
|
||||
node identity. The bridge BLPOPs
|
||||
from both `turnstone:inbound:{node_id}` (directed, priority) and `turnstone:inbound` (shared).
|
||||
Messages with `target_node` set are pushed to the target's per-node queue. Messages
|
||||
for existing workstreams are auto-routed via `turnstone:ws:{ws_id}` ownership keys in Redis.
|
||||
If a bridge picks up a shared-queue message for a workstream owned by another node, it
|
||||
re-routes to that node's queue (1 extra hop). Bridges publish heartbeats to
|
||||
`turnstone:node:{node_id}` with configurable TTL for node discovery.
|
||||
|
||||
### Cluster Console
|
||||
|
||||
```
|
||||
Monitoring (3 daemon threads) Control + Proxy (async Starlette)
|
||||
+------------------+ +----------------------------+
|
||||
| Event subscriber | | POST /v1/api/cluster/ |
|
||||
| SUBSCRIBE on | | workstreams/new |
|
||||
| events:cluster | | → LPUSH to Redis |
|
||||
+------------------+ | inbound:{node_id} |
|
||||
| Node discovery | +----------------------------+
|
||||
| SCAN node:* keys | | GET /node/{node_id}/ |
|
||||
| every 15 seconds | | → httpx.AsyncClient |
|
||||
+------------------+ | proxy to server_url |
|
||||
| Poll loop | | GET /node/{id}/v1/api/events |
|
||||
| GET /v1/api/dash | | → SSE stream proxy |
|
||||
| GET /health | | POST /node/{id}/v1/api/send |
|
||||
| ThreadPoolExec | | → forwarded to server |
|
||||
| SSE on | | workstreams/new |
|
||||
| /events/glob | | → POST to target server |
|
||||
+------------------+ +----------------------------+
|
||||
| Node discovery | | GET /node/{node_id}/ |
|
||||
| Service registry | | → httpx.AsyncClient |
|
||||
| every 15 seconds | | proxy to server_url |
|
||||
+------------------+ | GET /node/{id}/v1/api/events |
|
||||
| Poll loop | | → SSE stream proxy |
|
||||
| GET /v1/api/dash | | POST /node/{id}/v1/api/send |
|
||||
| GET /health | | → forwarded to server |
|
||||
| ThreadPoolExec | +----------------------------+
|
||||
+------------------+
|
||||
```
|
||||
|
||||
The console HTTP layer is a Starlette/ASGI app served by uvicorn. The SSE
|
||||
endpoint uses `EventSourceResponse` with the same listener queue pattern as
|
||||
the main server. `ClusterCollector`'s background threads (event subscriber,
|
||||
node discovery, poll loop) use sync Redis clients and `ThreadPoolExecutor`
|
||||
for parallel HTTP polling.
|
||||
node discovery, poll loop) use `ThreadPoolExecutor`
|
||||
for parallel HTTP polling. The poll loop diffs workstream IDs between poll
|
||||
cycles and fans out synthetic `ws_created`/`ws_closed` SSE events for any
|
||||
changes, ensuring browser clients stay in sync even when real-time cluster
|
||||
events are missed.
|
||||
|
||||
The console has two write-path capabilities:
|
||||
|
||||
1. **Workstream creation** — pushes `CreateWorkstreamMessage` to Redis inbound
|
||||
queues targeting specific nodes. The bridge on each node picks up the message
|
||||
and creates the workstream on the local server. Auto-selects the node with
|
||||
the most available capacity if no target is specified.
|
||||
1. **Workstream creation** — sends HTTP requests to target server nodes
|
||||
to create workstreams. Auto-selects the node with
|
||||
the most available capacity if no target is specified. When a `skill`
|
||||
field is present, the server resolves the skill BEFORE `mgr.create()`
|
||||
(applying the model override to the creation request) and snapshot-applies
|
||||
remaining settings (auto-approve, token budget, temperature, etc.) to the
|
||||
workstream config AFTER creation.
|
||||
|
||||
2. **Reverse proxy** — serves each node's server UI through the console port at
|
||||
`/node/{node_id}/`. Uses `httpx.AsyncClient` to proxy HTTP and SSE traffic.
|
||||
@@ -1250,7 +1297,7 @@ event loop on a daemon thread.
|
||||
|
||||
**Event types**: 27 standalone dataclasses in `events.py` with a type-registry
|
||||
pattern matching `OutboundEvent.from_json()` from `mq/protocol.py`. Events are
|
||||
decoupled from the MQ package so SDK consumers don't need the `redis` dependency.
|
||||
decoupled from server internals.
|
||||
|
||||
**TypeScript SDK**: `sdk/typescript/` — separate npm package with the same API
|
||||
surface. Zero browser dependencies, SSE via `fetch` + `ReadableStream` parsing.
|
||||
@@ -1271,20 +1318,19 @@ with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
|
||||
> See also: [Channel Integrations guide](channels.md)
|
||||
|
||||
The `turnstone-channel` gateway bridges external messaging platforms
|
||||
(Discord, Slack, Teams) to the turnstone cluster via Redis MQ. Each
|
||||
The `turnstone-channel` gateway connects external messaging platforms
|
||||
(Discord, Slack, Teams) to the turnstone cluster via HTTP. Each
|
||||
platform adapter implements the `ChannelAdapter` protocol and translates
|
||||
between platform-native events and turnstone MQ messages.
|
||||
between platform-native events and turnstone server API calls.
|
||||
|
||||
The `ChannelRouter` manages bidirectional routing: it maps platform
|
||||
channel/thread IDs to turnstone workstream IDs, handles workstream
|
||||
creation and stale-route recovery, and resolves platform users to
|
||||
turnstone identities via the `channel_users` table. When an evicted
|
||||
workstream is reactivated, the router uses atomic resume via the
|
||||
`resume_ws` field on `CreateWorkstreamMessage` — the server resumes
|
||||
`resume_ws` field on the workstream creation request — the server resumes
|
||||
the old workstream's conversation during creation in a single HTTP
|
||||
request, eliminating ordering fragility. The bridge emits a
|
||||
`WorkstreamResumedEvent` to confirm success.
|
||||
request, eliminating ordering fragility.
|
||||
|
||||
Discord ships as the first adapter. See [channels.md](channels.md) for
|
||||
setup instructions, configuration reference, and the adapter development
|
||||
@@ -1293,13 +1339,91 @@ guide.
|
||||
### Notification Subsystem
|
||||
|
||||
The `notify` tool enables the LLM to send notifications to users or
|
||||
channels without going through MQ. The server calls the channel gateway
|
||||
channels directly. The server calls the channel gateway
|
||||
directly over HTTP for lower latency: `_exec_notify()` queries the
|
||||
`services` database table for healthy channel gateways (heartbeat within
|
||||
120 seconds), authenticates with a service JWT (`aud: turnstone-channel`),
|
||||
and POSTs to `POST /v1/api/notify` on the first healthy gateway. The
|
||||
gateway validates the JWT, resolves the target (username lookup via
|
||||
payload includes the originating `ws_id` for reply routing. The gateway
|
||||
validates the JWT, resolves the target (username lookup via
|
||||
`channel_users` or direct `channel_type`+`channel_id`), and delegates to
|
||||
the appropriate `ChannelAdapter.send()`. Delivery retries up to 3 times
|
||||
with backoff, re-querying the service registry on each attempt. See
|
||||
[Notification Flow diagram](diagrams/png/17-notify-flow.png).
|
||||
`ChannelAdapter.send_notification()` which sends the message and tracks
|
||||
the outgoing message ID → `(ws_id, target_user_id)` mapping. Delivery
|
||||
retries up to 3 times with backoff, re-querying the service registry on
|
||||
each attempt. See [Notification Flow diagram](diagrams/png/17-notify-flow.png).
|
||||
|
||||
**Bidirectional replies:** When a user replies to a notification DM, the
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Governance
|
||||
|
||||
> See also: [Governance documentation](governance.md) | [Governance Architecture diagram](diagrams/19-governance-architecture.puml)
|
||||
|
||||
Turnstone governance extends the Phase 1 auth system with role-based access
|
||||
control (RBAC), tool execution policies, skills, usage tracking,
|
||||
and audit logging. The permission model has two layers: legacy scopes
|
||||
(`read`, `write`, `approve`) checked by `AuthMiddleware`, and 15 granular
|
||||
permissions checked per-endpoint by `require_permission()`. Three built-in
|
||||
roles (admin, operator, viewer) are seeded by migration 008; custom roles
|
||||
can be created with any permission subset. JWTs carry both `scopes` and
|
||||
`permissions` claims for backward compatibility.
|
||||
|
||||
Tool policies use glob pattern matching (`fnmatch`) with priority-ordered
|
||||
first-match-wins evaluation to control tool execution (allow/deny/ask).
|
||||
Skills provide reusable system messages with `{{variable}}` substitution
|
||||
plus session configuration (model, temperature, auto-approve, token budget,
|
||||
etc.). Usage events are recorded per-LLM-request for token accounting.
|
||||
An append-only audit log captures all admin mutations.
|
||||
|
||||
Skills are snapshot-applied once at workstream creation — not a live binding.
|
||||
The `prompt_templates` table (which stores skills) supports auto-versioning,
|
||||
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 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.
|
||||
|
||||
## Intent Validation
|
||||
|
||||
> See also: [Intent Validation guide](judge.md) | [Judge Architecture diagram](diagrams/png/22-judge-architecture.png)
|
||||
|
||||
Intent validation provides advisory risk assessments for tool calls that
|
||||
require human approval. The system runs a two-tier evaluation pipeline
|
||||
implemented in `turnstone/core/judge.py`:
|
||||
|
||||
1. **Heuristic tier** (synchronous, sub-millisecond) -- A priority-ordered
|
||||
rule table using fnmatch tool patterns and regex argument patterns. Four
|
||||
severity levels: critical (deny), high (review), medium (review), low
|
||||
(approve). First match wins. The heuristic verdict is attached to the
|
||||
`approve_request` SSE event immediately.
|
||||
|
||||
2. **LLM judge tier** (asynchronous, daemon thread) -- A multi-turn evaluation
|
||||
where the judge LLM receives conversation context and tool call details,
|
||||
optionally uses `read_file`/`list_directory` to gather evidence (with
|
||||
security-hardened path blocking), and produces a structured JSON verdict.
|
||||
If the LLM verdict has higher confidence than the heuristic, it replaces
|
||||
it via an `intent_verdict` SSE event.
|
||||
|
||||
The judge is session-scoped (`IntentJudge`), lazy-initialized on first
|
||||
approval, and configured via the `[judge]` config section or `--judge` CLI
|
||||
flags. By default it uses self-consistency (same model), but supports
|
||||
cross-model and cross-provider configurations. Sub-agents (plan, task)
|
||||
are exempt. All verdicts are persisted to the `intent_verdicts` table
|
||||
(migration 012) with the user's final decision, enabling future calibration.
|
||||
The console exposes `GET /v1/api/admin/verdicts` for audit queries
|
||||
(requires `admin.judge` permission).
|
||||
|
||||
+58
-29
@@ -1,9 +1,10 @@
|
||||
# Channel Integrations
|
||||
|
||||
The `turnstone-channel` gateway connects external messaging platforms to
|
||||
turnstone workstreams via Redis MQ. Each platform adapter translates
|
||||
turnstone workstreams via direct HTTP to the server (single-node) or the
|
||||
console routing proxy (multi-node). Each platform adapter translates
|
||||
platform-native events (messages, button clicks, slash commands) into
|
||||
turnstone MQ messages, and renders workstream output back into the
|
||||
turnstone API calls, and renders workstream output back into the
|
||||
platform's UI.
|
||||
|
||||
Discord ships as the first adapter. The adapter protocol is designed for
|
||||
@@ -20,24 +21,20 @@ Discord Gateway
|
||||
turnstone-channel (Discord adapter)
|
||||
|
|
||||
v
|
||||
Redis MQ
|
||||
|
|
||||
v
|
||||
turnstone-bridge ──> turnstone-server
|
||||
turnstone-server (direct HTTP)
|
||||
or
|
||||
turnstone-console (routing proxy, multi-node)
|
||||
```
|
||||
|
||||
Key components:
|
||||
|
||||
- **ChannelAdapter protocol** (`turnstone/channels/_protocol.py`) — generic
|
||||
interface for any messaging platform. Defines `start()`, `stop()`,
|
||||
`send()`, `edit_message()`, `send_approval_request()`,
|
||||
`send_plan_review()`, and `create_thread()`.
|
||||
`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 MQ, stale route detection, and user identity resolution.
|
||||
- **AsyncRedisBroker** (`turnstone/mq/async_broker.py`) — async Redis
|
||||
client compatible with discord.py's event loop. Used by the router for
|
||||
pub/sub and queue operations.
|
||||
creation via HTTP, stale route detection, and user identity resolution.
|
||||
- **channel_users table** — maps `(channel_type, channel_user_id)` to a
|
||||
turnstone `user_id`. Messages from unlinked users are silently dropped.
|
||||
- **channel_routes table** — persistent channel-to-workstream mappings.
|
||||
@@ -84,8 +81,7 @@ TURNSTONE_DISCORD_GUILD=123456789 # optional, restrict to one guild
|
||||
turnstone-channel \
|
||||
--discord-token "your-bot-token" \
|
||||
--discord-guild 123456789 \
|
||||
--redis-host localhost \
|
||||
--redis-port 6379
|
||||
--server-url http://localhost:8080
|
||||
```
|
||||
|
||||
**Docker Compose** (production profile):
|
||||
@@ -138,7 +134,7 @@ An admin can also force-link or unlink users via the console admin panel
|
||||
thread auto-creates a new workstream and atomically resumes the
|
||||
previous workstream via the `resume_ws` field on
|
||||
`CreateWorkstreamMessage`. The server resumes the workstream during
|
||||
creation (same HTTP request), and the bridge emits a
|
||||
creation (same HTTP request), and the server emits a
|
||||
`WorkstreamResumedEvent` back to the channel. The thread receives a
|
||||
*"Resumed: {name} ({count} messages restored)"* confirmation.
|
||||
|
||||
@@ -160,8 +156,7 @@ an orange embed with:
|
||||
- Tool name and argument preview
|
||||
- **Approve** (green), **Reject** (red), **Always Approve** (gray) buttons
|
||||
- Only linked users can interact with approval buttons
|
||||
- The approval decision is forwarded through MQ to the bridge, which
|
||||
relays it to the server
|
||||
- The approval decision is forwarded to the server via HTTP
|
||||
|
||||
Buttons use static `custom_id` values so they survive bot restarts.
|
||||
Correlation data (`ws_id`, `correlation_id`) is stored in the embed footer.
|
||||
@@ -181,7 +176,7 @@ Plan review requests are displayed as a blue embed with:
|
||||
- **Approve Plan** (green) button — approves the plan with empty feedback
|
||||
- **Request Changes** (gray) button — opens a modal for feedback text
|
||||
(up to 2000 characters)
|
||||
- Feedback is forwarded through MQ as a `PlanFeedbackMessage`
|
||||
- Feedback is forwarded to the server via HTTP
|
||||
|
||||
---
|
||||
|
||||
@@ -192,10 +187,8 @@ Plan review requests are displayed as a blue embed with:
|
||||
| `--discord-token` | `TURNSTONE_DISCORD_TOKEN` | — | Bot token (required to enable Discord) |
|
||||
| `--discord-guild` | — | `0` (all guilds) | Restrict to a single Discord guild |
|
||||
| `--discord-channels` | — | empty (all) | Comma-separated channel IDs to allow |
|
||||
| `--redis-host` | `REDIS_HOST` | `localhost` | Redis host |
|
||||
| `--redis-port` | — | `6379` | Redis port |
|
||||
| `--redis-password` | `REDIS_PASSWORD` | — | Redis password |
|
||||
| `--redis-db` | — | `0` | Redis DB number |
|
||||
| `--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 |
|
||||
| `--auto-approve` | — | `false` | Auto-approve ALL tool calls (skips approval buttons entirely) |
|
||||
| `--http-host` | — | `127.0.0.1` | HTTP server bind address for notify endpoint |
|
||||
@@ -232,13 +225,13 @@ See [Security: Database Schema](security.md#database-schema) for the
|
||||
3. **Eviction** — the server evicts an idle workstream for capacity. The
|
||||
route is preserved and the thread stays open.
|
||||
4. **Reactivation** — the next message in the thread detects the stale
|
||||
route (no MQ owner) and creates a new workstream with the old `ws_id`
|
||||
as `resume_ws` on the `CreateWorkstreamMessage`. The server resumes
|
||||
route and creates a new workstream with the old `ws_id`
|
||||
as `resume_ws` on the creation request. The server resumes
|
||||
the workstream during creation (no separate command or reverse lookup
|
||||
needed). The bridge emits a `WorkstreamResumedEvent` to the channel, and
|
||||
needed). The channel receives a `WorkstreamResumedEvent`, and
|
||||
the thread displays *"Resumed: {name} ({count} messages restored)"*.
|
||||
If the old workstream was pruned, a fresh one starts with no error.
|
||||
5. **Close** — `/close` command closes the workstream via MQ, deletes the
|
||||
5. **Close** — `/close` command closes the workstream via HTTP, deletes the
|
||||
route, unsubscribes from events, and archives the Discord thread.
|
||||
|
||||
---
|
||||
@@ -264,20 +257,50 @@ Two modes:
|
||||
|
||||
### Delivery Flow
|
||||
|
||||
Notifications bypass MQ for lower latency. The server calls the channel
|
||||
Notifications use direct HTTP for low latency. The server calls the channel
|
||||
gateway directly over HTTP:
|
||||
|
||||
1. The LLM calls the `notify` tool with a message and target
|
||||
2. `_exec_notify()` queries the `services` table for healthy channel
|
||||
gateways (heartbeat within the last 120 seconds)
|
||||
3. The server mints a service JWT (`aud: turnstone-channel`) via
|
||||
`ServiceTokenManager` and POSTs to the first healthy gateway
|
||||
`ServiceTokenManager` and POSTs to the first healthy gateway. The
|
||||
payload includes the originating `ws_id` for reply routing.
|
||||
4. The gateway validates the JWT, resolves the target, and calls
|
||||
`adapter.send()` on the appropriate platform adapter
|
||||
`adapter.send_notification()` which sends the message and tracks
|
||||
the outgoing message ID for reply routing
|
||||
5. On failure, the server tries the next gateway. If all fail, it
|
||||
retries up to 2 more times (delays: 1s, 3s), re-querying the
|
||||
service registry on each attempt
|
||||
|
||||
### Bidirectional Replies
|
||||
|
||||
Notifications support multi-turn DM conversations. When a user replies
|
||||
to a notification DM:
|
||||
|
||||
1. The bot looks up the originating `ws_id` from the tracked message ID
|
||||
(`_notify_ws_map`)
|
||||
2. Verifies the replying user matches the original notification
|
||||
recipient (defence in depth — Discord DMs are already private)
|
||||
3. Routes the reply to the workstream via `router.send_message()`
|
||||
4. Registers the DM channel for response forwarding
|
||||
(`_notify_reply_channels`)
|
||||
5. When the workstream responds (`TurnCompleteEvent`), the response is
|
||||
forwarded to the DM
|
||||
6. The response message is itself tracked, so the user can reply again
|
||||
for another turn
|
||||
|
||||
This enables scenarios like an oncall engineer responding to a CI/CD
|
||||
failure notification from their phone before opening a laptop.
|
||||
|
||||
**Limits:**
|
||||
|
||||
- Tracking map capped at 100 entries (FIFO eviction of oldest)
|
||||
- Entries cleaned up on workstream close/unsubscribe
|
||||
- Replying to an expired notification sends
|
||||
*"This notification is no longer active."*
|
||||
- DM reply content capped at 4096 characters
|
||||
|
||||
### Service Registry
|
||||
|
||||
The channel gateway registers itself in the `services` database table
|
||||
@@ -328,12 +351,18 @@ class ChannelAdapter(Protocol):
|
||||
async def start(self) -> None: ...
|
||||
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
|
||||
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.
|
||||
|
||||
To add a new platform:
|
||||
|
||||
1. Create `turnstone/channels/<platform>/` package
|
||||
|
||||
+87
-61
@@ -1,16 +1,16 @@
|
||||
# Cluster Dashboard (turnstone-console)
|
||||
|
||||
`turnstone-console` is a cluster management service that provides cluster-wide visibility and control across all turnstone nodes. It connects to the shared Redis broker, discovers nodes via heartbeat keys, polls each node's HTTP API for workstream data, and subscribes to a cluster event channel for real-time state changes.
|
||||
`turnstone-console` is a cluster management service that provides cluster-wide visibility and control across all turnstone nodes. It discovers nodes via the `services` database table, polls each node's HTTP API for workstream data, and receives real-time state changes via HTTP polling.
|
||||
|
||||
The console also supports **workstream creation** (dispatched via MQ to target nodes) and a **reverse proxy** that serves each node's server UI through the console port — so users only need network access to the console, not to individual server nodes.
|
||||
The console also supports **workstream creation** (dispatched via HTTP proxy to target nodes) and a **reverse proxy** that serves each node's server UI through the console port — so users only need network access to the console, not to individual server nodes.
|
||||
|
||||
## Architecture
|
||||
|
||||
> See also: [Console Data Flow diagram](diagrams/png/11-console-data-flow.png)
|
||||
|
||||
```
|
||||
┌── Redis ←── turnstone-bridge ←── turnstone-server
|
||||
│ (MQ) (per node) (per node)
|
||||
┌── services table ── turnstone-server
|
||||
│ (node registry) (per node)
|
||||
turnstone-console ──────┤
|
||||
(one instance) │
|
||||
└── turnstone-server (direct HTTP proxy)
|
||||
@@ -21,45 +21,31 @@ turnstone-console ──────┤
|
||||
|
||||
Data flows in two directions:
|
||||
|
||||
- **Inbound (monitoring):** Bridges publish state changes to `{prefix}:events:cluster` on Redis pub/sub. The console subscribes for real-time updates and periodically polls each node's `GET /v1/api/dashboard` for full workstream snapshots.
|
||||
- **Outbound (control):** The console pushes `CreateWorkstreamMessage` to Redis inbound queues targeting specific nodes. Bridges pick up these messages and create workstreams on their local servers.
|
||||
- **Inbound (monitoring):** The console discovers nodes via the `services` database table (nodes register on startup and send periodic heartbeats). It periodically polls each node's `GET /v1/api/dashboard` for full workstream snapshots and `GET /health` for node health.
|
||||
- **Outbound (control):** The console proxies workstream creation requests to target nodes via HTTP.
|
||||
- **Proxy (pass-through):** The console reverse-proxies each node's server UI at `/node/{node_id}/`, forwarding HTTP and SSE traffic so the browser never contacts server nodes directly.
|
||||
|
||||
### Data Sources
|
||||
|
||||
| Source | Method | Direction | Data |
|
||||
|--------|--------|-----------|------|
|
||||
| Redis heartbeats | `SCAN turnstone:node:*` | Read | Node discovery (node_id, server_url, started) |
|
||||
| Redis pub/sub | `SUBSCRIBE turnstone:events:cluster` | Read | State changes, creates, closes, renames |
|
||||
| `services` table | Database query | Read | Node discovery (node_id, server_url, started) |
|
||||
| Node HTTP API | `GET {server_url}/v1/api/dashboard` | Read | Full workstream list with tokens, context, activity |
|
||||
| Node HTTP API | `GET {server_url}/health` | Read | Node health status |
|
||||
| Redis inbound queue | `RPUSH turnstone:inbound:{node_id}` | Write | Workstream creation commands |
|
||||
| Node HTTP API | `POST {server_url}/v1/api/workstreams/new` | Write | Workstream creation |
|
||||
| Node HTTP API | `GET/POST {server_url}/*` | Proxy | Server UI, API requests, SSE streams |
|
||||
|
||||
### Redis Key: Cluster Event Channel
|
||||
|
||||
Bridges publish to `{prefix}:events:cluster` whenever a workstream state change, creation, closure, or rename occurs. Events include `node_id` so the console can attribute them to the correct node.
|
||||
|
||||
Event types on the cluster channel:
|
||||
|
||||
| Event | Fields | Trigger |
|
||||
|-------|--------|---------|
|
||||
| `cluster_state` | ws_id, state, node_id, tokens, context_ratio, activity | Workstream state transition |
|
||||
| `ws_created` | ws_id, name, node_id | New workstream created |
|
||||
| `ws_closed` | ws_id | Workstream closed |
|
||||
| `ws_rename` | ws_id, name | Workstream renamed |
|
||||
|
||||
---
|
||||
|
||||
## ClusterCollector
|
||||
|
||||
The collector (`turnstone/console/collector.py`) maintains an in-memory snapshot of all nodes and workstreams. Three daemon threads handle data acquisition:
|
||||
The collector (`turnstone/console/collector.py`) maintains an in-memory snapshot of all nodes and workstreams. Two daemon threads handle data acquisition:
|
||||
|
||||
1. **Event subscriber** — subscribes to `{prefix}:events:cluster` via `RedisBroker.subscribe_cluster()`. Applies state changes, creates, closes, and renames to the in-memory model immediately.
|
||||
1. **Node discovery** — queries the `services` database table every 15 seconds. Adds newly discovered nodes, removes expired ones (stale heartbeats), emits `node_joined` / `node_lost` events to SSE listeners.
|
||||
|
||||
2. **Node discovery** — scans heartbeat keys every 15 seconds via `broker.list_nodes()`. Adds newly discovered nodes, removes expired ones, emits `node_joined` / `node_lost` events to SSE listeners.
|
||||
2. **Poll loop** — fetches `GET /v1/api/dashboard` and `GET /health` from each known node every 10 seconds. Uses `ThreadPoolExecutor(max_workers=50)` for parallelism. Each poll replaces the node's workstream list with the authoritative server data.
|
||||
|
||||
3. **Poll loop** — fetches `GET /v1/api/dashboard` and `GET /health` from each known node every 10 seconds. Uses `ThreadPoolExecutor(max_workers=50)` for parallelism. Each poll replaces the node's workstream list with the authoritative server data.
|
||||
A `get_snapshot()` method builds the full cluster state under a single lock acquisition — overview aggregates and per-node workstream lists in one atomic read. This is served both as a REST endpoint and as the initial SSE event on client connect.
|
||||
|
||||
### Thread Safety
|
||||
|
||||
@@ -67,10 +53,11 @@ All reads and writes to the node/workstream map are protected by a single `threa
|
||||
|
||||
### Scale Considerations
|
||||
|
||||
- **10,000 workstreams** at ~500 bytes each = ~5 MB in memory
|
||||
- **1,000 nodes** polled in parallel with 50 threads at ~100ms each = ~2 second poll cycle
|
||||
- **50,000 workstreams** (1,000 nodes × 50 per node) at ~500 bytes each = ~25 MB in memory
|
||||
- **1,000 nodes** polled in parallel — fan-out concurrency is configurable via `cluster.node_fan_out_limit` (default 200), yielding 5 batches at ~100ms each = ~0.5 second poll cycle
|
||||
- **Filtering and pagination** run in-memory on the full workstream list — sub-millisecond at this scale
|
||||
- **SSE fan-out** uses the same per-client queue pattern as the per-node server — backed-up clients get events dropped, not blocking
|
||||
- **SSE fan-out** uses per-client queues (2,000 events) — backed-up clients get events dropped, not blocking
|
||||
- **Database** — for clusters sharing PostgreSQL, use [PgBouncer](pgbouncer.md) in transaction pooling mode
|
||||
|
||||
---
|
||||
|
||||
@@ -146,9 +133,41 @@ Single node detail with all its workstreams.
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /v1/api/cluster/snapshot`
|
||||
|
||||
Full cluster state in a single response — all nodes with their workstreams plus overview aggregates. Built under a single lock for internal consistency. Used by the browser on initial load and SSE reconnect.
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": "db-west-04",
|
||||
"server_url": "http://10.0.3.4:8080",
|
||||
"max_ws": 10,
|
||||
"reachable": true,
|
||||
"version": "0.3.0",
|
||||
"health": {"status": "ok", "version": "0.3.0"},
|
||||
"aggregate": {"total_tokens": 48200, "total_tool_calls": 156},
|
||||
"workstreams": [
|
||||
{"id": "a1b2c3d4", "name": "perf-db-west", "state": "running", ...}
|
||||
]
|
||||
}
|
||||
],
|
||||
"overview": {
|
||||
"nodes": 847,
|
||||
"workstreams": 4219,
|
||||
"states": {"running": 1847, "thinking": 312, "attention": 89, "idle": 1940, "error": 31},
|
||||
"aggregate": {"total_tokens": 12400000, "total_tool_calls": 34200},
|
||||
"version_drift": false,
|
||||
"versions": ["0.3.0"]
|
||||
},
|
||||
"timestamp": 1709294400.0
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /v1/api/cluster/workstreams/new`
|
||||
|
||||
Create a new workstream on a target node. Dispatches a `CreateWorkstreamMessage` through the Redis MQ pipeline — the bridge on the target node picks it up and creates the workstream on the server. Requires `write` scope.
|
||||
Create a new workstream on a target node. The console proxies the creation request to the target node's HTTP API. Requires `write` scope.
|
||||
|
||||
Request:
|
||||
|
||||
@@ -162,9 +181,9 @@ Request:
|
||||
|
||||
All fields are optional:
|
||||
- `node_id` — targeting mode:
|
||||
- **omitted or `"auto"`** — console picks the reachable node with the most available capacity (max_ws - ws_total) and pushes to its directed queue.
|
||||
- **`"pool"`** — pushes to the shared inbound queue; the next available bridge picks it up (true general-pool dispatch).
|
||||
- **specific node ID** — pushes to that node's directed queue.
|
||||
- **omitted or `"auto"`** — console picks the reachable node with the most available capacity (max_ws - ws_total) and proxies the request to it.
|
||||
- **`"pool"`** — console picks a reachable node with available capacity using round-robin selection.
|
||||
- **specific node ID** — proxies the request to that node directly.
|
||||
- `name` — workstream display name. Auto-generated if omitted.
|
||||
- `model` — model alias from the target node's registry. Uses the node's default model if omitted.
|
||||
|
||||
@@ -178,11 +197,11 @@ Response:
|
||||
}
|
||||
```
|
||||
|
||||
Creation is asynchronous — the response confirms the MQ message was dispatched. A `ws_created` event on the cluster SSE stream confirms the workstream was actually created.
|
||||
The response confirms the workstream creation request was proxied to the target node. A `ws_created` event on the cluster SSE stream confirms the workstream was actually created.
|
||||
|
||||
### `GET /v1/api/cluster/events`
|
||||
|
||||
Server-Sent Events stream for real-time cluster updates.
|
||||
Server-Sent Events stream for real-time cluster updates. The first event is always a `snapshot` containing the full cluster state (same shape as `GET /v1/api/cluster/snapshot` with an added `type: "snapshot"` field), followed by incremental events:
|
||||
|
||||
```
|
||||
data: {"type":"cluster_state","ws_id":"a1b2","node_id":"db-west-04","state":"running"}
|
||||
@@ -326,11 +345,11 @@ The server UI uses root-relative URLs (`/v1/api/send`, `/static/app.js`, `/share
|
||||
|
||||
### SSE Proxy
|
||||
|
||||
SSE streams (`/v1/api/events`, `/v1/api/events/global`) are proxied by creating a per-connection `httpx.AsyncClient(timeout=None)`, streaming the upstream response via `aiter_text()`, parsing SSE framing (`\n\n` delimiters), and re-emitting events through `EventSourceResponse`. Each proxied SSE stream requires its own httpx client since the shared client's 30-second timeout would kill long-lived connections.
|
||||
SSE streams (`/v1/api/events`, `/v1/api/events/global`) are proxied as raw byte passthrough — the console opens an `httpx.AsyncClient.stream()` to the upstream server (with `read=None` and `pool=None` timeouts since SSE connections are long-lived) and relays every byte via `StreamingResponse`. This preserves server-side ping comments, event framing, and keepalives verbatim without parsing or re-encoding.
|
||||
|
||||
### Authentication
|
||||
|
||||
The proxy forwards the user's JWT to upstream server nodes — it extracts the token from the incoming request's cookie (or `Authorization` header) and adds it as a `Bearer` header on the proxied request. Since all services share the same `TURNSTONE_JWT_SECRET`, the user's JWT is valid on every node without re-authentication. The console's own auth middleware also checks proxy routes — `POST` requests to proxy write endpoints (`/v1/api/send`, `/v1/api/approve`, etc.) require `write` scope, preventing read-only tokens from escalating via proxy. The static `--auth-token` / `proxy_auth_token` is used as a fallback when no user JWT is present.
|
||||
The proxy mints a short-lived (5-minute) JWT per request carrying the real user's `user_id`, `scopes`, and `permissions` with `aud: turnstone-server`. The user's console JWT (`aud: turnstone-console`) cannot be forwarded directly — it would be rejected by the server's audience validation — so the console re-signs a new server-audience JWT from the validated `AuthResult`. This preserves audit attribution (the upstream server sees the real user, not a service identity) and enforces scope narrowing as defense in depth (a read-only console user's proxied request carries only `read` scope). The JWT `src` claim is set to `"console-proxy"` for audit traceability. When no user context is available (auth disabled), the proxy falls back to a `ServiceTokenManager` with service identity `console-proxy`. The static `--auth-token` / `proxy_auth_token` is used as a final fallback.
|
||||
|
||||
---
|
||||
|
||||
@@ -360,7 +379,8 @@ Breadcrumb: `Cluster > Running` or `Cluster > db-west-04`. Server-side paginated
|
||||
|
||||
Triggered by the "+ new" header button. A modal dialog with:
|
||||
|
||||
- **Node selector** — dropdown with three targeting modes: "Auto (best available)" picks the node with the most headroom, "General pool (any node)" pushes to the shared queue for any bridge to pick up, or a specific node from the list (showing capacity).
|
||||
- **Node selector** — dropdown with three targeting modes: "Auto (best available)" picks the node with the most headroom, "General pool (any node)" picks a node with available capacity using round-robin, or a specific node from the list (showing capacity).
|
||||
- **Profile** — optional dropdown listing enabled skills. Applies the skill's model, auto-approve policy, token budget, and other behavioral settings at creation time.
|
||||
- **Name** — optional text input. Auto-generated if left empty.
|
||||
- **Model** — optional text input for a model alias from the target node's registry.
|
||||
|
||||
@@ -368,11 +388,16 @@ On submit, `POST /v1/api/cluster/workstreams/new` dispatches the creation reques
|
||||
|
||||
All five views receive live updates via SSE — state cards update counts, node rows update metrics, workstream rows update state indicators.
|
||||
|
||||
The browser maintains a local `clusterState` object that mirrors the cluster snapshot. It is initialized from the SSE `snapshot` event on connect (or via `GET /v1/api/cluster/snapshot` on initial page load) and updated incrementally by SSE events. View navigation reads from local state — no API round-trips needed after the initial snapshot.
|
||||
|
||||
### 5. Admin Panel
|
||||
|
||||
Accessed via the "admin" button in the header (visible when authenticated
|
||||
with `approve` scope). Provides user, API token, and channel link management
|
||||
with three tabs:
|
||||
with `approve` scope). Provides user, API token, channel link, MCP server,
|
||||
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:**
|
||||
|
||||
@@ -408,6 +433,22 @@ with three tabs:
|
||||
- Admins can force-link users who have not self-linked via `/link` in
|
||||
Discord
|
||||
|
||||
**MCP Servers tab:**
|
||||
|
||||
The tab has two views toggled via a pill control: **Servers** and
|
||||
**Registry**.
|
||||
|
||||
- **Servers view** -- lists all installed MCP servers with source badges
|
||||
(CONFIG, MANUAL, REGISTRY), transport badges, tool/resource/prompt
|
||||
counts, per-node connection status, and CRUD actions for DB-managed
|
||||
servers
|
||||
- **Registry view** -- search the official MCP Registry to discover and
|
||||
install servers. Results show server name, description, version, source
|
||||
type badges (remote/npm/pypi), and Install/Installed/Update buttons.
|
||||
Remote servers without required configuration are installed with one
|
||||
click; servers needing env vars, headers, or URL variables open an
|
||||
install modal for configuration
|
||||
|
||||
**Accessibility:**
|
||||
|
||||
- Full keyboard navigation: focus traps in modals, Escape to close, arrow
|
||||
@@ -425,17 +466,17 @@ to create the initial admin user and receive a JWT in one step. See
|
||||
|
||||
## Scheduled Tasks
|
||||
|
||||
The console includes a background **TaskScheduler** daemon that creates workstreams on a timed basis via the MQ broker. It supports cron-based recurring schedules and one-shot `at` schedules.
|
||||
The console includes a background **TaskScheduler** daemon that creates workstreams on a timed basis via HTTP proxy to target nodes. It supports cron-based recurring schedules and one-shot `at` schedules.
|
||||
|
||||
### Architecture
|
||||
|
||||
The scheduler runs as a daemon thread inside the console process. Every `check_interval` seconds (default 15) it:
|
||||
|
||||
1. Acquires a distributed lock via Redis `SET NX EX` (prevents duplicate dispatch in multi-console deployments)
|
||||
1. Acquires a distributed lock via the `system_settings` table (prevents duplicate dispatch in multi-console deployments)
|
||||
2. Queries the storage backend for tasks whose `next_run <= now` and `enabled = true`
|
||||
3. Dispatches each due task as one or more `CreateWorkstreamMessage` via MQ
|
||||
3. Dispatches each due task as one or more workstream creation requests via HTTP proxy
|
||||
4. Updates `last_run` and computes the next `next_run` (or disables one-shot `at` tasks)
|
||||
5. Releases the lock via Lua script (safe conditional delete)
|
||||
5. Releases the lock
|
||||
|
||||
Run history is automatically pruned (runs older than 90 days) approximately once per hour.
|
||||
|
||||
@@ -451,7 +492,7 @@ Run history is automatically pruned (runs older than 90 days) approximately once
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `auto` | Picks the reachable node with the most available capacity |
|
||||
| `pool` | Pushes to the shared inbound queue (any bridge picks it up) |
|
||||
| `pool` | Picks a reachable node with available capacity using round-robin |
|
||||
| `all` | Fan-out to all reachable nodes (capped at `max_fan_out`, default 20) |
|
||||
| `<node_id>` | Targets a specific node by ID |
|
||||
|
||||
@@ -588,10 +629,6 @@ CLI flags for `turnstone-console`:
|
||||
|------|---------|-------------|
|
||||
| `--host` | `0.0.0.0` | Bind host |
|
||||
| `--port` | `8090` | HTTP port |
|
||||
| `--redis-host` | `localhost` | Redis host |
|
||||
| `--redis-port` | `6379` | Redis port |
|
||||
| `--redis-password` | `$REDIS_PASSWORD` | Redis password |
|
||||
| `--redis-db` | `0` | Redis DB |
|
||||
| `--poll-interval` | `10` | Node polling interval (seconds) |
|
||||
| `--auth-token` | `$TURNSTONE_AUTH_TOKEN` | Bearer token for server node communication and proxy |
|
||||
| `--log-level` | `INFO` | Log level |
|
||||
@@ -604,11 +641,6 @@ host = "0.0.0.0"
|
||||
port = 8090
|
||||
url = "http://localhost:8090" # used by CLI /cluster commands
|
||||
poll_interval = 10
|
||||
|
||||
[redis]
|
||||
host = "localhost"
|
||||
port = 6379
|
||||
password = "my-redis-password"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -616,17 +648,11 @@ password = "my-redis-password"
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
# Start Redis
|
||||
redis-server
|
||||
|
||||
# Start turnstone servers (one per node)
|
||||
turnstone-server --port 8080
|
||||
|
||||
# Start bridges (one per server)
|
||||
turnstone-bridge --server-url http://localhost:8080 --node-id node-a
|
||||
|
||||
# Start cluster console (one instance)
|
||||
turnstone-console --redis-host localhost --port 8090 --auth-token "$TURNSTONE_AUTH_TOKEN"
|
||||
turnstone-console --port 8090 --auth-token "$TURNSTONE_AUTH_TOKEN"
|
||||
```
|
||||
|
||||
Open `http://localhost:8090` for the cluster dashboard. Create workstreams via the "+ new" button. Click any workstream to open the proxied server UI — no direct access to server ports required.
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Consistent Hash Ring — Reference Design
|
||||
|
||||
**Status**: Reference (not currently in the hot path)
|
||||
**Date**: 2026-03-30
|
||||
|
||||
## Overview
|
||||
|
||||
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 to consider the ring approach
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
### Hash function: FNV-1a (32-bit)
|
||||
|
||||
```python
|
||||
def fnv1a_32(data: bytes) -> int:
|
||||
"""FNV-1a 32-bit hash.
|
||||
|
||||
Basis: 0x811C9DC5, Prime: 0x01000193.
|
||||
XOR each byte, then multiply by prime (masked to 32 bits).
|
||||
"""
|
||||
h = 0x811C9DC5
|
||||
for b in data:
|
||||
h ^= b
|
||||
h = (h * 0x01000193) & 0xFFFFFFFF
|
||||
return h
|
||||
```
|
||||
|
||||
Known test vectors:
|
||||
- `fnv1a_32(b"")` = `0x811C9DC5` (basis value)
|
||||
- `fnv1a_32(b"foobar")` = `0xBF9CF968`
|
||||
|
||||
Cross-language implementations:
|
||||
- **Python**: loop above (no dependencies)
|
||||
- **Go**: same algorithm with `uint32` arithmetic
|
||||
- **TypeScript**: same algorithm with `>>> 0` for unsigned 32-bit
|
||||
|
||||
### Virtual nodes
|
||||
|
||||
Each physical node with weight `w` gets `w * 150` virtual positions on a
|
||||
16-bit ring (65536 positions). Virtual node `i` of physical node `N` is
|
||||
placed at:
|
||||
|
||||
```
|
||||
position = fnv1a_32(f"{N.node_id}:{i}".encode()) % 65536
|
||||
```
|
||||
|
||||
With 150 vnodes per unit weight:
|
||||
- 2 equal-weight nodes: ~50/50 split (measured: 38-62% range due to
|
||||
hash variance, stddev ~3% with large vnode counts)
|
||||
- 3 nodes at weights 2:1:1: ~50/25/25 (within 10% tolerance)
|
||||
|
||||
### Lookup
|
||||
|
||||
```python
|
||||
def owner(bucket: int) -> str:
|
||||
"""O(log n) bisect-right walk to find the next virtual node clockwise."""
|
||||
idx = bisect_right(positions, bucket)
|
||||
if idx >= len(positions):
|
||||
idx = 0 # wrap around
|
||||
return vnode_map[positions[idx]]
|
||||
```
|
||||
|
||||
### Stability properties
|
||||
|
||||
The consistent hash ring guarantees:
|
||||
- **Node addition**: adding a node moves at most `1/N` of buckets (where N
|
||||
is the new node count). Other nodes' buckets are unaffected.
|
||||
- **Node removal**: only the removed node's buckets are reassigned. Buckets
|
||||
owned by surviving nodes don't move.
|
||||
- **Determinism**: same membership list always produces the same ring.
|
||||
No coordination needed between processes.
|
||||
|
||||
### Full assignment precomputation
|
||||
|
||||
```python
|
||||
def assignments() -> list[tuple[int, str]]:
|
||||
"""Compute all 65536 bucket-to-node mappings."""
|
||||
return [(b, owner(b)) for b in range(65536)]
|
||||
```
|
||||
|
||||
This produces a complete assignment table that can be loaded into a flat
|
||||
array for O(1) request-time lookup. The ring itself is never consulted
|
||||
on the hot path.
|
||||
|
||||
## Data structures
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RingNode:
|
||||
node_id: str
|
||||
url: str
|
||||
weight: int = 1
|
||||
|
||||
class HashRing:
|
||||
"""Immutable consistent hash ring. Thread-safe (no mutable state)."""
|
||||
|
||||
def __init__(self, nodes: Sequence[RingNode], vnodes_per_unit: int = 150):
|
||||
# Validate no duplicate node_ids
|
||||
# Build sorted array of (position, node_id) tuples
|
||||
# positions[i] = fnv1a_32(f"{node_id}:{i}".encode()) % RING_SIZE
|
||||
|
||||
def owner(self, bucket: int) -> RingNode | None:
|
||||
# bisect_right + wrap
|
||||
|
||||
@property
|
||||
def version(self) -> int:
|
||||
# Deterministic hash of membership: fnv1a_32 of sorted node_id:weight pairs
|
||||
|
||||
def assignments(self) -> list[tuple[int, str]]:
|
||||
# Precompute all 65536 bucket assignments
|
||||
```
|
||||
|
||||
## Comparison with current approach
|
||||
|
||||
| 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
|
||||
|
||||
For cross-language implementation validation:
|
||||
|
||||
```json
|
||||
{
|
||||
"fnv1a_32": [
|
||||
{"input": "", "output": 2166136261},
|
||||
{"input": "foobar", "output": 3215766888}
|
||||
],
|
||||
"bucket_of": [
|
||||
{"ws_id": "a3f100000000000000000000000000000", "bucket": 41969},
|
||||
{"ws_id": "00000000000000000000000000000000", "bucket": 0},
|
||||
{"ws_id": "ffff0000000000000000000000000000", "bucket": 65535}
|
||||
],
|
||||
"ring_single_node": {
|
||||
"nodes": [{"node_id": "n1", "weight": 1}],
|
||||
"vnodes_per_unit": 150,
|
||||
"expected_n1_buckets": 65536
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -13,24 +13,22 @@ cloud "LLM Providers" as llm {
|
||||
component [OpenAI-compatible API\n(OpenAI, vLLM, llama.cpp)] as llm_openai
|
||||
component [Anthropic Messages API] as llm_anthropic
|
||||
}
|
||||
database "Redis" as redis
|
||||
database "SQLite\n(.turnstone.db)" as sqlite
|
||||
|
||||
' Turnstone System Boundary
|
||||
package "Turnstone Platform" {
|
||||
component [turnstone\n(CLI)] as cli <<entry point>>
|
||||
component [turnstone-server\n(HTTP + SSE)] as server <<entry point>>
|
||||
component [turnstone-bridge\n(Queue ↔ HTTP)] as bridge <<service>>
|
||||
component [turnstone-console\n(Dashboard)] as console <<service>>
|
||||
component [turnstone-console\n(Dashboard + Router)] as console <<service>>
|
||||
component [turnstone-eval\n(Headless)] as eval <<entry point>>
|
||||
component [turnstone-sim\n(Simulator)] as sim <<service>>
|
||||
component [turnstone-channel\n(Channel Gateway)] as channel <<service>>
|
||||
}
|
||||
|
||||
' User connections
|
||||
cli_user --> cli : stdin / stdout
|
||||
browser_user --> server : HTTP + SSE\n(port 8080)
|
||||
browser_user --> console : HTTP + SSE\n(port 8090)
|
||||
ext_client --> redis : Redis LIST\n(push commands)
|
||||
ext_client --> server : HTTP + SSE\n(SDK / API)
|
||||
eval_user --> eval : Python API
|
||||
|
||||
' Internal connections
|
||||
@@ -43,26 +41,16 @@ server --> sqlite : SQLite
|
||||
eval --> llm : LLM Provider API\n(non-streaming)
|
||||
eval --> sqlite : SQLite
|
||||
|
||||
bridge --> server : HTTP REST\n(POST /v1/api/send, etc.)
|
||||
bridge <-- server : SSE\n(GET /v1/api/events)
|
||||
bridge --> redis : Redis LIST + PUBSUB\n+ STRING (routing, heartbeats)
|
||||
console --> server : HTTP proxy\n(hash-ring bucket lookup,\nproxy /node/{id}/* traffic)
|
||||
|
||||
console --> redis : Redis PUBSUB + STRING + LIST\n(cluster events, heartbeats,\nworkstream creation commands)
|
||||
console --> server : HTTP polling + reverse proxy\n(GET /v1/api/dashboard,\nproxy /node/{id}/* traffic)
|
||||
|
||||
sim --> redis : Redis LIST + PUBSUB\n+ STRING (heartbeats)
|
||||
channel --> server : HTTP + SSE\n(POST /v1/api/send,\nGET /v1/api/events)
|
||||
|
||||
' Notes
|
||||
note right of sim
|
||||
Simulator replaces Server+Bridge
|
||||
with lightweight SimNodes that
|
||||
publish to the same Redis channels.
|
||||
end note
|
||||
|
||||
note right of redis
|
||||
Shared message broker:
|
||||
- LIST: command queues
|
||||
- STRING: heartbeats, routing
|
||||
- PUBSUB: event broadcast
|
||||
note right of console
|
||||
Multi-node router:
|
||||
- Hash-ring bucket lookup
|
||||
- Proxies create/send/approve
|
||||
- Direct SSE from client to node
|
||||
- HTTP polling for dashboard
|
||||
end note
|
||||
@enduml
|
||||
|
||||
@@ -6,13 +6,12 @@ title Turnstone — Package & Module Structure
|
||||
skinparam component {
|
||||
BackgroundColor<<entry>> #B8D4E3
|
||||
BackgroundColor<<core>> #C8E6C9
|
||||
BackgroundColor<<mq>> #FFE0B2
|
||||
BackgroundColor<<sim>> #E1BEE7
|
||||
BackgroundColor<<console>> #B2EBF2
|
||||
BackgroundColor<<ui>> #F0F4C3
|
||||
BackgroundColor<<artifact>> #ECEFF1
|
||||
BackgroundColor<<sdk>> #FFCDD2
|
||||
BackgroundColor<<api>> #D1C4E9
|
||||
BackgroundColor<<channel>> #FFE0B2
|
||||
}
|
||||
|
||||
' Entry points
|
||||
@@ -40,27 +39,16 @@ package "turnstone/core/" <<Rectangle>> {
|
||||
component [auth.py\nAuthentication] as auth <<core>>
|
||||
component [healthcheck.py\nBackendHealthMonitor] as healthcheck <<core>>
|
||||
component [ratelimit.py\nRateLimiter] as ratelimit <<core>>
|
||||
component [mcp_client.py\nMCPClientManager] as mcp <<core>>
|
||||
component [mcp_client.py\nMCPClientManager\n(push + periodic refresh)] as mcp <<core>>
|
||||
component [tool_search.py\nToolSearchManager, BM25] as toolsearch <<core>>
|
||||
component [model_registry.py\nModelRegistry] as registry <<core>>
|
||||
}
|
||||
|
||||
' MQ subsystem
|
||||
package "turnstone/mq/" <<Rectangle>> {
|
||||
component [protocol.py\n28 message types] as protocol <<mq>>
|
||||
component [broker.py\nMessageBroker, RedisBroker] as broker <<mq>>
|
||||
component [bridge.py\nturnstone-bridge] as bridge <<mq>>
|
||||
component [client.py\nTurnstoneClient] as client <<mq>>
|
||||
}
|
||||
|
||||
' Simulator
|
||||
package "turnstone/sim/" <<Rectangle>> {
|
||||
component [cluster.py\nSimCluster] as simcluster <<sim>>
|
||||
component [node.py\nSimNode, SimWorkstream] as simnode <<sim>>
|
||||
component [engine.py\nSimEngine] as simengine <<sim>>
|
||||
component [scenario.py\n5 scenarios] as scenario <<sim>>
|
||||
component [sim/config.py\nSimConfig] as simconfig <<sim>>
|
||||
component [sim/metrics.py\nSim metrics] as simmetrics <<sim>>
|
||||
component [sim/cli.py\nturnstone-sim] as simcli <<sim>>
|
||||
' Channels
|
||||
package "turnstone/channels/" <<Rectangle>> {
|
||||
component [_routing.py\nChannelRouter] as router <<channel>>
|
||||
component [discord/bot.py\nDiscordBot] as discordbot <<channel>>
|
||||
component [gateway.py\nturnstone-channel] as gateway <<channel>>
|
||||
}
|
||||
|
||||
' Console
|
||||
@@ -74,6 +62,7 @@ package "turnstone/ui/" <<Rectangle>> {
|
||||
component [colors.py\nANSI colors] as colors <<ui>>
|
||||
component [markdown.py\nMD rendering] as markdown <<ui>>
|
||||
component [spinner.py\nTerminal spinner] as spinner <<ui>>
|
||||
component [renderer.js\nBrowser MD + LaTeX] as renderer <<ui>>
|
||||
}
|
||||
|
||||
' API schemas
|
||||
@@ -95,7 +84,7 @@ package "turnstone/sdk/" <<Rectangle>> {
|
||||
|
||||
' Tool schemas
|
||||
package "turnstone/tools/" <<Rectangle>> {
|
||||
component [*.json\n14 tool schemas] as schemas <<artifact>>
|
||||
component [*.json\n19 tool schemas] as schemas <<artifact>>
|
||||
}
|
||||
|
||||
' Entry point dependencies
|
||||
@@ -136,6 +125,7 @@ session --> edit
|
||||
session --> web
|
||||
session --> healthcheck
|
||||
session --> mcp : optional
|
||||
session --> toolsearch : optional
|
||||
session --> registry : optional
|
||||
registry --> providers
|
||||
healthcheck --> metrics
|
||||
@@ -143,35 +133,17 @@ mcp --> config
|
||||
registry --> config
|
||||
tools --> schemas
|
||||
|
||||
' MQ dependencies
|
||||
bridge --> protocol
|
||||
bridge --> broker
|
||||
bridge --> config
|
||||
client --> protocol
|
||||
client --> broker
|
||||
|
||||
' Sim dependencies
|
||||
simcli --> simcluster
|
||||
simcli --> simconfig
|
||||
simcli --> scenario
|
||||
simcluster --> simnode
|
||||
simcluster --> broker
|
||||
simcluster --> simmetrics
|
||||
simcluster --> simconfig
|
||||
simnode --> simengine
|
||||
simnode --> protocol
|
||||
simnode --> simconfig
|
||||
simnode --> simmetrics
|
||||
scenario --> broker
|
||||
scenario --> protocol
|
||||
scenario --> simconfig
|
||||
scenario --> simmetrics
|
||||
' Channel dependencies
|
||||
gateway --> discordbot
|
||||
gateway --> router
|
||||
discordbot --> sdkserver : HTTP + SSE
|
||||
router --> storage : channel_routes
|
||||
|
||||
' Console dependencies
|
||||
consoleserver --> collector
|
||||
consoleserver --> config
|
||||
consoleserver --> auth
|
||||
collector --> broker
|
||||
collector --> server : HTTP polling
|
||||
|
||||
' API dependencies
|
||||
serverspec --> openapi
|
||||
|
||||
@@ -12,7 +12,7 @@ interface "SessionUI" as SessionUI <<Protocol>> {
|
||||
+ on_content_token(text: str)
|
||||
+ on_stream_end()
|
||||
+ approve_tools(items: list) → (bool, str|None)
|
||||
+ on_tool_result(call_id: str, name: str, output: str)
|
||||
+ 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
|
||||
@@ -41,7 +41,7 @@ class "WorkstreamTerminalUI" as WsTermUI {
|
||||
}
|
||||
|
||||
class "WebUI" as WebUI {
|
||||
- _event_queue: Queue
|
||||
- _listeners: list[Queue]
|
||||
- _approval_event: Event
|
||||
- _plan_event: Event
|
||||
- _ws_prompt_tokens: int
|
||||
@@ -84,6 +84,8 @@ class "OpenAIProvider" as OpenAIProv {
|
||||
in OpenAI format.
|
||||
Search models: web_search_options
|
||||
+ url_citation annotations.
|
||||
Extended cache: 24h retention
|
||||
for GPT-5.x (free).
|
||||
--
|
||||
core/providers/_openai.py
|
||||
}
|
||||
@@ -94,6 +96,8 @@ class "AnthropicProvider" as AnthropicProv {
|
||||
Adaptive + manual thinking.
|
||||
Native web search via
|
||||
web_search_20250305 server tool.
|
||||
Auto prompt caching via
|
||||
cache_control: ephemeral.
|
||||
Lazy anthropic SDK import.
|
||||
--
|
||||
core/providers/_anthropic.py
|
||||
@@ -108,6 +112,8 @@ class "ModelCapabilities" as ModelCaps <<frozen>> {
|
||||
+ thinking_mode: str
|
||||
+ supports_effort: bool
|
||||
+ supports_web_search: bool
|
||||
+ supports_tool_search: bool
|
||||
+ supports_vision: bool
|
||||
}
|
||||
|
||||
' ChatSession
|
||||
@@ -120,6 +126,7 @@ class "ChatSession" as ChatSession {
|
||||
- _msg_tokens: list[int]
|
||||
- _ws_id: str
|
||||
- _mcp_client: MCPClientManager | None
|
||||
- _tool_search: ToolSearchManager | None
|
||||
- _registry: ModelRegistry | None
|
||||
+ model_alias: str | None {property}
|
||||
- _tools: list[dict]
|
||||
@@ -139,6 +146,12 @@ class "ChatSession" as ChatSession {
|
||||
- _prepare_tool(tc) → item dict
|
||||
- _prepare_mcp_tool(call_id, name, args) → item dict
|
||||
- _exec_mcp_tool(item) → (call_id, output)
|
||||
- _get_active_tools() → list[dict]
|
||||
- _prepare_tool_search() → None
|
||||
- _exec_tool_search(item) → (call_id, output)
|
||||
- _on_mcp_tools_changed()
|
||||
- _rebuild_tool_search()
|
||||
+ close()
|
||||
- _run_agent(messages, tools, ...) → str
|
||||
- _compact_messages(auto: bool)
|
||||
- _full_messages() → list[dict]
|
||||
@@ -201,22 +214,56 @@ enum "WorkstreamState" as WsState {
|
||||
' MCPClientManager
|
||||
class "MCPClientManager" as MCPMgr {
|
||||
- _sessions: dict[str, ClientSession]
|
||||
- _per_server_tools: dict[str, list[dict]]
|
||||
- _per_server_resources: dict[str, list[dict]]
|
||||
- _per_server_prompts: dict[str, list[dict]]
|
||||
- _tools: list[dict]
|
||||
- _tool_map: dict[str, tuple]
|
||||
- _resource_map: dict[str, tuple]
|
||||
- _prompt_map: dict[str, tuple]
|
||||
- _supports_list_changed: dict[str, bool]
|
||||
- _listeners: list[Callable]
|
||||
--
|
||||
+ start()
|
||||
+ get_tools() → list[dict]
|
||||
+ get_resources() → list[dict]
|
||||
+ get_prompts() → list[dict]
|
||||
+ is_mcp_tool(name) → bool
|
||||
+ call_tool_sync(name, args) → str
|
||||
+ read_resource_sync(uri) → str
|
||||
+ get_prompt_sync(name, args?) → list[dict]
|
||||
+ refresh_sync(server?) → dict
|
||||
+ add_listener(callback)
|
||||
+ remove_listener(callback)
|
||||
+ server_names: list[str] {property}
|
||||
+ shutdown()
|
||||
--
|
||||
Background asyncio event loop
|
||||
bridges async MCP SDK to
|
||||
sync ChatSession dispatch.
|
||||
Push + periodic + manual refresh.
|
||||
Resources + prompts discovered
|
||||
alongside tools at startup.
|
||||
--
|
||||
core/mcp_client.py
|
||||
}
|
||||
|
||||
' ToolSearchManager
|
||||
class "ToolSearchManager" as ToolSearchMgr {
|
||||
- _always_on: list[dict]
|
||||
- _deferred: list[dict]
|
||||
- _expanded: dict[str, None]
|
||||
- _index: BM25Index
|
||||
--
|
||||
+ get_visible_tools() → list[dict]
|
||||
+ get_deferred_tools() → list[dict]
|
||||
+ get_expanded_names() → list[str]
|
||||
+ search(query, k) → list[dict]
|
||||
+ expand_visible(names) → list[dict]
|
||||
+ get_search_tool_definition() → dict
|
||||
+ format_search_results(tools) → str
|
||||
}
|
||||
|
||||
' ModelRegistry
|
||||
class "ModelRegistry" as ModelReg {
|
||||
- _models: dict[str, ModelConfig]
|
||||
@@ -317,6 +364,7 @@ LLMProvider <|.. AnthropicProv
|
||||
ChatSession --> SessionUI : uses
|
||||
ChatSession --> LLMProvider : delegates LLM calls
|
||||
ChatSession --> MCPMgr : optional
|
||||
ChatSession --o ToolSearchMgr : _tool_search
|
||||
ChatSession --> ModelReg : optional
|
||||
ChatSession <|-- HeadlessSession
|
||||
|
||||
|
||||
@@ -57,6 +57,14 @@ group loop [while tool_calls present]
|
||||
end
|
||||
end
|
||||
|
||||
note right of CS
|
||||
**Cancellation checkpoint:**
|
||||
_check_cancelled() runs per chunk.
|
||||
If cancel_event is set, raises
|
||||
GenerationCancelled — preserves
|
||||
partial content, emits idle state.
|
||||
end note
|
||||
|
||||
LLM --> CS : stream complete (usage stats)
|
||||
deactivate LLM
|
||||
|
||||
@@ -112,20 +120,21 @@ group loop [while tool_calls present]
|
||||
note right of TP
|
||||
Parallel execution:
|
||||
bash → Popen + line-by-line streaming
|
||||
read_file → open().read()
|
||||
read_file → open().read() or base64 image
|
||||
search → grep subprocess
|
||||
edit_file → string replace
|
||||
task/plan → _run_agent() sub-loop
|
||||
math → sandboxed subprocess
|
||||
web_fetch → httpx + LLM summarize
|
||||
web_search → provider-native or Tavily fallback
|
||||
remember/recall/forget → SQLite
|
||||
memory/recall → SQLite
|
||||
end note
|
||||
|
||||
note right of TP
|
||||
bash: on_tool_output_chunk(call_id, line)
|
||||
called per stdout line,
|
||||
then on_tool_result(call_id, name, output).
|
||||
then on_tool_result(call_id, name, output, is_error).
|
||||
is_error=True when execution failed.
|
||||
call_id routes chunks/results to correct
|
||||
tool div during parallel execution.
|
||||
Other tools: on_tool_result() only.
|
||||
@@ -144,6 +153,12 @@ group loop [while tool_calls present]
|
||||
end
|
||||
|
||||
note right of CS : Loop back for next LLM call
|
||||
|
||||
else GenerationCancelled
|
||||
CS -> CS : Preserve partial content\nor roll back incomplete tools
|
||||
CS -> UI : on_info("[Generation cancelled]")
|
||||
CS -> UI : on_state_change("idle")
|
||||
CS --> User : return (no re-raise)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -24,27 +24,33 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
||||
:Dispatch to _prepare_{func_name}();
|
||||
|
||||
note right
|
||||
**Dispatch table (14 tools):**
|
||||
┌─────────────┬──────────────────┐
|
||||
│ Tool │ Needs Approval? │
|
||||
├─────────────┼──────────────────┤
|
||||
│ bash │ ✓ Yes │
|
||||
│ read_file │ ✗ Auto-approve │
|
||||
│ write_file │ ✓ Yes │
|
||||
│ edit_file │ ✓ Yes │
|
||||
│ search │ ✗ Auto-approve │
|
||||
│ math │ ✓ Yes │
|
||||
│ man │ ✗ Auto-approve │
|
||||
│ web_fetch │ ✓ Yes │
|
||||
│ web_search │ ✓ Yes │
|
||||
│ task │ ✓ Yes │
|
||||
│ plan │ ✓ Yes │
|
||||
│ remember │ ✗ Auto-approve │
|
||||
│ recall │ ✗ Auto-approve │
|
||||
│ forget │ ✗ Auto-approve │
|
||||
├─────────────┼──────────────────┤
|
||||
│ mcp__* │ ✓ Yes (external) │
|
||||
└─────────────┴──────────────────┘
|
||||
**Dispatch table (19 built-in + tool_search):**
|
||||
┌───────────────┬──────────────────┐
|
||||
│ Tool │ Needs Approval? │
|
||||
├───────────────┼──────────────────┤
|
||||
│ bash │ ✓ Yes │
|
||||
│ read_file │ ✗ Auto-approve │
|
||||
│ write_file │ ✓ Yes │
|
||||
│ 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 │
|
||||
│ watch │ ✓ create only │
|
||||
│ skill │ ✓ load only │
|
||||
│ read_resource │ ✓ Yes │
|
||||
│ use_prompt │ ✓ Yes │
|
||||
├───────────────┼──────────────────┤
|
||||
│ mcp__* │ ✓ Yes (external) │
|
||||
└───────────────┴──────────────────┘
|
||||
end note
|
||||
|
||||
:Build item dict:
|
||||
@@ -66,8 +72,8 @@ partition "Phase 2: Approve" #FFF3E0 {
|
||||
**TerminalUI**: Print headers/previews,
|
||||
prompt [y/n/a, optional message]
|
||||
If user chose "always":
|
||||
Set ui.auto_approve = True
|
||||
(auto-approve all future tools in this session)
|
||||
Add pending tool names to auto_approve_tools
|
||||
(auto-approve these tool types going forward)
|
||||
**WebUI**: Enqueue approve_request,
|
||||
block on _approval_event.wait()
|
||||
**NullUI**: Return (True, None)
|
||||
@@ -86,6 +92,8 @@ partition "Phase 2: Approve" #FFF3E0 {
|
||||
}
|
||||
|
||||
partition "Phase 3: Execute" #E3F2FD {
|
||||
:_check_cancelled();
|
||||
note right: Cancellation checkpoint:\nraises GenerationCancelled if\ncancel event is set
|
||||
if (single tool call?) then (yes)
|
||||
:Execute sequentially:\nrun_one(items[0]);
|
||||
else (multiple)
|
||||
@@ -98,7 +106,7 @@ partition "Phase 3: Execute" #E3F2FD {
|
||||
if item.denied → return denial message
|
||||
else → item["execute"](item)
|
||||
├─ _exec_bash: subprocess.run(["bash", script.sh])
|
||||
├─ _exec_read_file: open().readlines()
|
||||
├─ _exec_read_file: open().readlines() or _exec_read_image (base64)
|
||||
├─ _exec_write_file: makedirs + write
|
||||
├─ _exec_edit_file: find_occurrences + replace
|
||||
├─ _exec_search: grep subprocess
|
||||
@@ -106,11 +114,14 @@ partition "Phase 3: Execute" #E3F2FD {
|
||||
├─ _exec_man: man/info subprocess
|
||||
├─ _exec_web_fetch: httpx.get + LLM summary
|
||||
├─ _exec_web_search: Tavily API POST (fallback for local models)
|
||||
├─ _exec_tool_search: BM25 search + expand_visible()
|
||||
├─ _exec_task: _run_agent(TASK_AGENT_TOOLS)
|
||||
├─ _exec_plan: _run_agent(AGENT_TOOLS, read-only)
|
||||
├─ _exec_remember: SQLite INSERT OR REPLACE
|
||||
├─ _exec_recall: SQLite FTS5/LIKE search
|
||||
├─ _exec_forget: SQLite DELETE
|
||||
├─ _exec_notify: HTTP POST to channel gateway
|
||||
├─ _exec_memory: structured memory save/search/delete/list
|
||||
├─ _exec_recall: conversation history FTS5 search
|
||||
├─ _exec_read_resource: MCPClientManager.read_resource_sync()
|
||||
├─ _exec_use_prompt: MCPClientManager.get_prompt_sync()
|
||||
└─ _exec_mcp_tool: MCPClientManager.call_tool_sync()
|
||||
end note
|
||||
|
||||
@@ -119,7 +130,7 @@ partition "Phase 3: Execute" #E3F2FD {
|
||||
:_truncate_output() on each result\n(max context_window × chars_per_token × 0.5 chars\ndefault: ~context_window × 2 chars);
|
||||
|
||||
:bash: ui.on_tool_output_chunk(call_id, line) per stdout line;
|
||||
:ui.on_tool_result(call_id, name, output) for each;
|
||||
:ui.on_tool_result(call_id, name, output, is_error) for each;
|
||||
|
||||
if (plan tool was executed?) then (yes)
|
||||
:ui.on_plan_review(output);
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Message Queue Protocol Types
|
||||
skinparam classAttributeIconSize 0
|
||||
skinparam packageStyle rectangle
|
||||
|
||||
package "Inbound Messages (Client → Bridge)" #FFF3E0 {
|
||||
|
||||
abstract class "InboundMessage" as IM {
|
||||
+ type: str
|
||||
+ correlation_id: str {auto: uuid4().hex[:12]}
|
||||
+ timestamp: float {auto: time.time()}
|
||||
--
|
||||
+ to_json() → str
|
||||
+ {static} from_json(raw) → InboundMessage
|
||||
}
|
||||
|
||||
class SendMessage {
|
||||
type = "send"
|
||||
--
|
||||
+ ws_id: str
|
||||
+ message: str
|
||||
+ auto_approve: bool = False
|
||||
+ auto_approve_tools: list[str] = []
|
||||
+ name: str = ""
|
||||
+ target_node: str = ""
|
||||
}
|
||||
|
||||
class ApproveMessage {
|
||||
type = "approve"
|
||||
--
|
||||
+ ws_id: str
|
||||
+ request_id: str
|
||||
+ approved: bool = True
|
||||
+ feedback: str | None
|
||||
+ always: bool = False
|
||||
}
|
||||
|
||||
class PlanFeedbackMessage {
|
||||
type = "plan_feedback"
|
||||
--
|
||||
+ ws_id: str
|
||||
+ request_id: str
|
||||
+ feedback: str
|
||||
}
|
||||
|
||||
class CommandMessage {
|
||||
type = "command"
|
||||
--
|
||||
+ ws_id: str
|
||||
+ command: str
|
||||
}
|
||||
|
||||
class CreateWorkstreamMessage {
|
||||
type = "create_workstream"
|
||||
--
|
||||
+ name: str = ""
|
||||
+ auto_approve: bool = False
|
||||
+ auto_approve_tools: list[str] = []
|
||||
+ target_node: str = ""
|
||||
+ initial_message: str = ""
|
||||
}
|
||||
|
||||
class CloseWorkstreamMessage {
|
||||
type = "close_workstream"
|
||||
--
|
||||
+ ws_id: str
|
||||
}
|
||||
|
||||
class ListWorkstreamsMessage {
|
||||
type = "list_workstreams"
|
||||
}
|
||||
|
||||
class HealthMessage {
|
||||
type = "health"
|
||||
}
|
||||
|
||||
class ListNodesMessage {
|
||||
type = "list_nodes"
|
||||
}
|
||||
|
||||
IM <|-- SendMessage
|
||||
IM <|-- ApproveMessage
|
||||
IM <|-- PlanFeedbackMessage
|
||||
IM <|-- CommandMessage
|
||||
IM <|-- CreateWorkstreamMessage
|
||||
IM <|-- CloseWorkstreamMessage
|
||||
IM <|-- ListWorkstreamsMessage
|
||||
IM <|-- HealthMessage
|
||||
IM <|-- ListNodesMessage
|
||||
}
|
||||
|
||||
package "Outbound Events (Bridge → Client)" #E3F2FD {
|
||||
|
||||
abstract class "OutboundEvent" as OE {
|
||||
+ type: str
|
||||
+ ws_id: str
|
||||
+ correlation_id: str
|
||||
+ timestamp: float
|
||||
--
|
||||
+ to_json() → str
|
||||
+ {static} from_json(raw) → OutboundEvent
|
||||
}
|
||||
|
||||
package "Streaming" #BBDEFB {
|
||||
class ContentEvent {
|
||||
type = "content"
|
||||
+ text: str
|
||||
}
|
||||
class ReasoningEvent {
|
||||
type = "reasoning"
|
||||
+ text: str
|
||||
}
|
||||
class StreamEndEvent {
|
||||
type = "stream_end"
|
||||
}
|
||||
}
|
||||
|
||||
package "Tools" #C8E6C9 {
|
||||
class ToolInfoEvent {
|
||||
type = "tool_info"
|
||||
+ items: list
|
||||
}
|
||||
class ApprovalRequestEvent {
|
||||
type = "approval_request"
|
||||
+ items: list
|
||||
..
|
||||
correlation_id = request_id
|
||||
}
|
||||
class ToolOutputChunkEvent {
|
||||
type = "tool_output_chunk"
|
||||
+ call_id: str
|
||||
+ chunk: str
|
||||
}
|
||||
class ToolResultEvent {
|
||||
type = "tool_result"
|
||||
+ call_id: str
|
||||
+ name: str
|
||||
+ output: str
|
||||
}
|
||||
class PlanReviewEvent {
|
||||
type = "plan_review"
|
||||
+ content: str
|
||||
}
|
||||
}
|
||||
|
||||
package "Status" #FFF9C4 {
|
||||
class AckEvent {
|
||||
type = "ack"
|
||||
+ status: str
|
||||
+ detail: str
|
||||
}
|
||||
class StatusEvent {
|
||||
type = "status"
|
||||
+ prompt_tokens: int
|
||||
+ completion_tokens: int
|
||||
+ total_tokens: int
|
||||
+ context_window: int
|
||||
+ pct: float
|
||||
+ effort: str
|
||||
}
|
||||
class StateChangeEvent {
|
||||
type = "state_change"
|
||||
+ state: str
|
||||
}
|
||||
class TurnCompleteEvent {
|
||||
type = "turn_complete"
|
||||
}
|
||||
}
|
||||
|
||||
package "Lifecycle" #F8BBD0 {
|
||||
class WorkstreamCreatedEvent {
|
||||
type = "ws_created"
|
||||
+ name: str
|
||||
}
|
||||
class WorkstreamClosedEvent {
|
||||
type = "ws_closed"
|
||||
}
|
||||
class WorkstreamListEvent {
|
||||
type = "ws_list"
|
||||
+ workstreams: list
|
||||
}
|
||||
class WorkstreamRenameEvent {
|
||||
type = "ws_rename"
|
||||
+ name: str
|
||||
}
|
||||
}
|
||||
|
||||
package "System" #E0E0E0 {
|
||||
class HealthResponseEvent {
|
||||
type = "health_response"
|
||||
+ data: dict
|
||||
}
|
||||
class ErrorEvent {
|
||||
type = "error"
|
||||
+ message: str
|
||||
}
|
||||
class InfoEvent {
|
||||
type = "info"
|
||||
+ message: str
|
||||
}
|
||||
class NodeListEvent {
|
||||
type = "node_list"
|
||||
+ nodes: list
|
||||
}
|
||||
class ClusterStateEvent {
|
||||
type = "cluster_state"
|
||||
+ state: str
|
||||
+ node_id: str
|
||||
+ tokens: int
|
||||
+ context_ratio: float
|
||||
+ activity: str
|
||||
+ activity_state: str
|
||||
}
|
||||
}
|
||||
|
||||
OE <|-- ContentEvent
|
||||
OE <|-- ReasoningEvent
|
||||
OE <|-- StreamEndEvent
|
||||
OE <|-- ToolInfoEvent
|
||||
OE <|-- ApprovalRequestEvent
|
||||
OE <|-- ToolResultEvent
|
||||
OE <|-- PlanReviewEvent
|
||||
OE <|-- AckEvent
|
||||
OE <|-- StatusEvent
|
||||
OE <|-- StateChangeEvent
|
||||
OE <|-- TurnCompleteEvent
|
||||
OE <|-- WorkstreamCreatedEvent
|
||||
OE <|-- WorkstreamClosedEvent
|
||||
OE <|-- WorkstreamListEvent
|
||||
OE <|-- WorkstreamRenameEvent
|
||||
OE <|-- HealthResponseEvent
|
||||
OE <|-- ErrorEvent
|
||||
OE <|-- InfoEvent
|
||||
OE <|-- NodeListEvent
|
||||
OE <|-- ClusterStateEvent
|
||||
}
|
||||
|
||||
note bottom of IM
|
||||
**Deserialization**: Strict type-dispatch via _INBOUND_REGISTRY.
|
||||
Unknown type raises ValueError.
|
||||
end note
|
||||
|
||||
note bottom of OE
|
||||
**Deserialization**: Lenient type-dispatch via _OUTBOUND_REGISTRY.
|
||||
Unknown type falls back to base OutboundEvent.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -1,105 +0,0 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Multi-Node Message Routing
|
||||
|
||||
skinparam sequenceArrowThickness 1.5
|
||||
|
||||
participant "TurnstoneClient" as Client
|
||||
collections "Redis" as Redis
|
||||
participant "Bridge-A\n(node_id: nodeA)" as BridgeA
|
||||
participant "Bridge-B\n(node_id: nodeB)" as BridgeB
|
||||
participant "Server-A" as ServerA
|
||||
|
||||
== Scenario A: New Message — No Workstream Affinity ==
|
||||
|
||||
Client -> Redis : RPUSH turnstone:inbound\n{type:"send", message:"...", ws_id:""}
|
||||
note right of Redis : Shared queue — any bridge can pick up
|
||||
|
||||
BridgeA -> Redis : BLPOP [turnstone:inbound:nodeA,\n turnstone:inbound]
|
||||
Redis --> BridgeA : SendMessage (from shared queue)
|
||||
|
||||
BridgeA -> ServerA : POST /v1/api/workstreams/new\n{name:"", auto_approve:false}
|
||||
ServerA --> BridgeA : {ws_id:"abc12345", name:"ws-abc1"}
|
||||
|
||||
BridgeA -> Redis : SET turnstone:ws:abc12345 "nodeA"
|
||||
note right : Register workstream ownership
|
||||
|
||||
BridgeA -> ServerA : GET /v1/api/events?ws_id=abc12345
|
||||
note right : Start per-WS SSE thread
|
||||
|
||||
BridgeA -> Redis : PUBLISH turnstone:events:global\nWorkstreamCreatedEvent
|
||||
|
||||
BridgeA -> Redis : PUBLISH turnstone:events:cluster\nClusterStateEvent(ws_id, state:"idle", node_id:"nodeA")
|
||||
|
||||
BridgeA -> ServerA : POST /v1/api/send\n{message:"...", ws_id:"abc12345"}
|
||||
ServerA --> BridgeA : {status:"ok"}
|
||||
|
||||
BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nAckEvent(status:"ok")
|
||||
|
||||
... SSE events flow: content, tool_output_chunk, tool_result, status, state_change ...
|
||||
|
||||
BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nContentEvent, ToolResultEvent, ...
|
||||
BridgeA -> Redis : PUBLISH turnstone:events:global\nStateChangeEvent(state:"idle")
|
||||
BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nTurnCompleteEvent
|
||||
|
||||
== Scenario B: Directed Message to Specific Node ==
|
||||
|
||||
Client -> Redis : RPUSH turnstone:inbound:nodeB\n{type:"send", target_node:"nodeB", ...}
|
||||
note right : Per-node queue — only nodeB picks up
|
||||
|
||||
BridgeB -> Redis : BLPOP [turnstone:inbound:nodeB,\n turnstone:inbound]
|
||||
Redis --> BridgeB : SendMessage (from per-node queue, priority)
|
||||
|
||||
note right of BridgeB : Process locally on nodeB
|
||||
|
||||
== Scenario C: Re-routing (Lands on Wrong Node) ==
|
||||
|
||||
Client -> Redis : RPUSH turnstone:inbound\n{type:"send", ws_id:"abc12345"}
|
||||
|
||||
BridgeB -> Redis : BLPOP [..., turnstone:inbound]
|
||||
Redis --> BridgeB : SendMessage (ws_id: abc12345)
|
||||
|
||||
BridgeB -> Redis : GET turnstone:ws:abc12345
|
||||
Redis --> BridgeB : "nodeA"
|
||||
|
||||
note right of BridgeB : Owner is nodeA, not me — re-route
|
||||
|
||||
BridgeB -> Redis : RPUSH turnstone:inbound:nodeA\n(re-routed message)
|
||||
|
||||
BridgeA -> Redis : BLPOP [turnstone:inbound:nodeA, ...]
|
||||
Redis --> BridgeA : SendMessage (from per-node queue)
|
||||
note right of BridgeA : Process locally — I own this workstream
|
||||
|
||||
== Scenario D: Approval via Response Queue ==
|
||||
|
||||
BridgeA <- ServerA : SSE: {type:"approve_request", items:[...]}
|
||||
|
||||
note right of BridgeA
|
||||
Bridge checks auto-approve:
|
||||
1. _ws_auto_approve[ws_id]? → auto
|
||||
2. All tools in safe set? → auto
|
||||
(read_file, search, man,
|
||||
remember, recall, forget)
|
||||
3. Otherwise → manual approval
|
||||
end note
|
||||
|
||||
BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nApprovalRequestEvent(correlation_id: req_xyz)
|
||||
|
||||
Client <- Redis : (subscribed) ApprovalRequestEvent
|
||||
|
||||
Client -> Redis : RPUSH turnstone:resp:req_xyz\nApproveMessage(approved:true)
|
||||
note right : Response queue — bypasses inbound queue
|
||||
|
||||
BridgeA -> Redis : BLPOP turnstone:resp:req_xyz\n(spawned approval thread, timeout 300s)
|
||||
Redis --> BridgeA : ApproveMessage
|
||||
|
||||
BridgeA -> ServerA : POST /v1/api/approve\n{approved:true, ws_id:"abc12345"}
|
||||
|
||||
== Heartbeat (continuous) ==
|
||||
|
||||
BridgeA -> Redis : SET turnstone:node:nodeA\n{server_url, started} EX 60
|
||||
note right : Every 30s — TTL 60s
|
||||
|
||||
BridgeB -> Redis : SET turnstone:node:nodeB\n{server_url, started} EX 60
|
||||
|
||||
@enduml
|
||||
@@ -1,98 +0,0 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Redis Key Schema
|
||||
|
||||
skinparam component {
|
||||
BackgroundColor<<LIST>> #BBDEFB
|
||||
BackgroundColor<<STRING>> #C8E6C9
|
||||
BackgroundColor<<PUBSUB>> #FFE0B2
|
||||
}
|
||||
|
||||
skinparam note {
|
||||
BackgroundColor #FAFAFA
|
||||
}
|
||||
|
||||
package "Queues (Redis LIST)" #E3F2FD {
|
||||
component [**turnstone:inbound**\n\nShared command queue.\nAny bridge can consume.\n\nOps: RPUSH (write), BLPOP (read)] as inbound <<LIST>>
|
||||
|
||||
component [**turnstone:inbound:{node_id}**\n\nPer-node directed queue.\nPriority over shared queue.\n\nOps: RPUSH (write), BLPOP (read)] as inbound_node <<LIST>>
|
||||
|
||||
component [**turnstone:resp:{request_id}**\n\nPer-request response queue.\nFor approval / plan feedback.\nTTL: 600s\n\nOps: RPUSH + EXPIRE (write), BLPOP (read)] as resp <<LIST>>
|
||||
}
|
||||
|
||||
package "Routing (Redis STRING)" #E8F5E9 {
|
||||
component [**turnstone:ws:{ws_id}**\n\nWorkstream → node ownership.\nValue: node_id string.\nNo TTL.\n\nOps: SET, GET, DEL] as ws_owner <<STRING>>
|
||||
|
||||
component [**turnstone:node:{node_id}**\n\nNode heartbeat + metadata.\nValue: JSON {server_url, started, ...}\nTTL: 60s (refreshed every 30s)\n\nOps: SET with EX, GET, SCAN] as node_hb <<STRING>>
|
||||
}
|
||||
|
||||
package "Event Channels (Redis PUBSUB)" #FFF3E0 {
|
||||
component [**turnstone:events:global**\n\nGlobal event broadcast.\nAll state changes, ws lifecycle.\n\nOps: PUBLISH, SUBSCRIBE] as evt_global <<PUBSUB>>
|
||||
|
||||
component [**turnstone:events:{ws_id}**\n\nPer-workstream events.\nContent, tools, status.\n\nOps: PUBLISH, SUBSCRIBE] as evt_ws <<PUBSUB>>
|
||||
|
||||
component [**turnstone:events:cluster**\n\nCluster-wide state changes.\nUsed by Console dashboard.\n\nOps: PUBLISH, SUBSCRIBE] as evt_cluster <<PUBSUB>>
|
||||
}
|
||||
|
||||
' Readers / Writers
|
||||
|
||||
actor "TurnstoneClient" as client
|
||||
actor "Bridge" as bridge
|
||||
actor "SimNode" as sim
|
||||
actor "Console\nCollector" as console
|
||||
actor "Scenario\n(injector)" as scenario
|
||||
|
||||
' Queue interactions
|
||||
client --> inbound : RPUSH\n(send commands)
|
||||
client --> inbound_node : RPUSH\n(directed)
|
||||
scenario --> inbound : RPUSH\n(inject load)
|
||||
scenario --> inbound_node : RPUSH\n(directed scenario)
|
||||
bridge --> inbound : BLPOP\n(consume)
|
||||
bridge --> inbound_node : BLPOP\n(priority)
|
||||
bridge --> inbound_node : RPUSH\n(re-route)
|
||||
sim --> inbound_node : BLPOP\n(via dispatcher)
|
||||
|
||||
client --> resp : RPUSH\n(approval response)
|
||||
bridge --> resp : BLPOP\n(wait for approval)
|
||||
|
||||
' Routing interactions
|
||||
bridge --> ws_owner : SET / GET / DEL
|
||||
client --> ws_owner : GET\n(route lookup)
|
||||
sim --> ws_owner : SET / DEL
|
||||
|
||||
bridge --> node_hb : SET with EX\n(heartbeat)
|
||||
sim --> node_hb : SET with EX\n(heartbeat)
|
||||
console --> node_hb : SCAN + GET\n(discovery)
|
||||
client --> node_hb : SCAN + GET\n(list_nodes)
|
||||
|
||||
' Pub/sub interactions
|
||||
bridge --> evt_global : PUBLISH
|
||||
bridge --> evt_ws : PUBLISH
|
||||
bridge --> evt_cluster : PUBLISH
|
||||
client --> evt_global : SUBSCRIBE
|
||||
client --> evt_ws : SUBSCRIBE
|
||||
sim --> evt_global : PUBLISH
|
||||
sim --> evt_ws : PUBLISH
|
||||
sim --> evt_cluster : PUBLISH
|
||||
console --> evt_cluster : SUBSCRIBE
|
||||
|
||||
note bottom of inbound
|
||||
**BLPOP priority**: Bridges call
|
||||
BLPOP [per-node, shared] so the
|
||||
per-node queue is always checked first.
|
||||
end note
|
||||
|
||||
note bottom of resp
|
||||
**Bypasses inbound queue**: Approval
|
||||
responses go directly to the response
|
||||
queue, not through the inbound queue.
|
||||
Auto-cleaned after 600s TTL.
|
||||
end note
|
||||
|
||||
note bottom of evt_cluster
|
||||
**ClusterStateEvent** includes node_id,
|
||||
tokens, and context_ratio — enriched
|
||||
data not available on the global channel.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -40,6 +40,23 @@ running --> error : Exception during\ntool execution
|
||||
|
||||
error --> thinking : New send() call\n_emit_state("thinking")
|
||||
|
||||
thinking --> idle : cancel() called\nstream aborted\n_emit_state("idle")
|
||||
|
||||
running --> idle : cancel() called\n_emit_state("idle")
|
||||
|
||||
attention --> idle : cancel() unblocks\napproval/plan wait\n_emit_state("idle")
|
||||
|
||||
note left of idle
|
||||
**Cancel escalation:**
|
||||
1. **Cooperative**: cancel() sets event + closes
|
||||
SDK stream → worker exits at next checkpoint
|
||||
2. **Force**: force=true abandons the worker
|
||||
thread, emits stream_end immediately.
|
||||
Orphaned thread still kills subprocesses
|
||||
but skips message mutations (generation
|
||||
counter prevents stale writes).
|
||||
end note
|
||||
|
||||
note right of thinking
|
||||
**Emitted via:**
|
||||
session._emit_state(state)
|
||||
@@ -47,7 +64,7 @@ note right of thinking
|
||||
|
||||
**Propagation:**
|
||||
• WebUI → global SSE queue (ws_state)
|
||||
• Bridge → PUBLISH to global + cluster channels
|
||||
• Console → HTTP polling picks up state
|
||||
• CLI → WorkstreamManager.set_state()
|
||||
end note
|
||||
|
||||
@@ -55,27 +72,8 @@ note left of attention
|
||||
**Blocking mechanisms:**
|
||||
• TerminalUI: input() prompt
|
||||
• WebUI: threading.Event.wait()
|
||||
• Bridge: BLPOP on response queue
|
||||
• ChannelBot: SSE event + Discord button
|
||||
• NullUI: auto-approve (never reaches)
|
||||
end note
|
||||
|
||||
state "SimWorkstream (simplified)" as sim_group {
|
||||
state "sim_idle" as si <<idle>>
|
||||
state "sim_thinking" as st <<thinking>>
|
||||
state "sim_running" as sr <<running>>
|
||||
state "sim_error" as se <<error>>
|
||||
|
||||
[*] --> si
|
||||
si --> st : process_turn() called
|
||||
st --> sr : Tool calls generated
|
||||
sr --> st : More rounds
|
||||
st --> si : No tools / max rounds
|
||||
st --> se : Uncaught exception
|
||||
}
|
||||
|
||||
note right of sim_group
|
||||
SimWorkstream has no ATTENTION state —
|
||||
tool approval is not simulated.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Simulator Architecture
|
||||
|
||||
skinparam component {
|
||||
BackgroundColor<<cluster>> #E1BEE7
|
||||
BackgroundColor<<node>> #CE93D8
|
||||
BackgroundColor<<engine>> #F3E5F5
|
||||
BackgroundColor<<scenario>> #FFF3E0
|
||||
BackgroundColor<<metrics>> #E8F5E9
|
||||
BackgroundColor<<redis>> #FFCDD2
|
||||
}
|
||||
|
||||
package "SimCluster" as cluster <<cluster>> {
|
||||
|
||||
component [**ThreadPoolExecutor**\nmax_workers=64\n(blocking Redis ops)] as executor <<cluster>>
|
||||
|
||||
component [**redis.ConnectionPool**\nmax_connections=64\ndecode_responses=True\n(shared across all nodes)] as pool <<redis>>
|
||||
|
||||
package "InboundDispatchers" {
|
||||
component [**Dispatcher 0**\nnodes 0-49] as d0
|
||||
component [**Dispatcher 1**\nnodes 50-99] as d1
|
||||
component [**...**\n(ceil(N/50) total)] as dn
|
||||
|
||||
note bottom of d0
|
||||
Each dispatcher calls BLPOP on a single Redis
|
||||
connection for up to 50 node queues + shared queue.
|
||||
Keys: [prefix:inbound:sim-0000, ..., prefix:inbound]
|
||||
Per-node keys have BLPOP priority over shared.
|
||||
end note
|
||||
}
|
||||
|
||||
package "SimNodes (N instances)" {
|
||||
component [**SimNode sim-0000**] as n0 <<node>>
|
||||
component [**SimNode sim-0001**] as n1 <<node>>
|
||||
component [**...**] as nn <<node>>
|
||||
|
||||
component [**SimEngine**\n(per node, seeded RNG)\n\nLLM simulation:\n gaussian(μ=2s, σ=0.5s) latency\n gaussian(μ=200, σ=50) tokens\n random word content\n P(tool_calls) = 0.6/0.3\n\nTool simulation:\n gaussian(μ=0.5s, σ=0.2s) latency\n P(failure) = 0.02] as engine <<engine>>
|
||||
|
||||
component [**SimWorkstream**\n(0..max_ws per node)\n\nState: idle→thinking→running→idle\nToken accounting: word_count × 3\nContent: 8-chunk streaming] as ws <<node>>
|
||||
}
|
||||
|
||||
component [**MetricsCollector**\n(thread-safe, shared)\n\nTracks: turn latencies,\nthroughput, utilization,\nerrors, node kills] as metrics <<metrics>>
|
||||
}
|
||||
|
||||
package "Scenarios (5 workload patterns)" <<scenario>> {
|
||||
component [**SteadyState**\nConstant rate:\n1/mps interval\nfor duration secs] as steady <<scenario>>
|
||||
|
||||
component [**Burst**\nburst_size messages\nas fast as possible\nthen wait] as burst <<scenario>>
|
||||
|
||||
component [**NodeFailure**\nSteadyState + periodic\nnode kills (up to N/2)] as failure <<scenario>>
|
||||
|
||||
component [**Directed**\nMessages targeted to\nspecific nodes via\ntarget_node field] as directed <<scenario>>
|
||||
|
||||
component [**Lifecycle**\n3 phases:\n1. Create workstreams\n2. Send messages\n3. Close half] as lifecycle <<scenario>>
|
||||
}
|
||||
|
||||
database "Redis" as redis <<redis>>
|
||||
|
||||
' Scenario -> Redis
|
||||
steady --> redis : RPUSH prefix:inbound\n(SendMessage)
|
||||
burst --> redis : RPUSH prefix:inbound\n(burst)
|
||||
failure --> redis : RPUSH prefix:inbound
|
||||
directed --> redis : RPUSH prefix:inbound:{node}\n(directed)
|
||||
lifecycle --> redis : RPUSH prefix:inbound\n(Create/Send/Close)
|
||||
|
||||
' Dispatchers -> Redis -> Nodes
|
||||
d0 --> redis : BLPOP [per-node..., shared]
|
||||
d1 --> redis : BLPOP [per-node..., shared]
|
||||
d0 --> n0 : handle_message(raw)
|
||||
d0 --> n1 : handle_message(raw)
|
||||
|
||||
' Nodes internal
|
||||
n0 --> engine : simulate_llm_response()\nsimulate_tool_execution()
|
||||
n0 --> ws : process_turn()
|
||||
|
||||
' Nodes -> Redis (events)
|
||||
n0 --> redis : PUBLISH prefix:events:global\n(StateChangeEvent)
|
||||
n0 --> redis : PUBLISH prefix:events:{ws_id}\n(ContentEvent, ToolResultEvent, ...)
|
||||
n0 --> redis : PUBLISH prefix:events:cluster\n(ClusterStateEvent)
|
||||
n0 --> redis : SET prefix:node:sim-0000\nEX 60 (heartbeat)
|
||||
n0 --> redis : SET prefix:ws:{ws_id}\n(ownership)
|
||||
|
||||
' Shared pool
|
||||
n0 ..> pool : PooledBroker\n(shared connection)
|
||||
n1 ..> pool : PooledBroker
|
||||
d0 ..> pool
|
||||
d0 ..> executor : asyncio.to_thread()
|
||||
|
||||
' Metrics
|
||||
ws --> metrics : record_turn(ws_id, node_id, latency)
|
||||
steady --> metrics : record_inject()
|
||||
burst --> metrics : record_inject()
|
||||
directed --> metrics : record_inject()
|
||||
lifecycle --> metrics : record_inject()
|
||||
cluster --> metrics : record_node_kill(node_id)
|
||||
cluster --> metrics : snapshot_utilization()\n(every metrics_interval)
|
||||
|
||||
note bottom of cluster
|
||||
**SimConfig** controls all simulation parameters:
|
||||
num_nodes, max_ws_per_node, redis settings,
|
||||
llm_latency_mean/stddev, tool_failure_rate,
|
||||
scenario, duration, messages_per_second, seed
|
||||
end note
|
||||
|
||||
note right of redis
|
||||
Simulator uses **real Redis** —
|
||||
not a mock. Console dashboard
|
||||
can monitor a running simulation
|
||||
via the same cluster channel.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -7,67 +7,14 @@ skinparam sequenceArrowThickness 1.5
|
||||
participant "Browser" as Browser
|
||||
participant "Console\nStarlette App" as Server
|
||||
participant "ClusterCollector" as CC
|
||||
collections "Redis" as Redis
|
||||
participant "Node-A Bridge" as BridgeA
|
||||
participant "Node-A\n(real server)" as NodeA
|
||||
participant "Node-B\n(sim node)" as NodeB
|
||||
participant "Node-A\n(server)" as NodeA
|
||||
participant "Node-B\n(server)" as NodeB
|
||||
|
||||
== Thread 1: Cluster Event Subscriber (real-time) ==
|
||||
== Thread 1: HTTP Polling (every 10s) ==
|
||||
|
||||
CC -> Redis : SUBSCRIBE turnstone:events:cluster
|
||||
activate CC #E1BEE7
|
||||
|
||||
Redis --> CC : ClusterStateEvent\n{ws_id, state:"thinking",\nnode_id:"nodeA", tokens:500,\ncontext_ratio:0.05}
|
||||
CC -> CC : Update NodeSnapshot["nodeA"]\n.workstreams["ws123"].state = "thinking"
|
||||
CC -> CC : _fanout(event) → all SSE listeners
|
||||
|
||||
Redis --> CC : {"type":"ws_created",\nws_id:"ws456", name:"task-1",\nnode_id:"sim-0003"}
|
||||
CC -> CC : Add workstream to\nNodeSnapshot["sim-0003"]
|
||||
CC -> CC : _fanout(event)
|
||||
|
||||
Redis --> CC : ClusterStateEvent\n{ws_id:"ws456", state:"idle"}
|
||||
CC -> CC : Update workstream state
|
||||
|
||||
note right of CC
|
||||
Handles: cluster_state,
|
||||
ws_created, ws_closed, ws_rename
|
||||
|
||||
Thread runs continuously.
|
||||
All updates are thread-safe
|
||||
via threading.Lock.
|
||||
end note
|
||||
|
||||
deactivate CC
|
||||
|
||||
== Thread 2: Node Discovery (every 15s) ==
|
||||
|
||||
CC -> Redis : SCAN 0 MATCH turnstone:node:*
|
||||
activate CC #B2EBF2
|
||||
Redis --> CC : [turnstone:node:nodeA, turnstone:node:sim-0003, ...]
|
||||
|
||||
loop for each discovered key
|
||||
CC -> Redis : GET turnstone:node:{id}
|
||||
Redis --> CC : JSON: {server_url, started, max_ws, sim:true/false}
|
||||
end
|
||||
|
||||
CC -> CC : Create new NodeSnapshot\nfor newly discovered nodes
|
||||
CC -> CC : Remove NodeSnapshot\nfor disappeared nodes
|
||||
|
||||
CC -> CC : _fanout({type: "node_joined", ...})\n_fanout({type: "node_lost", ...})
|
||||
|
||||
deactivate CC
|
||||
|
||||
== Thread 3: HTTP Polling (every 10s, real nodes only) ==
|
||||
|
||||
CC -> CC : Filter nodes where\nserver_url.startswith("http")
|
||||
CC -> CC : Iterate registered nodes
|
||||
activate CC #C8E6C9
|
||||
|
||||
note right of CC
|
||||
sim:// nodes are SKIPPED.
|
||||
Their data comes exclusively
|
||||
from the cluster event channel.
|
||||
end note
|
||||
|
||||
CC -> NodeA : GET /v1/api/dashboard
|
||||
activate NodeA
|
||||
NodeA --> CC : {workstreams: [...],\naggregate: {total_tokens, ...}}
|
||||
@@ -75,12 +22,25 @@ deactivate NodeA
|
||||
|
||||
CC -> NodeA : GET /health
|
||||
activate NodeA
|
||||
NodeA --> CC : {status:"ok", version:"0.3.0",\nmodel:"...", workstreams:{...}}
|
||||
NodeA --> CC : {status:"ok", version:"0.9.2",\nmodel:"...", workstreams:{...}}
|
||||
deactivate NodeA
|
||||
|
||||
CC -> CC : Replace NodeSnapshot["nodeA"]\n.workstreams, .health, .aggregate
|
||||
CC -> NodeB : GET /v1/api/dashboard
|
||||
activate NodeB
|
||||
NodeB --> CC : {workstreams: [...],\naggregate: {total_tokens, ...}}
|
||||
deactivate NodeB
|
||||
|
||||
CC -x NodeB : (SKIPPED: sim:// URL)
|
||||
CC -> CC : Diff old vs new workstream IDs
|
||||
CC -> CC : Replace NodeSnapshot["nodeA"]\n.workstreams, .health, .aggregate
|
||||
CC -> CC : _fanout(ws_created) for\nnewly appeared workstreams
|
||||
CC -> CC : _fanout(ws_closed) for\nremoved workstreams
|
||||
|
||||
note right of CC
|
||||
Poll-diff fanout ensures
|
||||
browser SSE clients learn
|
||||
about workstreams that
|
||||
appeared between polls.
|
||||
end note
|
||||
|
||||
deactivate CC
|
||||
|
||||
@@ -89,11 +49,16 @@ deactivate CC
|
||||
Browser -> Server : GET /v1/api/cluster/events
|
||||
activate Server
|
||||
|
||||
Server -> CC : register_listener(queue)
|
||||
note right : Per-client queue.Queue(maxsize=500)\nSSE via EventSourceResponse + run_in_executor()
|
||||
Server -> CC : get_snapshot()
|
||||
CC --> Server : ClusterSnapshot\n(full current state)
|
||||
|
||||
loop continuous
|
||||
CC -> Server : event via listener queue\n(from any of the 3 threads)
|
||||
Server -> CC : register_listener(queue)
|
||||
note right : Per-client queue.Queue(maxsize=2000)\nSSE via EventSourceResponse + run_in_executor()
|
||||
|
||||
Server -> Browser : data: {"type":"snapshot",...}\n(full state as first SSE event)
|
||||
|
||||
loop continuous (incremental updates)
|
||||
CC -> Server : event via listener queue\n(from polling thread)
|
||||
Server -> Browser : data: {"type":"cluster_state",...}\n\n
|
||||
end
|
||||
|
||||
@@ -105,24 +70,31 @@ Browser -> Server : connection closed
|
||||
Server -> CC : unregister_listener(queue)
|
||||
deactivate Server
|
||||
|
||||
== Browser REST: Snapshot ==
|
||||
|
||||
Browser -> Server : GET /v1/api/cluster/snapshot
|
||||
Server -> CC : get_snapshot()
|
||||
CC --> Server : ClusterSnapshot\n(full current state)
|
||||
Server --> Browser : JSON response
|
||||
|
||||
== Browser REST Requests ==
|
||||
|
||||
Browser -> Server : GET /v1/api/cluster/overview
|
||||
Server -> CC : get_overview()
|
||||
CC --> Server : {nodes: 10, workstreams: 47,\nstates: {running:5, ...},\naggregate: {total_tokens: 50000},\nversion_drift: false, versions: ["0.3.0"]}
|
||||
CC --> Server : {nodes: 2, workstreams: 12,\nstates: {running:5, ...},\naggregate: {total_tokens: 50000},\nversion_drift: false, versions: ["0.9.2"]}
|
||||
Server --> Browser : JSON response
|
||||
|
||||
Browser -> Server : GET /v1/api/cluster/nodes?sort=activity
|
||||
Server -> CC : get_nodes(sort_by="activity")
|
||||
CC --> Server : {nodes: [...], total: 10}
|
||||
CC --> Server : {nodes: [...], total: 2}
|
||||
Server --> Browser : JSON response
|
||||
|
||||
Browser -> Server : GET /v1/api/cluster/workstreams\n?state=running&node=sim-0003
|
||||
Server -> CC : get_workstreams(state="running",\nnode="sim-0003")
|
||||
Browser -> Server : GET /v1/api/cluster/workstreams\n?state=running&node=nodeA
|
||||
Server -> CC : get_workstreams(state="running",\nnode="nodeA")
|
||||
CC --> Server : {workstreams: [...], total: 5,\npage: 1, per_page: 50, pages: 1}
|
||||
Server --> Browser : JSON response
|
||||
|
||||
== Workstream Creation (via MQ) ==
|
||||
== Workstream Creation (via Console proxy) ==
|
||||
|
||||
Browser -> Server : POST /v1/api/cluster/workstreams/new\n{node_id:"nodeA", name:"new-task"}
|
||||
activate Server #FFECB3
|
||||
@@ -130,33 +102,22 @@ activate Server #FFECB3
|
||||
Server -> CC : _pick_best_node() or\nget_node_detail(node_id)
|
||||
CC --> Server : node validated
|
||||
|
||||
Server -> Server : Build CreateWorkstreamMessage\n{target_node:"nodeA", name:"new-task"}
|
||||
Server -> NodeA : POST http://nodeA:8080/v1/api/workstreams/new\n{name:"new-task", user_id: from auth_result}
|
||||
activate NodeA
|
||||
NodeA --> Server : {ws_id:"ws789", name:"new-task",\nnode_url:"http://nodeA:8080"}
|
||||
deactivate NodeA
|
||||
|
||||
Server -> Redis : RPUSH turnstone:inbound:nodeA\n(directed queue)
|
||||
Server --> Browser : {status:"ok", correlation_id:"abc",\ntarget_node:"nodeA"}
|
||||
Server --> Browser : {status:"ok", ws_id:"ws789",\nnode_url:"http://nodeA:8080"}
|
||||
deactivate Server
|
||||
|
||||
note right of Redis
|
||||
Bridge on Node-A picks up the
|
||||
message from its directed queue,
|
||||
POSTs to /v1/api/workstreams/new,
|
||||
registers ownership, publishes
|
||||
ws_created to cluster channel.
|
||||
note right of Server
|
||||
Console proxies the create request
|
||||
directly to the target node via HTTP.
|
||||
The response includes node_url so the
|
||||
client can establish a direct SSE
|
||||
connection for the data plane.
|
||||
end note
|
||||
|
||||
Redis --> BridgeA : BLPOP turnstone:inbound:nodeA
|
||||
activate BridgeA
|
||||
BridgeA -> NodeA : POST /v1/api/workstreams/new\n{name:"new-task"}
|
||||
NodeA --> BridgeA : {ws_id:"ws789", name:"new-task"}
|
||||
BridgeA -> Redis : SET turnstone:ws:ws789 = nodeA
|
||||
BridgeA -> Redis : PUBLISH turnstone:events:cluster\n{type:"ws_created", ws_id:"ws789",\nnode_id:"nodeA", name:"new-task"}
|
||||
deactivate BridgeA
|
||||
|
||||
Redis --> CC : ws_created event
|
||||
CC -> CC : Add workstream to\nNodeSnapshot["nodeA"]
|
||||
CC -> CC : _fanout(event)
|
||||
Server -> Browser : SSE: data: {"type":"ws_created",...}
|
||||
|
||||
== Reverse Proxy (server UI through console port) ==
|
||||
|
||||
Browser -> Server : GET /node/nodeA/
|
||||
|
||||
@@ -14,98 +14,83 @@ node "Docker Host" as host {
|
||||
|
||||
frame "turnstone-net (bridge network)" as net {
|
||||
|
||||
node "redis" <<redis:7.4-alpine>> as redis_node {
|
||||
component [Redis Server\nport 6379] as redis
|
||||
note bottom of redis
|
||||
Healthcheck: redis-cli ping
|
||||
Volume: redis-data
|
||||
end note
|
||||
}
|
||||
|
||||
node "server" <<turnstone image>> as server_node {
|
||||
component [turnstone-server\nport 8080] as server
|
||||
note bottom of server
|
||||
Command: turnstone-server
|
||||
--host 0.0.0.0
|
||||
--port 8080
|
||||
Depends: redis (healthy)
|
||||
Volume: turnstone-data
|
||||
(/data)
|
||||
end note
|
||||
}
|
||||
|
||||
node "bridge ×N" <<turnstone image>> as bridge_node {
|
||||
component [turnstone-bridge] as bridge
|
||||
note bottom of bridge
|
||||
Command: turnstone-bridge
|
||||
--server-url http://server:8080
|
||||
--redis-host redis
|
||||
Depends: server + redis
|
||||
Scalable: --scale bridge=N
|
||||
node_id: auto from hostname
|
||||
end note
|
||||
}
|
||||
|
||||
node "console" <<turnstone image>> as console_node {
|
||||
component [turnstone-console\nport 8090] as console
|
||||
note bottom of console
|
||||
Command: turnstone-console
|
||||
--redis-host redis
|
||||
--port 8090
|
||||
Depends: redis
|
||||
Depends: server
|
||||
Hash-ring router for
|
||||
multi-node clusters
|
||||
end note
|
||||
}
|
||||
|
||||
node "sim (profile: sim)" <<turnstone image>> as sim_node {
|
||||
component [turnstone-sim] as sim
|
||||
note bottom of sim
|
||||
Command: turnstone-sim
|
||||
--redis-host redis
|
||||
--nodes 100
|
||||
--scenario steady
|
||||
Depends: redis
|
||||
Optional: only with
|
||||
--profile sim
|
||||
node "postgres (profile: production)" <<pgautoupgrade>> as pg_node {
|
||||
component [PostgreSQL\nport 5432] as postgres
|
||||
note bottom of postgres
|
||||
Healthcheck: pg_isready
|
||||
Volume: postgres-data
|
||||
Required for cluster
|
||||
and production profiles
|
||||
end note
|
||||
}
|
||||
|
||||
node "pgbouncer (optional)" <<bitnami/pgbouncer>> as pgb_node {
|
||||
component [PgBouncer\nport 6432] as pgbouncer
|
||||
note bottom of pgbouncer
|
||||
pool_mode: transaction
|
||||
Recommended for clusters
|
||||
> 50 nodes
|
||||
See docs/pgbouncer.md
|
||||
end note
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actor "Browser\nUser" as browser
|
||||
actor "MQ Client" as mqclient
|
||||
actor "SDK /\nAPI Client" as apiclient
|
||||
|
||||
' External connections
|
||||
browser --> server : HTTP + SSE\nport 8080
|
||||
browser --> console : HTTP + SSE\nport 8090
|
||||
mqclient --> redis : Redis protocol\nport 6379
|
||||
apiclient --> server : HTTP + SSE\nport 8080
|
||||
|
||||
' Internal connections
|
||||
server --> redis : Redis protocol\n(6379)
|
||||
server --> llm_api : OpenAI API\n(HTTPS/HTTP)
|
||||
|
||||
bridge --> server : HTTP REST\n(POST /v1/api/send, etc.)
|
||||
bridge <-- server : SSE\n(GET /v1/api/events)
|
||||
bridge --> redis : Redis protocol\n(queues + pubsub)
|
||||
console --> server : HTTP proxy\n(hash-ring lookup,\nproxy /node/{id}/*)
|
||||
|
||||
console --> redis : Redis PUBSUB + LIST\n(cluster events,\nws creation commands)
|
||||
console --> server : HTTP polling + proxy\n(GET /v1/api/dashboard,\nproxy /node/{id}/*)
|
||||
|
||||
sim --> redis : Redis protocol\n(queues + pubsub + keys)
|
||||
' Database connections (production/cluster profiles)
|
||||
server ..> pgbouncer : PostgreSQL\n(pool_size=2)
|
||||
console ..> pgbouncer : PostgreSQL\n(auth/admin)
|
||||
pgbouncer --> postgres : transaction\npooling
|
||||
|
||||
' Environment variables
|
||||
note right of host
|
||||
**Environment Variables:**
|
||||
• LLM_BASE_URL — LLM endpoint
|
||||
• OPENAI_API_KEY — API key
|
||||
• REDIS_PASSWORD — Redis auth
|
||||
• TURNSTONE_AUTH_TOKEN — API auth
|
||||
* LLM_BASE_URL -- LLM endpoint
|
||||
* OPENAI_API_KEY -- API key
|
||||
* TURNSTONE_AUTH_TOKEN -- API auth
|
||||
* TURNSTONE_DB_URL -- PostgreSQL URL
|
||||
* POSTGRES_PASSWORD -- DB password
|
||||
end note
|
||||
|
||||
' Volumes
|
||||
database "redis-data" as rv
|
||||
database "turnstone-data" as tv
|
||||
database "postgres-data" as pv
|
||||
|
||||
redis_node --> rv
|
||||
server_node --> tv
|
||||
pg_node --> pv
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -32,6 +32,7 @@ package "turnstone/sdk/ (Python)" {
|
||||
+ approve()
|
||||
+ plan_feedback()
|
||||
+ command()
|
||||
+ cancel(ws_id)
|
||||
+ stream_events(ws_id)
|
||||
+ stream_global_events()
|
||||
+ send_and_wait()
|
||||
@@ -45,6 +46,7 @@ package "turnstone/sdk/ (Python)" {
|
||||
+ nodes()
|
||||
+ workstreams()
|
||||
+ node_detail()
|
||||
+ snapshot()
|
||||
+ create_workstream()
|
||||
+ stream_cluster_events()
|
||||
+ login() / logout()
|
||||
@@ -129,6 +131,7 @@ package "sdk/typescript/ (TypeScript)" {
|
||||
class "TurnstoneConsole" as TSConsole <<ts>> {
|
||||
+ overview()
|
||||
+ nodes()
|
||||
+ snapshot()
|
||||
+ clusterEvents()
|
||||
...
|
||||
}
|
||||
|
||||
@@ -53,10 +53,10 @@ class "SQLiteBackend" as SQLite <<sqlite>> {
|
||||
|
||||
class "PostgreSQLBackend" as PG <<postgres>> {
|
||||
-_engine: sa.Engine
|
||||
+__init__(url: str, pool_size: int)
|
||||
+__init__(url: str, pool_size: int = 2,\n max_overflow: int = 3)
|
||||
--
|
||||
tsvector + ILIKE search
|
||||
Connection pooling
|
||||
Connection pooling (5 max per process)
|
||||
}
|
||||
|
||||
' -- Schema --
|
||||
@@ -64,11 +64,12 @@ class "_schema.py" as Schema <<schema>> {
|
||||
+metadata: MetaData
|
||||
+memories: Table
|
||||
+conversations: Table
|
||||
+workstreams: Table (node_id, alias, title, state)
|
||||
+workstreams: Table (node_id, alias, title,\n state, skill_id)
|
||||
+workstream_config: Table
|
||||
+users: Table (username, password_hash)
|
||||
+api_tokens: Table (token_hash, scopes)
|
||||
+channel_users: Table (channel_type)
|
||||
+scheduled_tasks: Table (..., skill)
|
||||
--
|
||||
SQLAlchemy Core
|
||||
Single source of truth
|
||||
@@ -150,7 +151,7 @@ note right of Registry
|
||||
backend = "sqlite" | "postgresql"
|
||||
url = "postgresql+psycopg://..."
|
||||
path = ".turnstone.db"
|
||||
pool_size = 5
|
||||
pool_size = 2 (+ 3 overflow)
|
||||
end note
|
||||
|
||||
note bottom of SQLite
|
||||
@@ -161,8 +162,9 @@ end note
|
||||
|
||||
note bottom of PG
|
||||
Production backend.
|
||||
Multi-node / Docker
|
||||
default.
|
||||
Multi-node / Docker default.
|
||||
Use PgBouncer (transaction mode)
|
||||
for clusters > 50 nodes.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -176,4 +176,15 @@ note bottom of SH
|
||||
Both share JWT signing secret
|
||||
end note
|
||||
|
||||
note left of JWT
|
||||
**Console Proxy Token Minting**
|
||||
When proxying requests to server nodes:
|
||||
1. Console AuthMiddleware validates user JWT (aud: turnstone-console)
|
||||
2. Proxy mints new JWT (aud: turnstone-server)
|
||||
with real user_id, scopes, permissions
|
||||
3. src: "console-proxy" for audit traceability
|
||||
4. 5-minute expiry (fresh per request)
|
||||
5. Fallback: ServiceTokenManager if no user context
|
||||
end note
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -5,8 +5,6 @@ title Turnstone — Channel Integration Architecture
|
||||
skinparam class {
|
||||
BackgroundColor<<platform>> #E1BEE7
|
||||
BackgroundColor<<service>> #E8EAF6
|
||||
BackgroundColor<<mq>> #FFCDD2
|
||||
BackgroundColor<<bridge>> #C8E6C9
|
||||
BackgroundColor<<server>> #FFE0B2
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
}
|
||||
@@ -53,6 +51,7 @@ class "DiscordBot" as Bot <<service>> {
|
||||
+on_message(msg)
|
||||
+on_interaction(interaction)
|
||||
+send(channel_id, content)
|
||||
+send_notification(channel_id, content, ws_id)
|
||||
+run(token)
|
||||
--
|
||||
discord.py Client
|
||||
@@ -61,51 +60,24 @@ class "DiscordBot" as Bot <<service>> {
|
||||
Creates threads for workstreams
|
||||
Renders approval buttons
|
||||
escape_mentions() on send
|
||||
--
|
||||
_notify_ws_map: msg_id -> (ws_id, user_id)
|
||||
_notify_reply_channels: ws_id -> (dm, user_id)
|
||||
}
|
||||
|
||||
class "ChannelRouter" as Router <<service>> {
|
||||
+resolve_route(platform, channel_id)
|
||||
→ ws_id | None
|
||||
-> ws_id | None
|
||||
+register_route(channel_id, ws_id)
|
||||
+resolve_identity(platform, platform_user_id)
|
||||
→ user_id | None
|
||||
-> user_id | None
|
||||
--
|
||||
Maps channels → workstreams
|
||||
Maps platform users → turnstone users
|
||||
Maps channels -> workstreams
|
||||
Maps platform users -> turnstone users
|
||||
Caches routes in memory
|
||||
}
|
||||
|
||||
class "AsyncRedisBroker" as Broker <<service>> {
|
||||
+push_inbound(msg)
|
||||
+subscribe(ws_id) → AsyncIterator
|
||||
+subscribe_global() → AsyncIterator
|
||||
+push_response(correlation_id, msg)
|
||||
--
|
||||
redis.asyncio client
|
||||
Pub/sub + queue operations
|
||||
}
|
||||
|
||||
' -- Redis MQ --
|
||||
class "Redis MQ" as Redis <<mq>> {
|
||||
turnstone:inbound (LIST)
|
||||
turnstone:events:{ws_id} (PUBSUB)
|
||||
turnstone:events:global (PUBSUB)
|
||||
turnstone:resp:{corr_id} (LIST)
|
||||
--
|
||||
Shared message bus
|
||||
Same queues as bridge protocol
|
||||
}
|
||||
|
||||
' -- Bridge + Server --
|
||||
class "turnstone-bridge" as Bridge <<bridge>> {
|
||||
BLPOP turnstone:inbound
|
||||
Drive server via HTTP
|
||||
Relay SSE → Redis pub/sub
|
||||
--
|
||||
Owns workstream lifecycle
|
||||
Auto-approve / manual approve
|
||||
}
|
||||
|
||||
' -- Server --
|
||||
class "turnstone-server" as Server <<server>> {
|
||||
POST /v1/api/send
|
||||
POST /v1/api/approve
|
||||
@@ -124,7 +96,7 @@ class "channel_users" as CU <<storage>> {
|
||||
channel_user_id (PK)
|
||||
platform: "discord" | "slack"
|
||||
platform_user_id
|
||||
user_id → users
|
||||
user_id -> users
|
||||
linked_at
|
||||
--
|
||||
/link command creates row
|
||||
@@ -157,18 +129,13 @@ class "services" as SVC <<storage>> {
|
||||
' -- Relationships --
|
||||
Discord --> Bot : gateway\nevents
|
||||
Bot --> Router : on_message\non_interaction
|
||||
Router --> Broker : SendMessage\nApproveMessage
|
||||
Router --> CU : resolve identity
|
||||
Router --> CR : resolve / register route
|
||||
Broker --> Redis : RPUSH inbound\nRPUSH resp:{id}
|
||||
|
||||
Redis --> Bridge : BLPOP inbound
|
||||
Bridge --> Server : HTTP API
|
||||
Server --> Bridge : SSE events
|
||||
Bridge --> Redis : PUBLISH events:{ws_id}\nPUBLISH events:global
|
||||
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
|
||||
|
||||
Redis --> Broker : SUBSCRIBE events:{ws_id}
|
||||
Broker --> Bot : event stream
|
||||
Bot --> Discord : reply / embed\nbutton callback
|
||||
|
||||
Slack .[hidden]. Discord
|
||||
@@ -176,10 +143,9 @@ Teams .[hidden]. Slack
|
||||
|
||||
ChannelService --> Bot : creates + runs
|
||||
ChannelService --> Router : creates
|
||||
ChannelService --> Broker : creates
|
||||
ChannelService --> SVC : register / heartbeat /\nderegister
|
||||
|
||||
' -- Notification path (direct HTTP, bypasses MQ) --
|
||||
' -- Notification path (direct HTTP) --
|
||||
Server --> ChannelService : POST /v1/api/notify\n(JWT: aud=turnstone-channel)
|
||||
Server --> SVC : list_services("channel",\nmax_age_seconds=120)
|
||||
|
||||
@@ -188,38 +154,36 @@ note right of Bot
|
||||
**Inbound Flow**
|
||||
1. Discord message arrives via gateway
|
||||
2. Bot.on_message() fires
|
||||
3. ChannelRouter resolves channel → ws_id
|
||||
3. ChannelRouter resolves channel -> ws_id
|
||||
(or creates new workstream)
|
||||
4. ChannelRouter resolves platform user → user_id
|
||||
4. ChannelRouter resolves platform user -> user_id
|
||||
via channel_users table
|
||||
5. Broker.push_inbound(SendMessage)
|
||||
6. Bridge pops from Redis, drives server
|
||||
5. Router sends POST /v1/api/send to server
|
||||
|
||||
**Workstream Resume (evicted workstreams)**
|
||||
1. Stale route detected (no MQ owner)
|
||||
1. Stale route detected (no active SSE listener)
|
||||
2. Existing ws_id reused directly from route
|
||||
3. CreateWorkstreamMessage sent with
|
||||
3. POST /v1/api/workstreams/new with
|
||||
resume_ws=<ws_id>
|
||||
4. Server resumes atomically during creation
|
||||
5. Bridge emits WorkstreamResumedEvent → thread
|
||||
5. SSE emits WorkstreamResumedEvent -> thread
|
||||
end note
|
||||
|
||||
note right of Broker
|
||||
note right of Server
|
||||
**Outbound Flow**
|
||||
1. Server emits SSE events
|
||||
2. Bridge relays to Redis events:{ws_id}
|
||||
3. Broker.subscribe(ws_id) yields events
|
||||
4. Bot formats and sends to Discord thread
|
||||
1. Server emits SSE events on
|
||||
GET /v1/api/events?ws_id=
|
||||
2. Bot subscribes via httpx-sse
|
||||
3. Bot formats and sends to Discord thread
|
||||
end note
|
||||
|
||||
note bottom of CR
|
||||
**Approval Flow**
|
||||
1. ApprovalRequestEvent arrives via events:{ws_id}
|
||||
1. ApprovalRequestEvent arrives via SSE
|
||||
2. Bot renders Discord buttons (Approve / Deny)
|
||||
3. User clicks button → on_interaction()
|
||||
3. User clicks button -> on_interaction()
|
||||
4. Router builds ApproveMessage
|
||||
5. Broker.push_response(correlation_id, msg)
|
||||
6. Bridge pops from resp:{id}, calls POST /api/approve
|
||||
5. Router sends POST /v1/api/approve to server
|
||||
end note
|
||||
|
||||
note bottom of CU
|
||||
@@ -234,17 +198,26 @@ note bottom of CU
|
||||
end note
|
||||
|
||||
note bottom of SVC
|
||||
**Notification Flow** (direct HTTP, bypasses MQ)
|
||||
1. LLM calls notify tool → _prepare_notify()
|
||||
**Notification Flow** (direct HTTP)
|
||||
1. LLM calls notify tool -> _prepare_notify()
|
||||
2. _exec_notify() checks rate limit (5/turn)
|
||||
3. Queries services table for healthy gateways
|
||||
4. Mints JWT (aud: turnstone-channel) via
|
||||
ServiceTokenManager
|
||||
5. POSTs to first healthy gateway
|
||||
5. POSTs to first healthy gateway (incl. ws_id)
|
||||
6. Gateway validates JWT, resolves target
|
||||
7. adapter.send() → Discord API
|
||||
8. On failure: retry up to 3× (1s, 3s backoff)
|
||||
7. adapter.send_notification() -> Discord API
|
||||
(tracks msg_id -> ws_id for reply routing)
|
||||
8. On failure: retry up to 3x (1s, 3s backoff)
|
||||
9. SSRF: only http(s) URLs allowed
|
||||
|
||||
**Bidirectional DM Replies**
|
||||
1. User replies to notification DM
|
||||
2. Bot looks up ws_id from _notify_ws_map
|
||||
3. Verifies author == notification recipient
|
||||
4. Routes reply via router.send_message()
|
||||
5. Response forwarded to DM on TurnCompleteEvent
|
||||
6. Response tracked for multi-turn conversation
|
||||
end note
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -103,6 +103,41 @@ alt all retries exhausted
|
||||
Session --> Session : "Error: notification delivery failed"
|
||||
end
|
||||
|
||||
== Bidirectional Reply (User responds to notification DM) ==
|
||||
|
||||
Discord -> Adapter : user replies to\nnotification message
|
||||
Adapter -> Adapter : lookup message_id\nin _notify_ws_map
|
||||
note right
|
||||
Maps message_id →
|
||||
(ws_id, target_user_id)
|
||||
Atomic pop prevents TOCTOU
|
||||
end note
|
||||
|
||||
alt message not tracked
|
||||
Adapter -> Discord : "This notification\nis no longer active."
|
||||
else tracked
|
||||
Adapter -> Adapter : verify author ==\ntarget_user_id
|
||||
Adapter -> Adapter : resolve_user()\n(unlinked → drop)
|
||||
Adapter -> Adapter : router.send_message(ws_id, content)
|
||||
note right
|
||||
Routes reply via MQ to
|
||||
the originating workstream.
|
||||
Registers DM channel in
|
||||
_notify_reply_channels[ws_id]
|
||||
end note
|
||||
|
||||
... workstream processes reply ...
|
||||
|
||||
Adapter <- Adapter : TurnCompleteEvent\n(with content)
|
||||
Adapter -> Discord : forward response to DM
|
||||
Adapter -> Adapter : track response message\nfor multi-turn replies
|
||||
note right
|
||||
Response message_id added
|
||||
to _notify_ws_map — user can
|
||||
reply again indefinitely
|
||||
end note
|
||||
end
|
||||
|
||||
== Service Registry (Background) ==
|
||||
|
||||
note over Gateway, Storage
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Watch Tool Architecture
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<server>> #FFE0B2
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<session>> #C8E6C9
|
||||
BackgroundColor<<ui>> #E8EAF6
|
||||
}
|
||||
|
||||
participant "ChatSession\n(session.py)" as Session <<session>>
|
||||
participant "WatchRunner\n(watch.py)" as Runner <<server>>
|
||||
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
|
||||
participant "WebUI / SSE\n(server.py)" as UI <<ui>>
|
||||
|
||||
== Create Phase ==
|
||||
|
||||
Session -> Session : _prepare_watch(action="create")
|
||||
note right
|
||||
Validates:
|
||||
- command via is_command_blocked()
|
||||
- poll_every → parse_duration()
|
||||
- stop_on → validate_condition()
|
||||
- max watches limit (5)
|
||||
- duplicate name check
|
||||
needs_approval = True
|
||||
end note
|
||||
|
||||
Session -> Storage : create_watch(watch_id, ws_id,\nnode_id, command, interval,\nstop_on, max_polls, next_poll)
|
||||
|
||||
Session --> UI : tool_result:\n"Watch 'pr-review' created"
|
||||
|
||||
== Poll Phase (WatchRunner daemon, every 15s) ==
|
||||
|
||||
Runner -> Storage : list_due_watches(now)
|
||||
Storage --> Runner : due_watches[]
|
||||
note right
|
||||
Filters:
|
||||
active=1 AND
|
||||
next_poll <= now AND
|
||||
node_id matches
|
||||
end note
|
||||
|
||||
loop for each due watch
|
||||
|
||||
Runner -> Runner : is_command_blocked()?
|
||||
alt blocked
|
||||
Runner -> Storage : update_watch(active=False)
|
||||
else safe
|
||||
|
||||
Runner -> Runner : subprocess.run(command)
|
||||
note right
|
||||
timeout = tool_timeout
|
||||
start_new_session = True
|
||||
output truncated at 64KB
|
||||
end note
|
||||
|
||||
Runner -> Runner : evaluate_condition(\nstop_on, output,\nexit_code, prev_output)
|
||||
note right
|
||||
**Variables:**
|
||||
output, data, exit_code,
|
||||
prev_output, changed
|
||||
|
||||
**Safe builtins only:**
|
||||
len, str, int, sorted, ...
|
||||
No import/open/exec/eval
|
||||
|
||||
**stop_on=None:**
|
||||
fires on change (skip 1st poll)
|
||||
end note
|
||||
|
||||
alt condition fired OR max_polls reached
|
||||
Runner -> Storage : update_watch(\npoll_count++,\nlast_output, active=False)
|
||||
Runner -> Runner : format_watch_message()
|
||||
Runner -> Runner : _dispatch_result(ws_id, msg)
|
||||
else not fired
|
||||
Runner -> Storage : update_watch(\npoll_count++,\nlast_output, next_poll)
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
== Dispatch Phase ==
|
||||
|
||||
note over Runner, Session
|
||||
**Three dispatch paths:**
|
||||
end note
|
||||
|
||||
alt Path A: workstream active + idle
|
||||
Runner -> Session : dispatch_fn(message)\n→ _watch_pending.put()
|
||||
Session -> Session : _dispatch_pending_watch()\n→ self.send(message)
|
||||
Session -> UI : SSE: thinking, content,\ntool calls...
|
||||
note right
|
||||
Watch result appears as
|
||||
synthetic user message.
|
||||
Model sees it and responds.
|
||||
Depth guard: max 5 chains.
|
||||
end note
|
||||
|
||||
else Path B: workstream active + busy
|
||||
Runner -> Session : dispatch_fn(message)\n→ _watch_pending.put()
|
||||
note right
|
||||
Queued. Dispatched when
|
||||
current send() reaches IDLE.
|
||||
end note
|
||||
|
||||
else Path C: workstream evicted
|
||||
Runner -> Runner : restore_fn(ws_id)
|
||||
note right
|
||||
1. mgr.create() — may evict
|
||||
another idle workstream
|
||||
2. session.resume(ws_id)
|
||||
3. set_watch_runner()
|
||||
4. register new dispatch_fn
|
||||
end note
|
||||
Runner -> Session : restored dispatch_fn(message)
|
||||
end
|
||||
|
||||
== Cancel / List ==
|
||||
|
||||
Session -> Storage : list_watches_for_ws(ws_id)
|
||||
note right : action="list" (auto-approve)
|
||||
|
||||
Session -> Storage : update_watch(active=False)
|
||||
note right : action="cancel" (auto-approve)
|
||||
|
||||
== Server Lifecycle ==
|
||||
|
||||
note over Runner, Storage
|
||||
**Startup:**
|
||||
1. WatchRunner created in main() with storage + node_id
|
||||
2. restore_fn closure captures WorkstreamManager
|
||||
3. Initial workstream: session.set_watch_runner(runner)
|
||||
4. _lifespan(): runner.start() — daemon thread begins
|
||||
|
||||
**New workstream:**
|
||||
session.set_watch_runner(runner) in create_workstream()
|
||||
→ registers dispatch_fn for ws_id
|
||||
|
||||
**Eviction / close:**
|
||||
session.close() → runner.remove_dispatch_fn(ws_id)
|
||||
Watches remain active in DB — WatchRunner uses restore_fn
|
||||
|
||||
**Restart recovery:**
|
||||
Overdue watches fire ONE immediate poll
|
||||
next_poll updated to now + interval
|
||||
Normal cadence resumes
|
||||
|
||||
**Shutdown:**
|
||||
_lifespan(): runner.stop() — joins thread
|
||||
end note
|
||||
|
||||
== REST API ==
|
||||
|
||||
note over UI, Storage
|
||||
**GET /v1/api/watches[?ws_id=X]**
|
||||
List active watches (for node or workstream)
|
||||
|
||||
**POST /v1/api/watches/{watch_id}/cancel**
|
||||
Cancel a watch (sets active=False)
|
||||
|
||||
Both require write scope
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,100 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName "IBM Plex Mono"
|
||||
skinparam componentStyle rectangle
|
||||
|
||||
title Turnstone Governance Architecture
|
||||
|
||||
package "Auth Flow" {
|
||||
[Login/Token Auth] as auth
|
||||
[_load_user_permissions()] as perms
|
||||
[_permissions_to_scopes()] as scopes
|
||||
[create_jwt()] as jwt
|
||||
}
|
||||
|
||||
package "Middleware" {
|
||||
[AuthMiddleware\n(scope check)] as mw
|
||||
[require_permission()\n(granular check)] as rp
|
||||
}
|
||||
|
||||
package "Governance Storage" {
|
||||
database "roles" as roles_db
|
||||
database "user_roles" as ur_db
|
||||
database "orgs" as orgs_db
|
||||
database "tool_policies" as tp_db
|
||||
database "prompt_templates\n(skills)" as pt_db
|
||||
database "usage_events" as ue_db
|
||||
database "audit_events" as ae_db
|
||||
database "skills" as wt_db
|
||||
}
|
||||
|
||||
package "Runtime Enforcement" {
|
||||
[evaluate_tool_policies_batch()] as eval
|
||||
[WebUI.approve_tools()] as approve
|
||||
[record_usage_event()\n+cache_creation/read_tokens] as usage
|
||||
[record_audit()] as audit
|
||||
}
|
||||
|
||||
package "Template Runtime" {
|
||||
[_load_templates()] as tload
|
||||
[_render_template()\n{{model}}, {{ws_id}}, {{node_id}}] as trender
|
||||
[_init_system_messages()] as tsys
|
||||
[set_template() / /template] as tset
|
||||
}
|
||||
|
||||
package "Skill Runtime" {
|
||||
[resolve_skill()] as wtr
|
||||
[apply settings\n(model, budget, prompt)] as wta
|
||||
[budget gate\n(session.send)] as wtb
|
||||
}
|
||||
|
||||
package "Console UI" {
|
||||
[Admin Panel\n10 tabs] as ui
|
||||
[governance.js] as govjs
|
||||
[sessionStorage\npermissions] as ss
|
||||
}
|
||||
|
||||
auth --> perms : user_id
|
||||
perms --> roles_db : JOIN user_roles + roles
|
||||
perms --> scopes : permission set
|
||||
scopes --> jwt : scopes + permissions
|
||||
|
||||
jwt --> mw : JWT in cookie/header
|
||||
mw --> rp : scope OK → check permission
|
||||
|
||||
rp --> ui : 403 or allow
|
||||
|
||||
eval --> tp_db : list_tool_policies()
|
||||
approve --> eval : tool names
|
||||
approve --> ae_db : (via audit)
|
||||
|
||||
usage --> ue_db : on_status()
|
||||
audit --> ae_db : admin handlers
|
||||
|
||||
govjs --> roles_db : /v1/api/admin/roles
|
||||
govjs --> tp_db : /v1/api/admin/policies
|
||||
govjs --> pt_db : /v1/api/admin/skills
|
||||
govjs --> ue_db : /v1/api/admin/usage
|
||||
govjs --> ae_db : /v1/api/admin/audit
|
||||
|
||||
tload --> pt_db : list_default_templates()\nor get_by_name()
|
||||
tload --> trender : template content
|
||||
trender --> tsys : rendered content
|
||||
tset --> tload : name or None
|
||||
|
||||
note right of pt_db
|
||||
Read-only listing:
|
||||
GET /v1/api/skills
|
||||
(read scope, summary only)
|
||||
end note
|
||||
|
||||
govjs --> wt_db : /v1/api/admin/skills
|
||||
wtr --> wt_db : get_skill_by_name()
|
||||
wtr --> wta : skill settings
|
||||
wta --> pt_db : skill lookup
|
||||
wtb --> approve : __budget_override__
|
||||
|
||||
auth -[hidden]-> mw
|
||||
mw -[hidden]-> approve
|
||||
@enduml
|
||||
@@ -0,0 +1,203 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — MCP Architecture (Resources, Prompts, Tools)
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<mcp>> #E1BEE7
|
||||
BackgroundColor<<session>> #C8E6C9
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<server>> #FFE0B2
|
||||
BackgroundColor<<ui>> #E8EAF6
|
||||
BackgroundColor<<registry>> #F8BBD0
|
||||
}
|
||||
|
||||
participant "MCP Server\n(external)" as MCPSrv <<mcp>>
|
||||
participant "MCPClientManager\n(mcp_client.py)" as MCPMgr <<mcp>>
|
||||
participant "ChatSession\n(session.py)" as Session <<session>>
|
||||
participant "StorageBackend\n(governance)" as Storage <<storage>>
|
||||
participant "Server / Console\n(health + UI)" as UI <<server>>
|
||||
|
||||
participant "Console Admin UI\n(admin panel)" as Admin <<ui>>
|
||||
participant "Database\n(mcp_servers table)" as DB <<storage>>
|
||||
|
||||
participant "MCPRegistryClient\n(mcp_registry.py)" as RegClient <<mcp>>
|
||||
participant "MCP Registry\n(registry.modelcontextprotocol.io)" as Registry <<registry>>
|
||||
|
||||
== Admin-Driven Configuration ==
|
||||
|
||||
Admin -> DB : CRUD MCP server definitions\n(POST/PUT/DELETE /v1/api/admin/mcp-servers)
|
||||
|
||||
Admin -> UI : POST /v1/api/admin/mcp-servers/reload
|
||||
UI -> MCPMgr : POST /_internal/mcp-reload\n(forwarded to each node)
|
||||
MCPMgr -> MCPMgr : reconcile_sync()
|
||||
note right
|
||||
Diffs running servers against DB:
|
||||
- New entries → connect
|
||||
- Removed entries → disconnect
|
||||
- Changed entries → reconnect
|
||||
end note
|
||||
|
||||
== Registry Discovery & Install ==
|
||||
|
||||
Admin -> UI : GET /v1/api/admin/mcp-registry/search?search=...
|
||||
UI -> RegClient : search(q, limit, cursor)
|
||||
RegClient -> Registry : GET /v0.1/servers?search=...&latest=true
|
||||
Registry --> RegClient : Server entries\n(remotes, packages, meta)
|
||||
RegClient --> UI : RegistrySearchResult\n(annotated with installed status)
|
||||
UI --> Admin : Search results\n(Install / Installed badges)
|
||||
|
||||
Admin -> UI : POST /v1/api/admin/mcp-registry/install
|
||||
UI -> DB : create_mcp_server()\n(registry_name, version, meta)
|
||||
UI -> MCPMgr : POST /_internal/mcp-reload\n(fan-out to nodes)
|
||||
MCPMgr -> MCPMgr : reconcile_sync()
|
||||
MCPMgr -> MCPSrv : connect to new server
|
||||
|
||||
note over RegClient, Registry
|
||||
MCPRegistryClient is an async httpx client
|
||||
targeting registry.modelcontextprotocol.io/v0.1.
|
||||
resolve_install_config() translates registry
|
||||
remotes/packages into mcp_servers rows.
|
||||
end note
|
||||
|
||||
== Startup: Connection & Discovery ==
|
||||
|
||||
MCPMgr -> DB : load_mcp_config(storage=)\n(merge config file + DB)
|
||||
|
||||
MCPMgr -> MCPSrv : initialize (stdio or HTTP)
|
||||
MCPSrv --> MCPMgr : capabilities\n(tools, resources, prompts)
|
||||
|
||||
MCPMgr -> MCPSrv : tools/list
|
||||
MCPSrv --> MCPMgr : Tool[]
|
||||
|
||||
opt resources capability
|
||||
MCPMgr -> MCPSrv : resources/list
|
||||
MCPSrv --> MCPMgr : Resource[]
|
||||
MCPMgr -> MCPSrv : resources/templates/list
|
||||
MCPSrv --> MCPMgr : ResourceTemplate[]
|
||||
end
|
||||
|
||||
opt prompts capability
|
||||
MCPMgr -> MCPSrv : prompts/list
|
||||
MCPSrv --> MCPMgr : Prompt[]
|
||||
end
|
||||
|
||||
note over MCPMgr
|
||||
Per-server storage:
|
||||
_per_server_tools, _per_server_resources, _per_server_prompts
|
||||
Copy-on-write rebuild into _tools, _resources, _prompts
|
||||
Prefix: mcp__{server}__{name}
|
||||
end note
|
||||
|
||||
MCPMgr -> Session : notify tool listeners
|
||||
MCPMgr -> Session : notify resource listeners
|
||||
|
||||
== Governance Sync (on connect & refresh) ==
|
||||
|
||||
MCPMgr -> Storage : sync_prompts_to_storage()
|
||||
note right
|
||||
For each MCP prompt:
|
||||
- Manual template exists? → skip
|
||||
- MCP template exists? → update
|
||||
(reset is_default=False)
|
||||
- New? → create (origin="mcp",
|
||||
readonly=True, is_default=False)
|
||||
Removed prompts → delete
|
||||
Protected by _sync_lock
|
||||
end note
|
||||
|
||||
== set_storage() from entry point ==
|
||||
|
||||
UI -> MCPMgr : set_storage(backend)
|
||||
note right
|
||||
If servers already connected,
|
||||
triggers immediate sync
|
||||
end note
|
||||
|
||||
== Runtime: Tool Execution ==
|
||||
|
||||
Session -> Session : _prepare_mcp_tool(func_name, args)
|
||||
note right
|
||||
approval_label = func_name
|
||||
(e.g. mcp__github__search)
|
||||
needs_approval = True
|
||||
end note
|
||||
Session -> MCPMgr : call_tool_sync(name, args)
|
||||
MCPMgr -> MCPSrv : tools/call
|
||||
MCPSrv --> MCPMgr : ToolResult
|
||||
MCPMgr --> Session : output (text)
|
||||
|
||||
== Runtime: Resource Read ==
|
||||
|
||||
Session -> Session : _prepare_read_resource(uri)
|
||||
note right
|
||||
approval_label = mcp_resource__{normalized_uri}
|
||||
URI normalized (.. resolved)
|
||||
needs_approval = True
|
||||
end note
|
||||
Session -> MCPMgr : read_resource_sync(uri)
|
||||
MCPMgr -> MCPSrv : resources/read
|
||||
MCPSrv --> MCPMgr : ReadResourceResult
|
||||
MCPMgr --> Session : content (text/blob)
|
||||
|
||||
== Runtime: Prompt Invocation ==
|
||||
|
||||
Session -> Session : _prepare_use_prompt(name, arguments)
|
||||
note right
|
||||
approval_label = mcp__srv__prompt
|
||||
Validated via is_mcp_prompt()
|
||||
needs_approval = True
|
||||
end note
|
||||
Session -> MCPMgr : get_prompt_sync(name, args)
|
||||
MCPMgr -> MCPSrv : prompts/get
|
||||
MCPSrv --> MCPMgr : GetPromptResult
|
||||
MCPMgr --> Session : messages [{role, content}]
|
||||
|
||||
== Three-Tier Refresh ==
|
||||
|
||||
group Push Notifications
|
||||
MCPSrv -> MCPMgr : ToolListChangedNotification
|
||||
MCPMgr -> MCPMgr : _refresh_server_tools()
|
||||
|
||||
MCPSrv -> MCPMgr : ResourceListChangedNotification
|
||||
MCPMgr -> MCPMgr : _refresh_server_resources()
|
||||
|
||||
MCPSrv -> MCPMgr : PromptListChangedNotification
|
||||
MCPMgr -> MCPMgr : _refresh_server_prompts()
|
||||
MCPMgr -> Storage : sync_prompts_to_storage()
|
||||
end
|
||||
|
||||
group Periodic Polling (default 4h)
|
||||
MCPMgr -> MCPMgr : _periodic_refresh()
|
||||
note right
|
||||
Only polls capabilities
|
||||
without push support.
|
||||
Staggered per-server.
|
||||
end note
|
||||
end
|
||||
|
||||
group Manual Refresh
|
||||
Session -> MCPMgr : refresh_sync()
|
||||
note right: /mcp refresh [server]
|
||||
end
|
||||
|
||||
== Policy Evaluation ==
|
||||
|
||||
note over Session
|
||||
Tool policies use fnmatch on approval_label:
|
||||
- mcp__github__* → allow (all GitHub tools/prompts)
|
||||
- mcp_resource__file:///docs/* → allow
|
||||
- mcp_resource__* → deny (block all resource reads)
|
||||
- mcp__untrusted__* → ask
|
||||
end note
|
||||
|
||||
== UI Visibility ==
|
||||
|
||||
UI -> MCPMgr : server_count, get_resources(), get_prompts()
|
||||
note over UI
|
||||
/health → mcp.servers, mcp.resources, mcp.prompts
|
||||
Server UI: magenta status badge
|
||||
Console: cluster status bar + node detail
|
||||
System message: <mcp-resources> + <mcp-prompts> catalogs
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,218 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Intent Validation (Judge) Architecture
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<session>> #C8E6C9
|
||||
BackgroundColor<<judge>> #FFE0B2
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<ui>> #E8EAF6
|
||||
BackgroundColor<<fs>> #F5F5F5
|
||||
}
|
||||
|
||||
participant "ChatSession\n(session.py)" as Session <<session>>
|
||||
participant "IntentJudge\n(judge.py)" as Judge <<judge>>
|
||||
participant "LLM Provider\n(provider)" as LLM <<judge>>
|
||||
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
|
||||
participant "WebUI / SSE\n(server.py)" as UI <<ui>>
|
||||
participant "Filesystem" as FS <<fs>>
|
||||
|
||||
== Tool Call Requires Approval ==
|
||||
|
||||
Session -> Session : _prepare_tool_calls()
|
||||
note right
|
||||
Tool calls parsed from
|
||||
LLM response. Auto-approved
|
||||
tools dispatched immediately.
|
||||
Remaining items need approval.
|
||||
end note
|
||||
|
||||
Session -> Session : _evaluate_intent(pending_items)
|
||||
|
||||
== Tier 1: Heuristic (synchronous, sub-ms) ==
|
||||
|
||||
Session -> Judge : evaluate(items, messages, callback)
|
||||
|
||||
Judge -> Judge : evaluate_heuristic()\nfor each item
|
||||
note right
|
||||
**36 rules (first match wins):**
|
||||
Critical (0.90, deny): rm /, mkfs,
|
||||
dd, pipe-to-shell, chmod 777 /,
|
||||
write/edit /etc/ .ssh/,
|
||||
download-then-execute chains
|
||||
High (0.80, review): sudo, kill -9,
|
||||
destructive git, DROP TABLE,
|
||||
secrets, HTTP mutations, ssh/scp,
|
||||
browser+data-export, transitive
|
||||
install, control-plane mutation
|
||||
Medium (0.70, review): content
|
||||
ingestion, interpreter exec,
|
||||
cloud CLI mutations, pkg install,
|
||||
write_file, MCP tools, docker ops
|
||||
Low (0.85, approve): read_file,
|
||||
list_directory, search, recall,
|
||||
tool_search, read_resource,
|
||||
web_search, read-only bash
|
||||
Default: medium, 0.50, review
|
||||
end note
|
||||
|
||||
Judge --> Session : heuristic_verdicts[]
|
||||
|
||||
Session -> Session : attach _heuristic_verdict\nto each pending item
|
||||
|
||||
Session -> UI : SSE: approve_request\n{items: [{verdict: ...}],\n judge_pending: true}
|
||||
note right
|
||||
Heuristic verdict displayed
|
||||
immediately as risk badge.
|
||||
Spinner shown while LLM
|
||||
judge evaluates.
|
||||
end note
|
||||
|
||||
Session -> Storage : create_intent_verdict()\nfor each heuristic verdict
|
||||
|
||||
== Tier 2: LLM Judge (daemon thread, async) ==
|
||||
|
||||
Judge -> Judge : spawn daemon thread\n"intent-judge"
|
||||
|
||||
note over Judge, LLM
|
||||
**Context preparation:**
|
||||
1. FIFO-truncate conversation history
|
||||
to max_context_ratio of context window
|
||||
2. Append tool call details as user message
|
||||
3. System prompt defines judge role + JSON schema
|
||||
end note
|
||||
|
||||
loop up to 3 turns (timeout budget)
|
||||
|
||||
Judge -> LLM : create_completion(\nmodel, judge_messages,\ntools=[read_file, list_directory])
|
||||
LLM --> Judge : CompletionResult
|
||||
|
||||
alt tool_calls present (turn < 3)
|
||||
Judge -> Judge : _exec_read_only_tool()
|
||||
note right
|
||||
**Security hardening:**
|
||||
Blocked: /etc/, /root/,
|
||||
/proc/, /sys/, /dev/,
|
||||
.ssh, .gnupg, .aws,
|
||||
*.pem, *.key, *.p12
|
||||
File cap: 32KB
|
||||
Dir cap: 200 entries
|
||||
end note
|
||||
Judge -> FS : read_file / list_directory
|
||||
FS --> Judge : file contents
|
||||
Judge -> Judge : append tool result\nto judge_messages
|
||||
else text response (final verdict)
|
||||
Judge -> Judge : _parse_verdict()
|
||||
note right
|
||||
**4-stage JSON parsing:**
|
||||
1. Direct JSON.loads
|
||||
2. Markdown code block
|
||||
3. Brace-counting
|
||||
4. Regex field extraction
|
||||
end note
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
== Tier 3: Arbitration ==
|
||||
|
||||
Judge -> Judge : compare confidence:\nLLM vs heuristic
|
||||
note right
|
||||
Only deliver LLM verdict
|
||||
if confidence > heuristic.
|
||||
Otherwise heuristic stands.
|
||||
end note
|
||||
|
||||
alt LLM confidence > heuristic confidence
|
||||
Judge -> Session : callback(llm_verdict)
|
||||
Session -> UI : SSE: intent_verdict\n{tier: "llm", ...}
|
||||
note right
|
||||
UI replaces heuristic badge
|
||||
with LLM verdict. Spinner
|
||||
resolves to final assessment.
|
||||
end note
|
||||
Session -> Storage : create_intent_verdict()\nfor LLM verdict
|
||||
end
|
||||
|
||||
== User Decision ==
|
||||
|
||||
UI -> Session : resolve_approval(\napproved, feedback)
|
||||
|
||||
Session -> Storage : update_intent_verdict(\nverdict_id, user_decision)
|
||||
note right
|
||||
All tracked verdicts
|
||||
(heuristic + LLM) updated
|
||||
with "approved" or "denied".
|
||||
Swap-and-clear avoids racing
|
||||
with daemon judge thread.
|
||||
end note
|
||||
|
||||
== Tool Execution ==
|
||||
|
||||
Session -> Session : _execute_tools()
|
||||
note right
|
||||
Tools execute with
|
||||
user approval.
|
||||
end note
|
||||
|
||||
== Output Guard (synchronous, time-budgeted) ==
|
||||
|
||||
Session -> Session : _evaluate_output()\nfor each tool result
|
||||
note right
|
||||
**Priority-ordered checks (5s budget):**
|
||||
P1: Prompt injection (role injection,
|
||||
override phrases, instruction tags)
|
||||
P2: Credential leakage (API keys,
|
||||
PEM blocks, connection strings)
|
||||
P3: Encoded payloads (data URIs,
|
||||
hex shellcode)
|
||||
P4: Adversarial URLs (cloud metadata,
|
||||
credential query params)
|
||||
P5: System info disclosure (private
|
||||
IPs, sensitive paths)
|
||||
|
||||
Annotates + optionally redacts.
|
||||
Does NOT gate.
|
||||
end note
|
||||
|
||||
alt output_warning flags detected
|
||||
Session -> UI : SSE: output_warning\n{call_id, risk_level, flags,\nfunc_name, redacted}
|
||||
note right
|
||||
Credential values replaced
|
||||
with [REDACTED:<type>] before
|
||||
output enters conversation.
|
||||
sanitized text excluded from
|
||||
SSE payload (defense in depth).
|
||||
end note
|
||||
UI -> Storage : record_output_assessment()\nfire-and-forget persistence
|
||||
note right
|
||||
Stored: flags, risk_level,
|
||||
annotations, output_length,
|
||||
redacted (bool). Raw tool
|
||||
output is never stored.
|
||||
end note
|
||||
end
|
||||
|
||||
== Lifecycle ==
|
||||
|
||||
note over Session, Judge
|
||||
**Lazy initialization:**
|
||||
IntentJudge created on first approval if judge_config.enabled.
|
||||
Re-uses session's provider/client by default (self-consistency).
|
||||
Cross-model: separate provider/client from [judge] config.
|
||||
|
||||
**Sub-agent exemption:**
|
||||
Plan agent and task agent skip intent validation entirely.
|
||||
|
||||
**Output guard:**
|
||||
Runs when judge_config.output_guard is true (default).
|
||||
Credential redaction when judge_config.redact_secrets is true.
|
||||
|
||||
**Storage:**
|
||||
intent_verdicts table (migration 012), output_assessments table
|
||||
(migration 022). Both queryable via admin API endpoints
|
||||
(requires admin.judge permission). Skills store scan_status,
|
||||
scan_report, scan_version for install-time risk assessment.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,159 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Structured Memory Architecture
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<session>> #C8E6C9
|
||||
BackgroundColor<<facade>> #FFE0B2
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<api>> #E8EAF6
|
||||
BackgroundColor<<sdk>> #F5F5F5
|
||||
}
|
||||
|
||||
participant "ChatSession\n(session.py)" as Session <<session>>
|
||||
participant "MemoryFacade\n(memory.py)" as Facade <<facade>>
|
||||
participant "MemoryRelevance\n(memory_relevance.py)" as Relevance <<facade>>
|
||||
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
|
||||
participant "Server API\n(server.py)" as API <<api>>
|
||||
participant "Console Admin\n(console/server.py)" as Admin <<api>>
|
||||
participant "SDK Client\n(sdk/)" as SDK <<sdk>>
|
||||
|
||||
== Phase 1: Tool Path (session.send) ==
|
||||
|
||||
Session -> Session : _prepare_tool_calls()\nparse memory(action=...)
|
||||
note right
|
||||
Tool schema: 4 actions
|
||||
save, search, delete, list
|
||||
Auto-approved (no approval needed)
|
||||
end note
|
||||
|
||||
Session -> Session : _exec_memory(item)
|
||||
|
||||
alt action = save
|
||||
Session -> Facade : save_structured_memory(\nname, content, description,\nmem_type, scope, scope_id)
|
||||
Facade -> Facade : normalize_key(name)
|
||||
Facade -> Storage : create_structured_memory()
|
||||
alt unique constraint violation
|
||||
Storage --> Facade : IntegrityError
|
||||
Facade -> Storage : get_structured_memory_by_name()
|
||||
Storage --> Facade : existing row
|
||||
Facade -> Storage : update_structured_memory()
|
||||
end
|
||||
Storage --> Facade : memory_id
|
||||
Facade --> Session : (memory_id, old_content)
|
||||
Session -> Session : _init_system_messages()\nrefresh BM25 context
|
||||
end
|
||||
|
||||
alt action = search
|
||||
Session -> Facade : search_structured_memories(\nquery, mem_type, scope,\nscope_id, limit)
|
||||
Facade -> Storage : search_structured_memories()
|
||||
Storage --> Session : matched rows
|
||||
end
|
||||
|
||||
alt action = delete
|
||||
Session -> Facade : delete_structured_memory(\nname, scope, scope_id)
|
||||
Facade -> Storage : delete_structured_memory()
|
||||
Storage --> Session : bool (existed)
|
||||
Session -> Session : _init_system_messages()\nrefresh BM25 context
|
||||
end
|
||||
|
||||
== Phase 2: BM25 Relevance Injection ==
|
||||
|
||||
Session -> Session : _init_system_messages()\nevery conversation turn
|
||||
|
||||
Session -> Session : _list_visible_memories(\nlimit=fetch_limit)
|
||||
note right
|
||||
**Scope resolution:**
|
||||
1. global scope (always)
|
||||
2. workstream scope (ws_id)
|
||||
3. user scope (user_id, if auth)
|
||||
Combined and deduplicated.
|
||||
end note
|
||||
|
||||
Session -> Facade : list_structured_memories()\nper scope
|
||||
Facade -> Storage : list_structured_memories()
|
||||
Storage --> Session : up to fetch_limit rows
|
||||
|
||||
Session -> Relevance : extract_recent_context(\nmessages, max_messages=3)
|
||||
Relevance --> Session : user text context
|
||||
|
||||
Session -> Relevance : score_memories(\nmemories, context,\nk=relevance_k)
|
||||
note right
|
||||
**BM25 scoring:**
|
||||
Index over name + description
|
||||
+ content[:200] for each memory.
|
||||
Returns top-k by relevance.
|
||||
Empty query returns most recent k.
|
||||
end note
|
||||
Relevance --> Session : top-k memories
|
||||
|
||||
Session -> Relevance : build_memory_context(\nrelevant_memories)
|
||||
note right
|
||||
Formats as XML block:
|
||||
<memories>
|
||||
<memory name="..." type="..."
|
||||
scope="..." description="...">
|
||||
content (max 500 chars)
|
||||
</memory>
|
||||
</memories>
|
||||
end note
|
||||
Relevance --> Session : XML string
|
||||
|
||||
Session -> Session : inject into\nsystem message
|
||||
|
||||
== Phase 3: Server API Path ==
|
||||
|
||||
SDK -> API : GET /v1/api/memories\n?type=project&limit=20
|
||||
API -> Facade : list_structured_memories()
|
||||
Facade -> Storage : list_structured_memories()
|
||||
Storage --> API : rows
|
||||
API --> SDK : {"memories": [...], "total": N}
|
||||
|
||||
SDK -> API : POST /v1/api/memories\n{name, content, ...}
|
||||
API -> API : validate type, scope,\nname length, content length
|
||||
API -> Facade : save_structured_memory()
|
||||
Facade -> Storage : create / update
|
||||
Storage --> API : memory row
|
||||
API --> SDK : 201 (created) / 200 (updated)
|
||||
|
||||
SDK -> API : POST /v1/api/memories/search\n{query, type, ...}
|
||||
API -> Facade : search_structured_memories()
|
||||
Facade -> Storage : search_structured_memories()
|
||||
Storage --> API : matched rows
|
||||
API --> SDK : {"memories": [...], "total": N}
|
||||
|
||||
SDK -> API : DELETE /v1/api/memories/{name}\n?scope=global
|
||||
API -> Facade : delete_structured_memory()
|
||||
Facade -> Storage : delete row
|
||||
API --> SDK : {"status": "ok"}
|
||||
|
||||
== Phase 4: Console Admin Path ==
|
||||
|
||||
SDK -> Admin : GET /v1/api/admin/memories\n?type=&scope=&limit=
|
||||
Admin -> Admin : require_permission(\n"admin.memories")
|
||||
Admin -> Storage : list_structured_memories()
|
||||
Storage --> Admin : rows
|
||||
Admin --> SDK : {"memories": [...], "total": N}
|
||||
|
||||
SDK -> Admin : GET /v1/api/admin/memories/{id}
|
||||
Admin -> Storage : get_structured_memory(id)
|
||||
Storage --> Admin : memory row
|
||||
Admin --> SDK : memory JSON
|
||||
|
||||
SDK -> Admin : DELETE /v1/api/admin/memories/{id}
|
||||
Admin -> Storage : delete_structured_memory_by_id()
|
||||
Admin -> Admin : record_audit(\n"memory.delete")
|
||||
Admin --> SDK : {"status": "ok"}
|
||||
|
||||
== Configuration ==
|
||||
|
||||
note over Session, Relevance
|
||||
**MemoryConfig** (from [memory] in config.toml):
|
||||
relevance_k = 5 -- top-k memories per turn
|
||||
fetch_limit = 50 -- max memories fetched for scoring
|
||||
max_content = 32768 -- max content length per memory
|
||||
nudge_cooldown = 300 -- seconds between metacognitive nudges
|
||||
nudges = true -- enable/disable memory nudges
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,151 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — Settings Architecture
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<session>> #C8E6C9
|
||||
BackgroundColor<<config>> #FFE0B2
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<api>> #E8EAF6
|
||||
BackgroundColor<<sdk>> #F5F5F5
|
||||
}
|
||||
|
||||
participant "Server\n(main)" as Server <<session>>
|
||||
participant "ConfigStore\n(config_store.py)" as Store <<config>>
|
||||
participant "SettingsRegistry\n(settings_registry.py)" as Registry <<config>>
|
||||
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
|
||||
participant "Console Admin\n(console/server.py)" as Admin <<api>>
|
||||
participant "SDK Client\n(sdk/)" as SDK <<sdk>>
|
||||
participant "ChatSession\n(session.py)" as Session <<session>>
|
||||
|
||||
== Phase 1: Server Startup ==
|
||||
|
||||
Server -> Server : parse_args()\nCLI flags override defaults
|
||||
Server -> Server : init_storage()\nSQLite / PostgreSQL
|
||||
|
||||
Server -> Store ** : ConfigStore(storage, node_id)
|
||||
Store -> Storage : get_system_settings_bulk(node_id)
|
||||
note right
|
||||
1. Load global settings (node_id="")
|
||||
2. Overlay per-node settings
|
||||
Returns {key: json_value} dict
|
||||
end note
|
||||
Storage --> Store : raw settings
|
||||
Store -> Registry : deserialize_value(key, json)\nper entry
|
||||
Registry --> Store : typed values
|
||||
Store -> Store : swap _cache atomically\nincrement _version
|
||||
|
||||
Server -> Server : warn_migrated_settings()
|
||||
note right
|
||||
Scans config.toml for keys
|
||||
now managed by ConfigStore.
|
||||
Logs warning for each overlap.
|
||||
end note
|
||||
|
||||
Server -> Server : session_factory captures\nConfigStore reference
|
||||
|
||||
== Phase 2: Settings Read (session creation) ==
|
||||
|
||||
Server -> Session : session_factory(ws_id)
|
||||
Session -> Store : get("model.temperature")
|
||||
Store -> Store : cache[key] lookup\n(lock-free)
|
||||
alt key in cache
|
||||
Store --> Session : stored value
|
||||
else key not in cache
|
||||
Store -> Registry : SETTINGS[key].default
|
||||
Registry --> Store : default value
|
||||
Store --> Session : default value
|
||||
end
|
||||
note right of Session
|
||||
Settings are captured once
|
||||
at workstream creation.
|
||||
Not re-read on every turn.
|
||||
end note
|
||||
|
||||
== Phase 3: Admin API — List / Schema ==
|
||||
|
||||
SDK -> Admin : GET /v1/api/admin/settings
|
||||
Admin -> Admin : require_permission(\n"admin.settings")
|
||||
Admin -> Store : all_effective()
|
||||
Store -> Store : merge cache with\nregistry defaults
|
||||
Store --> Admin : {key: effective_value}
|
||||
Admin -> Registry : SETTINGS (metadata)
|
||||
note right
|
||||
Annotates each setting with:
|
||||
type, default, description,
|
||||
is_stored, is_secret, constraints,
|
||||
changed_by, updated
|
||||
end note
|
||||
Admin --> SDK : {"settings": [...], "total": N}
|
||||
|
||||
SDK -> Admin : GET /v1/api/admin/settings/schema
|
||||
Admin -> Admin : require_permission(\n"admin.settings")
|
||||
Admin -> Registry : SETTINGS catalog
|
||||
Admin --> SDK : {"settings": [...], "total": N}
|
||||
|
||||
== Phase 4: Admin API — Update ==
|
||||
|
||||
SDK -> Admin : PUT /v1/api/admin/settings/\nmodel.temperature\n{"value": 0.7}
|
||||
Admin -> Admin : require_permission(\n"admin.settings")
|
||||
Admin -> Registry : validate_key("model.temperature")
|
||||
Registry --> Admin : SettingDef
|
||||
alt is_secret == true
|
||||
Admin --> SDK : 403 Forbidden
|
||||
else
|
||||
Admin -> Registry : validate_value(key, 0.7)
|
||||
note right
|
||||
Type coercion: float(0.7)
|
||||
Range check: 0.0 <= 0.7 <= 2.0
|
||||
Choices check: (none for this key)
|
||||
end note
|
||||
Registry --> Admin : typed value
|
||||
Admin -> Store : set(key, 0.7, changed_by="admin")
|
||||
Store -> Registry : serialize_value(0.7)\n=> "0.7"
|
||||
Store -> Storage : upsert_system_setting(\nkey, "0.7", node_id, ...)
|
||||
Storage --> Store : ok
|
||||
Store -> Store : swap _cache atomically
|
||||
Admin -> Admin : record_audit(\n"setting.update")
|
||||
Admin --> SDK : {"key": "...", "value": 0.7,\n"previous": 0.5}
|
||||
end
|
||||
|
||||
== Phase 5: Admin API — Delete (reset to default) ==
|
||||
|
||||
SDK -> Admin : DELETE /v1/api/admin/settings/\nmodel.temperature
|
||||
Admin -> Admin : require_permission(\n"admin.settings")
|
||||
Admin -> Store : delete("model.temperature")
|
||||
Store -> Registry : validate_key(key)
|
||||
Store -> Storage : delete_system_setting(key, node_id)
|
||||
Storage --> Store : bool (existed)
|
||||
Store -> Store : remove from cache,\nswap atomically
|
||||
Admin -> Admin : record_audit(\n"setting.delete")
|
||||
Admin --> SDK : {"status": "ok",\n"key": "...", "default": 0.5}
|
||||
|
||||
== Phase 6: Hot Reload ==
|
||||
|
||||
SDK -> Admin : POST /v1/api/_internal/\nconfig-reload
|
||||
Admin -> Store : reload()
|
||||
Store -> Storage : get_system_settings_bulk(node_id)
|
||||
Storage --> Store : all settings
|
||||
Store -> Store : rebuild cache,\nswap atomically,\nincrement _version
|
||||
note right
|
||||
Existing sessions: unchanged
|
||||
(frozen at creation time).
|
||||
New sessions: pick up
|
||||
updated values immediately.
|
||||
end note
|
||||
Admin --> SDK : {"status": "ok"}
|
||||
|
||||
== Precedence Summary ==
|
||||
|
||||
note over Server, Registry
|
||||
**Server entry point:**
|
||||
CLI flag > ConfigStore (database) > registry default
|
||||
|
||||
**CLI entry point:**
|
||||
CLI flag > config.toml > argparse default
|
||||
|
||||
**Bootstrap settings** (database, auth, server bind):
|
||||
Always from config.toml / env vars — never in ConfigStore.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,147 @@
|
||||
@startuml
|
||||
!theme plain
|
||||
title Turnstone — OIDC Authorization Code Flow with PKCE
|
||||
|
||||
skinparam participant {
|
||||
BackgroundColor<<browser>> #E8EAF6
|
||||
BackgroundColor<<server>> #FFE0B2
|
||||
BackgroundColor<<storage>> #B3E5FC
|
||||
BackgroundColor<<idp>> #C8E6C9
|
||||
}
|
||||
|
||||
participant "Browser" as Browser <<browser>>
|
||||
participant "Turnstone\n(Server / Console)" as Server <<server>>
|
||||
database "SQLite /\nPostgreSQL" as DB <<storage>>
|
||||
participant "Identity Provider\n(IdP)" as IdP <<idp>>
|
||||
|
||||
== Page Load ==
|
||||
|
||||
Browser -> Server : GET /v1/api/auth/status
|
||||
Server --> Browser : {oidc_enabled: true,\noidc_provider_name: "...",\npassword_enabled: true}
|
||||
|
||||
note right of Browser
|
||||
Login screen renders
|
||||
"Continue with {provider_name}"
|
||||
button alongside password form.
|
||||
If password_enabled=false,
|
||||
only the SSO button is shown.
|
||||
end note
|
||||
|
||||
== Authorization Request ==
|
||||
|
||||
Browser -> Server : GET /v1/api/auth/oidc/authorize
|
||||
|
||||
Server -> Server : Generate state (random)\nnonce (random)\nPKCE code_verifier + code_challenge
|
||||
|
||||
Server -> DB : create_oidc_pending_state(\nstate, nonce, code_verifier, audience)
|
||||
note right of DB
|
||||
Stored with created_at timestamp.
|
||||
Expires after 5 minutes.
|
||||
end note
|
||||
|
||||
Server --> Browser : 302 Redirect to IdP\nauthorization_endpoint
|
||||
|
||||
Browser -> IdP : GET /authorize?\nresponse_type=code&\nclient_id=...&\nredirect_uri=...&\nscope=openid email profile&\nstate=...&nonce=...&\ncode_challenge=...&\ncode_challenge_method=S256
|
||||
|
||||
== User Authentication (at IdP) ==
|
||||
|
||||
IdP -> Browser : Login page (if no\nexisting IdP session)
|
||||
Browser -> IdP : User authenticates\n(username/password, MFA, etc.)
|
||||
|
||||
IdP --> Browser : 302 Redirect to callback\n?code=AUTH_CODE&state=STATE
|
||||
|
||||
== Callback Processing ==
|
||||
|
||||
Browser -> Server : GET /v1/api/auth/oidc/callback\n?code=AUTH_CODE&state=STATE
|
||||
|
||||
Server -> Server : Rate limit check\n(5 per 5min per IP)
|
||||
|
||||
Server -> DB : cleanup_expired_oidc_states(300)
|
||||
note right of DB
|
||||
Lazy cleanup of states
|
||||
older than 5 minutes.
|
||||
end note
|
||||
|
||||
Server -> DB : pop_oidc_pending_state(state)
|
||||
DB --> Server : {nonce, code_verifier, audience}
|
||||
note right of Server
|
||||
Atomic fetch-and-delete.
|
||||
Returns None if state is
|
||||
expired or unknown.
|
||||
end note
|
||||
|
||||
== Token Exchange ==
|
||||
|
||||
Server -> IdP : POST /token\ngrant_type=authorization_code&\ncode=AUTH_CODE&\nclient_id=...&\nclient_secret=...&\ncode_verifier=...&\nredirect_uri=...
|
||||
note right of Server
|
||||
Client secret + PKCE verifier
|
||||
sent server-side only.
|
||||
Never exposed to browser.
|
||||
end note
|
||||
|
||||
IdP --> Server : {id_token: "eyJ...",\naccess_token: "..."}
|
||||
|
||||
== ID Token Validation ==
|
||||
|
||||
Server -> IdP : Fetch JWKS public keys\n(cached at startup, refreshed\non-demand when unknown kid\nencountered — key rotation)
|
||||
|
||||
Server -> Server : Validate ID token:\n1. Verify signature (RS256/ES256)\n2. Check iss == configured issuer\n3. Check aud == client_id\n4. Check exp (not expired)\n5. Verify nonce matches
|
||||
|
||||
== User Provisioning ==
|
||||
|
||||
Server -> DB : get_oidc_identity(issuer, sub)
|
||||
|
||||
alt Existing identity found
|
||||
DB --> Server : {user_id, ...}
|
||||
Server -> DB : update_oidc_identity_login()\nupdate last_login timestamp
|
||||
Server -> DB : get_user(user_id)
|
||||
DB --> Server : user record
|
||||
else New user (first login)
|
||||
Server -> Server : Derive username from\npreferred_username / email
|
||||
Server -> DB : create_user(user_id, username,\ndisplay_name, "!oidc")
|
||||
note right of DB
|
||||
Password hash set to sentinel
|
||||
value "!oidc" — not a valid
|
||||
bcrypt hash, so password login
|
||||
is always rejected.
|
||||
end note
|
||||
Server -> DB : create_oidc_identity(\nissuer, sub, user_id, email)
|
||||
end
|
||||
|
||||
opt Role mapping configured
|
||||
Server -> Server : Read role_claim from ID token\nMap values via role_map
|
||||
Server -> DB : Sync roles: add new,\nrevoke stale OIDC-assigned,\npreserve manually assigned
|
||||
end
|
||||
|
||||
== Issue Turnstone JWT ==
|
||||
|
||||
Server -> Server : Load user permissions\nDerive scopes from permissions
|
||||
Server -> Server : Create JWT (HS256)\nsub: user_id\nscopes: read,write,...\nsrc: "oidc"\naud: turnstone-server\nexp: +24h
|
||||
|
||||
Server --> Browser : 302 Redirect to /?oidc_success=1\nSet-Cookie: session=JWT\n(HttpOnly, SameSite=Lax, Secure)
|
||||
|
||||
== Browser Success Detection ==
|
||||
|
||||
Browser -> Browser : Detect ?oidc_success=1\nStrip param from URL\n(history.replaceState)
|
||||
Browser -> Browser : Hide login overlay\nCall onLoginSuccess()
|
||||
|
||||
note right of Browser
|
||||
Browser is now authenticated.
|
||||
JWT cookie sent on all
|
||||
subsequent requests.
|
||||
end note
|
||||
|
||||
== Error Paths ==
|
||||
|
||||
note over Browser, IdP
|
||||
**Error handling:**
|
||||
- IdP returns error param → redirect to /?oidc_error=...
|
||||
- State missing/expired → redirect to /?oidc_error=Login+session+expired
|
||||
- Token exchange fails → redirect to /?oidc_error=...
|
||||
- ID token validation fails → redirect to /?oidc_error=...
|
||||
- No admin user exists → redirect to /?oidc_error=Initial+setup+required
|
||||
- Rate limit exceeded → redirect to /?oidc_error=Too+many+login+attempts
|
||||
All errors are shown as toast messages on the login screen.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,100 @@
|
||||
@startuml
|
||||
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml
|
||||
LAYOUT_LEFT_RIGHT()
|
||||
|
||||
title Skills Discovery & Runtime Architecture
|
||||
|
||||
skinparam backgroundColor #1e1e2e
|
||||
skinparam defaultFontColor #cdd6f4
|
||||
skinparam defaultFontName "JetBrains Mono"
|
||||
skinparam arrowColor #89b4fa
|
||||
skinparam rectangleBorderColor #585b70
|
||||
skinparam rectangleBackgroundColor #313244
|
||||
skinparam noteBorderColor #585b70
|
||||
skinparam noteBackgroundColor #45475a
|
||||
skinparam packageBorderColor #585b70
|
||||
|
||||
package "External Sources" as ext #181825 {
|
||||
rectangle "skills.sh\nRegistry" as skillssh
|
||||
rectangle "GitHub\nRepositories" as github
|
||||
}
|
||||
|
||||
package "Console Server" as console #181825 {
|
||||
rectangle "admin_skill_discover\nGET /v1/api/admin/skills/discover" as discover
|
||||
rectangle "admin_skill_install\nPOST /v1/api/admin/skills/install" as install
|
||||
rectangle "_get_discovery_url\nsettings fallback" as settings
|
||||
}
|
||||
|
||||
package "Core Modules" as core #181825 {
|
||||
rectangle "SkillsShClient\nskill_sources.py" as client
|
||||
rectangle "fetch_skill_from_github\nskill_sources.py" as fetcher
|
||||
rectangle "parse_skill_md\nskill_parser.py" as parser
|
||||
rectangle "scan_skill_content\nstorage/_utils.py" as scanner
|
||||
}
|
||||
|
||||
package "Session Runtime" as runtime #181825 {
|
||||
rectangle "skill tool\nsession.py" as loadtool
|
||||
rectangle "set_skill()\nsession.py" as setskill
|
||||
rectangle "_load_skills()\nsession.py" as loadskills
|
||||
}
|
||||
|
||||
package "Storage" as storage #181825 {
|
||||
rectangle "prompt_templates\n(skills)" as skills_table
|
||||
rectangle "skill_resources\n(bundled files)" as resources_table
|
||||
rectangle "system_settings\n(discovery_url)" as settings_table
|
||||
}
|
||||
|
||||
package "Admin UI" as ui #181825 {
|
||||
rectangle "Skills Tab\nInstalled / Discover pill" as pill
|
||||
rectangle "Discovery View\nsearch + cards" as discoverui
|
||||
rectangle "GitHub Import\nmodal" as importui
|
||||
}
|
||||
|
||||
' External discovery flow
|
||||
discover --> settings : resolve URL
|
||||
settings --> settings_table : DB -> config -> default
|
||||
discover --> client : search(query)
|
||||
client --> skillssh : GET /api/search
|
||||
|
||||
install --> client : resolve_github_url()
|
||||
client --> skillssh : GET /api/skills/{id}
|
||||
install --> fetcher : fetch SKILL.md + resources
|
||||
fetcher --> github : raw.githubusercontent.com
|
||||
fetcher --> github : api.github.com/git/trees
|
||||
fetcher --> parser : parse frontmatter
|
||||
install --> scanner : auto-scan on create
|
||||
install --> skills_table : create_prompt_template
|
||||
install --> resources_table : create_skill_resource
|
||||
|
||||
' Runtime skill loading flow
|
||||
loadtool --> skills_table : search (BM25 ranking)
|
||||
loadtool --> setskill : load (name)
|
||||
setskill --> loadskills : reload + reinit system messages
|
||||
loadskills --> skills_table : get_skill_by_name
|
||||
|
||||
' UI flow
|
||||
pill --> discoverui : switch view
|
||||
discoverui --> discover : authFetch()
|
||||
importui --> install : POST (github source)
|
||||
|
||||
' Annotations
|
||||
note right of parser
|
||||
YAML frontmatter -> ParsedSkill
|
||||
allowed-tools (standard) -> allowed_tools (internal)
|
||||
Anthropic + Hermes tag formats
|
||||
Name validation (lowercase+hyphens)
|
||||
end note
|
||||
|
||||
note right of loadtool
|
||||
search: auto-approved (read-only)
|
||||
load: requires user approval
|
||||
Main session only (no sub-agents)
|
||||
end note
|
||||
|
||||
note right of scanner
|
||||
4 risk axes (content, supply chain,
|
||||
vulnerability, capability)
|
||||
Auto-triggers on create/update
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -34,253 +34,214 @@
|
||||
<text x="600" y="54" text-anchor="middle" fill="#8b949e" font-size="11" letter-spacing="1">SYSTEM ARCHITECTURE</text>
|
||||
|
||||
<!-- ==================== COLUMN HEADERS ==================== -->
|
||||
<text x="90" y="86" text-anchor="middle" fill="#58a6ff" font-size="9" font-weight="600" letter-spacing="2">CLIENTS</text>
|
||||
<text x="276" y="86" text-anchor="middle" fill="#3fb950" font-size="9" font-weight="600" letter-spacing="2">GATEWAYS</text>
|
||||
<text x="480" y="86" text-anchor="middle" fill="#f0883e" font-size="9" font-weight="600" letter-spacing="2">MESSAGE QUEUE</text>
|
||||
<text x="700" y="86" text-anchor="middle" fill="#f47067" font-size="9" font-weight="600" letter-spacing="2">CLUSTER NODES</text>
|
||||
<text x="940" y="86" text-anchor="middle" fill="#f778ba" font-size="9" font-weight="600" letter-spacing="2">LLM PROVIDERS</text>
|
||||
<text x="110" y="86" text-anchor="middle" fill="#58a6ff" font-size="9" font-weight="600" letter-spacing="2">CLIENTS</text>
|
||||
<text x="380" y="86" text-anchor="middle" fill="#3fb950" font-size="9" font-weight="600" letter-spacing="2">CONSOLE ROUTER</text>
|
||||
<text x="700" y="86" text-anchor="middle" fill="#f47067" font-size="9" font-weight="600" letter-spacing="2">SERVER NODES</text>
|
||||
<text x="1010" y="86" text-anchor="middle" fill="#f778ba" font-size="9" font-weight="600" letter-spacing="2">LLM PROVIDERS</text>
|
||||
|
||||
<!-- ==================== CLIENT BOXES ==================== -->
|
||||
<!-- CLI -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="30" y="108" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="30" y="108" width="120" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="108" width="120" height="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="111" width="120" height="2" fill="#161b22"/>
|
||||
<text x="90" y="130" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">CLI</text>
|
||||
<text x="90" y="145" text-anchor="middle" fill="#8b949e" font-size="9">terminal REPL</text>
|
||||
<rect x="40" y="108" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="40" y="108" width="140" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="40" y="108" width="140" height="5" fill="#58a6ff"/>
|
||||
<rect x="40" y="111" width="140" height="2" fill="#161b22"/>
|
||||
<text x="110" y="130" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">CLI</text>
|
||||
<text x="110" y="145" text-anchor="middle" fill="#8b949e" font-size="9">terminal REPL</text>
|
||||
</g>
|
||||
|
||||
<!-- Browser UI -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="30" y="174" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="30" y="174" width="120" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="174" width="120" height="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="177" width="120" height="2" fill="#161b22"/>
|
||||
<text x="90" y="196" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Browser UI</text>
|
||||
<text x="90" y="211" text-anchor="middle" fill="#8b949e" font-size="9">HTTP + SSE</text>
|
||||
<rect x="40" y="174" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="40" y="174" width="140" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="40" y="174" width="140" height="5" fill="#58a6ff"/>
|
||||
<rect x="40" y="177" width="140" height="2" fill="#161b22"/>
|
||||
<text x="110" y="196" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Browser UI</text>
|
||||
<text x="110" y="211" text-anchor="middle" fill="#8b949e" font-size="9">HTTP + SSE</text>
|
||||
</g>
|
||||
|
||||
<!-- SDK / API -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="30" y="244" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="30" y="244" width="120" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="244" width="120" height="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="247" width="120" height="2" fill="#161b22"/>
|
||||
<text x="90" y="266" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">SDK / API</text>
|
||||
<text x="90" y="281" text-anchor="middle" fill="#8b949e" font-size="9">programmatic</text>
|
||||
</g>
|
||||
|
||||
<!-- Discord / Slack -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="30" y="314" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="30" y="314" width="120" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="314" width="120" height="5" fill="#58a6ff"/>
|
||||
<rect x="30" y="317" width="120" height="2" fill="#161b22"/>
|
||||
<text x="90" y="336" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Discord / Slack</text>
|
||||
<text x="90" y="351" text-anchor="middle" fill="#8b949e" font-size="9">chat platforms</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== GATEWAY BOXES ==================== -->
|
||||
<!-- Console -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="216" y="118" width="120" height="58" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="216" y="118" width="120" height="5" rx="5" fill="#3fb950"/>
|
||||
<rect x="216" y="118" width="120" height="5" fill="#3fb950"/>
|
||||
<rect x="216" y="121" width="120" height="2" fill="#161b22"/>
|
||||
<text x="276" y="142" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Console</text>
|
||||
<text x="276" y="157" text-anchor="middle" fill="#8b949e" font-size="9">dashboard + proxy</text>
|
||||
<text x="276" y="169" text-anchor="middle" fill="#8b949e" font-size="9">cluster management</text>
|
||||
<rect x="40" y="244" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="40" y="244" width="140" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="40" y="244" width="140" height="5" fill="#58a6ff"/>
|
||||
<rect x="40" y="247" width="140" height="2" fill="#161b22"/>
|
||||
<text x="110" y="266" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">SDK / API</text>
|
||||
<text x="110" y="281" text-anchor="middle" fill="#8b949e" font-size="9">programmatic</text>
|
||||
</g>
|
||||
|
||||
<!-- Channel Gateway -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="216" y="292" width="120" height="58" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="216" y="292" width="120" height="5" rx="5" fill="#3fb950"/>
|
||||
<rect x="216" y="292" width="120" height="5" fill="#3fb950"/>
|
||||
<rect x="216" y="295" width="120" height="2" fill="#161b22"/>
|
||||
<text x="276" y="316" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Channel Gateway</text>
|
||||
<text x="276" y="331" text-anchor="middle" fill="#8b949e" font-size="9">platform adapter</text>
|
||||
<text x="276" y="343" text-anchor="middle" fill="#8b949e" font-size="9">Discord, Slack, ...</text>
|
||||
<rect x="40" y="314" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="40" y="314" width="140" height="5" rx="5" fill="#58a6ff"/>
|
||||
<rect x="40" y="314" width="140" height="5" fill="#58a6ff"/>
|
||||
<rect x="40" y="317" width="140" height="2" fill="#161b22"/>
|
||||
<text x="110" y="336" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Channel Gateway</text>
|
||||
<text x="110" y="351" text-anchor="middle" fill="#8b949e" font-size="9">Discord, Slack, ...</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== REDIS MQ ==================== -->
|
||||
<!-- ==================== CONSOLE ROUTER ==================== -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="420" y="168" width="120" height="132" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="420" y="168" width="120" height="5" rx="5" fill="#f0883e"/>
|
||||
<rect x="420" y="168" width="120" height="5" fill="#f0883e"/>
|
||||
<rect x="420" y="171" width="120" height="2" fill="#161b22"/>
|
||||
<text x="480" y="198" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Redis MQ</text>
|
||||
<line x1="438" y1="210" x2="522" y2="210" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="480" y="228" text-anchor="middle" fill="#8b949e" font-size="9">inbound queues</text>
|
||||
<text x="480" y="243" text-anchor="middle" fill="#8b949e" font-size="9">event pub/sub</text>
|
||||
<text x="480" y="258" text-anchor="middle" fill="#8b949e" font-size="9">node heartbeats</text>
|
||||
<text x="480" y="273" text-anchor="middle" fill="#8b949e" font-size="9">workstream routing</text>
|
||||
<text x="480" y="288" text-anchor="middle" fill="#8b949e" font-size="9">cluster state</text>
|
||||
<rect x="300" y="148" width="160" height="170" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="300" y="148" width="160" height="5" rx="5" fill="#3fb950"/>
|
||||
<rect x="300" y="148" width="160" height="5" fill="#3fb950"/>
|
||||
<rect x="300" y="151" width="160" height="2" fill="#161b22"/>
|
||||
<text x="380" y="178" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Console</text>
|
||||
<line x1="318" y1="190" x2="442" y2="190" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="380" y="208" text-anchor="middle" fill="#8b949e" font-size="9">hash-ring router</text>
|
||||
<text x="380" y="223" text-anchor="middle" fill="#8b949e" font-size="9">cluster dashboard</text>
|
||||
<text x="380" y="238" text-anchor="middle" fill="#8b949e" font-size="9">reverse proxy</text>
|
||||
<line x1="318" y1="250" x2="442" y2="250" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="380" y="268" text-anchor="middle" fill="#484f58" font-size="8">control plane:</text>
|
||||
<text x="380" y="282" text-anchor="middle" fill="#484f58" font-size="8">create / send / approve</text>
|
||||
<text x="380" y="296" text-anchor="middle" fill="#484f58" font-size="8">cancel / command / close</text>
|
||||
<text x="380" y="310" text-anchor="middle" fill="#484f58" font-size="8">port 8090</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== CLUSTER NODES ==================== -->
|
||||
<!-- ==================== SERVER NODES ==================== -->
|
||||
<!-- Cluster outline -->
|
||||
<rect x="598" y="100" width="204" height="310" rx="8" fill="none" stroke="#30363d" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<rect x="570" y="100" width="260" height="310" rx="8" fill="none" stroke="#30363d" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<text x="700" y="422" text-anchor="middle" fill="#30363d" font-size="9" letter-spacing="1">CLUSTER</text>
|
||||
|
||||
<!-- Node A -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="614" y="118" width="170" height="100" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="614" y="118" width="170" height="5" rx="5" fill="#f47067"/>
|
||||
<rect x="614" y="118" width="170" height="5" fill="#f47067"/>
|
||||
<rect x="614" y="121" width="170" height="2" fill="#161b22"/>
|
||||
<text x="699" y="142" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Node A</text>
|
||||
<line x1="632" y1="152" x2="766" y2="152" stroke="#30363d" stroke-width="1"/>
|
||||
<!-- Bridge -->
|
||||
<rect x="626" y="162" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="661" y="180" text-anchor="middle" fill="#8b949e" font-size="9">bridge</text>
|
||||
<!-- Server -->
|
||||
<rect x="704" y="162" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="739" y="180" text-anchor="middle" fill="#8b949e" font-size="9">server</text>
|
||||
<!-- Arrow bridge to server -->
|
||||
<line x1="696" y1="176" x2="702" y2="176" stroke="#484f58" stroke-width="1" marker-end="url(#arrow)"/>
|
||||
<rect x="590" y="118" width="220" height="100" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="590" y="118" width="220" height="5" rx="5" fill="#f47067"/>
|
||||
<rect x="590" y="118" width="220" height="5" fill="#f47067"/>
|
||||
<rect x="590" y="121" width="220" height="2" fill="#161b22"/>
|
||||
<text x="700" y="142" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Server Node A</text>
|
||||
<line x1="608" y1="152" x2="792" y2="152" stroke="#30363d" stroke-width="1"/>
|
||||
<!-- Server process -->
|
||||
<rect x="608" y="162" width="184" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="700" y="180" text-anchor="middle" fill="#8b949e" font-size="9">turnstone-server :8080</text>
|
||||
<!-- Tools label -->
|
||||
<text x="699" y="206" text-anchor="middle" fill="#484f58" font-size="8">14 tools + MCP</text>
|
||||
<text x="700" y="206" text-anchor="middle" fill="#484f58" font-size="8">19 tools + MCP</text>
|
||||
</g>
|
||||
|
||||
<!-- Node B -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="614" y="238" width="170" height="100" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="614" y="238" width="170" height="5" rx="5" fill="#f47067"/>
|
||||
<rect x="614" y="238" width="170" height="5" fill="#f47067"/>
|
||||
<rect x="614" y="241" width="170" height="2" fill="#161b22"/>
|
||||
<text x="699" y="262" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Node B</text>
|
||||
<line x1="632" y1="272" x2="766" y2="272" stroke="#30363d" stroke-width="1"/>
|
||||
<!-- Bridge -->
|
||||
<rect x="626" y="282" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="661" y="300" text-anchor="middle" fill="#8b949e" font-size="9">bridge</text>
|
||||
<!-- Server -->
|
||||
<rect x="704" y="282" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="739" y="300" text-anchor="middle" fill="#8b949e" font-size="9">server</text>
|
||||
<!-- Arrow bridge to server -->
|
||||
<line x1="696" y1="296" x2="702" y2="296" stroke="#484f58" stroke-width="1" marker-end="url(#arrow)"/>
|
||||
<rect x="590" y="238" width="220" height="100" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="590" y="238" width="220" height="5" rx="5" fill="#f47067"/>
|
||||
<rect x="590" y="238" width="220" height="5" fill="#f47067"/>
|
||||
<rect x="590" y="241" width="220" height="2" fill="#161b22"/>
|
||||
<text x="700" y="262" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Server Node B</text>
|
||||
<line x1="608" y1="272" x2="792" y2="272" stroke="#30363d" stroke-width="1"/>
|
||||
<!-- Server process -->
|
||||
<rect x="608" y="282" width="184" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
|
||||
<text x="700" y="300" text-anchor="middle" fill="#8b949e" font-size="9">turnstone-server :8080</text>
|
||||
<!-- Tools label -->
|
||||
<text x="699" y="326" text-anchor="middle" fill="#484f58" font-size="8">14 tools + MCP</text>
|
||||
<text x="700" y="326" text-anchor="middle" fill="#484f58" font-size="8">19 tools + MCP</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== LLM PROVIDERS ==================== -->
|
||||
<!-- OpenAI -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="870" y="130" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="870" y="130" width="140" height="5" rx="5" fill="#f778ba"/>
|
||||
<rect x="870" y="130" width="140" height="5" fill="#f778ba"/>
|
||||
<rect x="870" y="133" width="140" height="2" fill="#161b22"/>
|
||||
<text x="940" y="153" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">OpenAI</text>
|
||||
<text x="940" y="167" text-anchor="middle" fill="#8b949e" font-size="9">GPT-5, o-series</text>
|
||||
<rect x="930" y="130" width="160" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="930" y="130" width="160" height="5" rx="5" fill="#f778ba"/>
|
||||
<rect x="930" y="130" width="160" height="5" fill="#f778ba"/>
|
||||
<rect x="930" y="133" width="160" height="2" fill="#161b22"/>
|
||||
<text x="1010" y="153" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">OpenAI</text>
|
||||
<text x="1010" y="167" text-anchor="middle" fill="#8b949e" font-size="9">GPT-5, o-series</text>
|
||||
</g>
|
||||
|
||||
<!-- Anthropic -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="870" y="196" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="870" y="196" width="140" height="5" rx="5" fill="#f778ba"/>
|
||||
<rect x="870" y="196" width="140" height="5" fill="#f778ba"/>
|
||||
<rect x="870" y="199" width="140" height="2" fill="#161b22"/>
|
||||
<text x="940" y="219" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Anthropic</text>
|
||||
<text x="940" y="233" text-anchor="middle" fill="#8b949e" font-size="9">Claude 4.5 / 4.6</text>
|
||||
<rect x="930" y="196" width="160" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="930" y="196" width="160" height="5" rx="5" fill="#f778ba"/>
|
||||
<rect x="930" y="196" width="160" height="5" fill="#f778ba"/>
|
||||
<rect x="930" y="199" width="160" height="2" fill="#161b22"/>
|
||||
<text x="1010" y="219" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Anthropic</text>
|
||||
<text x="1010" y="233" text-anchor="middle" fill="#8b949e" font-size="9">Claude 4.5 / 4.6</text>
|
||||
</g>
|
||||
|
||||
<!-- Local / vLLM -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="870" y="262" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="870" y="262" width="140" height="5" rx="5" fill="#f778ba"/>
|
||||
<rect x="870" y="262" width="140" height="5" fill="#f778ba"/>
|
||||
<rect x="870" y="265" width="140" height="2" fill="#161b22"/>
|
||||
<text x="940" y="285" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Local / vLLM</text>
|
||||
<text x="940" y="299" text-anchor="middle" fill="#8b949e" font-size="9">llama.cpp, NIM</text>
|
||||
<rect x="930" y="262" width="160" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="930" y="262" width="160" height="5" rx="5" fill="#f778ba"/>
|
||||
<rect x="930" y="262" width="160" height="5" fill="#f778ba"/>
|
||||
<rect x="930" y="265" width="160" height="2" fill="#161b22"/>
|
||||
<text x="1010" y="285" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Local / vLLM</text>
|
||||
<text x="1010" y="299" text-anchor="middle" fill="#8b949e" font-size="9">llama.cpp, NIM</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== STORAGE ==================== -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="614" y="450" width="170" height="52" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="614" y="450" width="170" height="5" rx="5" fill="#bc8cff"/>
|
||||
<rect x="614" y="450" width="170" height="5" fill="#bc8cff"/>
|
||||
<rect x="614" y="453" width="170" height="2" fill="#161b22"/>
|
||||
<text x="699" y="476" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">PostgreSQL / SQLite</text>
|
||||
<text x="699" y="492" text-anchor="middle" fill="#8b949e" font-size="9">conversations, memory, auth</text>
|
||||
<rect x="590" y="450" width="220" height="52" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
|
||||
<rect x="590" y="450" width="220" height="5" rx="5" fill="#bc8cff"/>
|
||||
<rect x="590" y="450" width="220" height="5" fill="#bc8cff"/>
|
||||
<rect x="590" y="453" width="220" height="2" fill="#161b22"/>
|
||||
<text x="700" y="476" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">PostgreSQL / SQLite</text>
|
||||
<text x="700" y="492" text-anchor="middle" fill="#8b949e" font-size="9">conversations, memory, auth</text>
|
||||
</g>
|
||||
<text x="699" y="444" text-anchor="middle" fill="#bc8cff" font-size="9" font-weight="600" letter-spacing="2">STORAGE</text>
|
||||
<text x="700" y="444" text-anchor="middle" fill="#bc8cff" font-size="9" font-weight="600" letter-spacing="2">STORAGE</text>
|
||||
|
||||
<!-- ==================== CONNECTION LINES ==================== -->
|
||||
|
||||
<!-- CLIENT -> GATEWAY connections -->
|
||||
<!-- CLIENT -> CONSOLE connections (control plane) -->
|
||||
<!-- Browser -> Console -->
|
||||
<line x1="150" y1="197" x2="214" y2="155" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
|
||||
<!-- Discord -> Channel -->
|
||||
<line x1="150" y1="337" x2="214" y2="325" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
|
||||
<line x1="180" y1="197" x2="298" y2="210" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
|
||||
<!-- Channel -> Console -->
|
||||
<line x1="180" y1="337" x2="298" y2="290" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
|
||||
<!-- SDK -> Console -->
|
||||
<line x1="180" y1="267" x2="298" y2="248" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
|
||||
<text x="240" y="238" fill="#484f58" font-size="8" text-anchor="middle">HTTP</text>
|
||||
|
||||
<!-- CLI -> direct to Node A server (top path, curved) -->
|
||||
<path d="M 150 131 C 200 131, 200 100, 400 100 L 400 100 C 500 100, 570 140, 612 168" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.5" fill="none" stroke-dasharray="6,3" marker-end="url(#arrow-blue)"/>
|
||||
<text x="370" y="96" fill="#484f58" font-size="8" text-anchor="middle">direct</text>
|
||||
<!-- CLI -> direct to Node A (single-node mode, above everything) -->
|
||||
<path d="M 180 120 L 588 120" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.4" fill="none" stroke-dasharray="6,3" marker-end="url(#arrow-blue)"/>
|
||||
<text x="390" y="114" fill="#484f58" font-size="8" text-anchor="middle">direct (single-node)</text>
|
||||
|
||||
<!-- SDK -> Redis (direct push) -->
|
||||
<line x1="150" y1="267" x2="418" y2="240" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
|
||||
<!-- CONSOLE -> NODE connections (proxy) -->
|
||||
<!-- Console -> Node A -->
|
||||
<line x1="460" y1="200" x2="588" y2="176" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-green)"/>
|
||||
<!-- Console -> Node B -->
|
||||
<line x1="460" y1="260" x2="588" y2="296" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-green)"/>
|
||||
<text x="520" y="222" fill="#484f58" font-size="8" text-anchor="middle">proxy</text>
|
||||
|
||||
<!-- GATEWAY -> REDIS connections -->
|
||||
<!-- Console -> Redis -->
|
||||
<line x1="336" y1="160" x2="418" y2="200" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-green)"/>
|
||||
<!-- Channel -> Redis -->
|
||||
<line x1="336" y1="318" x2="418" y2="272" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-green)"/>
|
||||
|
||||
<!-- REDIS -> NODE connections -->
|
||||
<!-- Redis -> Node A bridge -->
|
||||
<line x1="540" y1="210" x2="624" y2="176" stroke="#f0883e" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-orange)"/>
|
||||
<!-- Redis -> Node B bridge -->
|
||||
<line x1="540" y1="260" x2="624" y2="296" stroke="#f0883e" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-orange)"/>
|
||||
|
||||
<!-- Console -> Node (proxy, dashed) -->
|
||||
<path d="M 336 147 C 380 130, 500 108, 612 145" stroke="#3fb950" stroke-width="1" stroke-opacity="0.4" fill="none" stroke-dasharray="4,3" marker-end="url(#arrow-green)"/>
|
||||
<text x="468" y="120" fill="#484f58" font-size="8" text-anchor="middle">proxy</text>
|
||||
<!-- CLIENT -> NODE direct SSE (data plane, below console) -->
|
||||
<!-- Browser -> Node A SSE (arc below console) -->
|
||||
<path d="M 180 205 C 240 370, 450 380, 588 330" stroke="#58a6ff" stroke-width="1" stroke-opacity="0.3" fill="none" stroke-dasharray="4,3" marker-end="url(#arrow-blue)"/>
|
||||
<text x="340" y="378" fill="#484f58" font-size="8" text-anchor="middle">SSE (data plane)</text>
|
||||
|
||||
<!-- NODE -> LLM connections -->
|
||||
<!-- Node A -> LLM providers -->
|
||||
<line x1="784" y1="168" x2="868" y2="155" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.5" marker-end="url(#arrow-coral)"/>
|
||||
<line x1="784" y1="176" x2="868" y2="219" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.3"/>
|
||||
<line x1="784" y1="180" x2="868" y2="282" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.2"/>
|
||||
<line x1="810" y1="168" x2="928" y2="155" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.5" marker-end="url(#arrow-coral)"/>
|
||||
<line x1="810" y1="176" x2="928" y2="219" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.3"/>
|
||||
<line x1="810" y1="180" x2="928" y2="282" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.2"/>
|
||||
|
||||
<!-- Node B -> LLM providers -->
|
||||
<line x1="784" y1="288" x2="868" y2="163" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.2"/>
|
||||
<line x1="784" y1="296" x2="868" y2="222" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.3"/>
|
||||
<line x1="784" y1="300" x2="868" y2="288" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.5" marker-end="url(#arrow-coral)"/>
|
||||
<line x1="810" y1="288" x2="928" y2="163" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.2"/>
|
||||
<line x1="810" y1="296" x2="928" y2="222" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.3"/>
|
||||
<line x1="810" y1="300" x2="928" y2="288" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.5" marker-end="url(#arrow-coral)"/>
|
||||
|
||||
<!-- NODE -> STORAGE connections -->
|
||||
<line x1="680" y1="338" x2="680" y2="448" stroke="#bc8cff" stroke-width="1.2" stroke-opacity="0.4" stroke-dasharray="4,3" marker-end="url(#arrow-muted)"/>
|
||||
<line x1="718" y1="218" x2="718" y2="236" stroke="#484f58" stroke-width="1" stroke-opacity="0.3" stroke-dasharray="2,2"/>
|
||||
|
||||
<!-- Extensibility hint -->
|
||||
<text x="699" y="392" text-anchor="middle" fill="#30363d" font-size="10">...</text>
|
||||
|
||||
<!-- Event flow: Bridges -> Redis (dashed, bidirectional feel) -->
|
||||
<line x1="624" y1="186" x2="542" y2="220" stroke="#f0883e" stroke-width="1" stroke-opacity="0.3" stroke-dasharray="3,3"/>
|
||||
<line x1="624" y1="286" x2="542" y2="250" stroke="#f0883e" stroke-width="1" stroke-opacity="0.3" stroke-dasharray="3,3"/>
|
||||
<text x="574" y="242" fill="#484f58" font-size="7" text-anchor="middle">events</text>
|
||||
<text x="700" y="392" text-anchor="middle" fill="#30363d" font-size="10">...</text>
|
||||
|
||||
<!-- ==================== FLOW LABELS ==================== -->
|
||||
<!-- Interactive flow label -->
|
||||
<!-- Direct / single-node flow label -->
|
||||
<rect x="30" y="395" width="10" height="10" rx="2" fill="none" stroke="#58a6ff" stroke-width="1.5" stroke-dasharray="3,2"/>
|
||||
<text x="46" y="404" fill="#8b949e" font-size="9">interactive (direct)</text>
|
||||
<text x="46" y="404" fill="#8b949e" font-size="9">direct (single-node / SSE)</text>
|
||||
|
||||
<!-- Queue flow label -->
|
||||
<rect x="160" y="395" width="10" height="10" rx="2" fill="none" stroke="#f0883e" stroke-width="1.5"/>
|
||||
<text x="176" y="404" fill="#8b949e" font-size="9">queue-driven</text>
|
||||
<!-- Control plane label -->
|
||||
<rect x="200" y="395" width="10" height="10" rx="2" fill="none" stroke="#58a6ff" stroke-width="1.5"/>
|
||||
<text x="216" y="404" fill="#8b949e" font-size="9">control plane (HTTP)</text>
|
||||
|
||||
<!-- Proxy/event label -->
|
||||
<rect x="275" y="395" width="10" height="10" rx="2" fill="none" stroke="#3fb950" stroke-width="1.5" stroke-dasharray="3,2"/>
|
||||
<text x="291" y="404" fill="#8b949e" font-size="9">proxy / events</text>
|
||||
<!-- Proxy label -->
|
||||
<rect x="340" y="395" width="10" height="10" rx="2" fill="none" stroke="#3fb950" stroke-width="1.5"/>
|
||||
<text x="356" y="404" fill="#8b949e" font-size="9">console proxy</text>
|
||||
|
||||
<!-- ==================== BOTTOM DETAILS ==================== -->
|
||||
<line x1="30" y1="430" x2="1170" y2="430" stroke="#21262d" stroke-width="1"/>
|
||||
|
||||
<!-- Routing rules at bottom, left-aligned -->
|
||||
<text x="44" y="456" fill="#30363d" font-size="9" font-weight="600" letter-spacing="1">ROUTING</text>
|
||||
<circle cx="44" cy="474" r="3" fill="#f47067" opacity="0.6"/>
|
||||
<text x="54" y="477" fill="#484f58" font-size="9">target_node set → route to specific node queue</text>
|
||||
<circle cx="44" cy="494" r="3" fill="#f0883e" opacity="0.6"/>
|
||||
<text x="54" y="497" fill="#484f58" font-size="9">ws_id set → route to owning node</text>
|
||||
<circle cx="44" cy="514" r="3" fill="#58a6ff" opacity="0.6"/>
|
||||
<text x="54" y="517" fill="#484f58" font-size="9">neither → shared queue, any node picks up</text></svg>
|
||||
<circle cx="44" cy="474" r="3" fill="#3fb950" opacity="0.6"/>
|
||||
<text x="54" y="477" fill="#484f58" font-size="9">control plane: client → console → server node (hash-ring bucket lookup)</text>
|
||||
<circle cx="44" cy="494" r="3" fill="#58a6ff" opacity="0.6"/>
|
||||
<text x="54" y="497" fill="#484f58" font-size="9">data plane: client → server node (direct SSE, node_url from create response)</text>
|
||||
<circle cx="44" cy="514" r="3" fill="#f47067" opacity="0.6"/>
|
||||
<text x="54" y="517" fill="#484f58" font-size="9">single-node: client → server (direct HTTP + SSE, no console needed)</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9a1b0361c466327d0011a488847ea3c0365983713537d4a7c27cd7f5538ba33c
|
||||
size 164829
|
||||
oid sha256:881a8b9bce67b5af9a52d5e50deaa72351cd99c76f18aad5caeb2b61131ca1af
|
||||
size 119798
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7d75da92a657525bcbb7a425dc6c8d3cafe074c3ac3bff3cf4b1d44aea607b50
|
||||
size 330156
|
||||
oid sha256:75da80e6bd205e45b9fe48aa2f87197110c908f44e1d98c52eee69f956274711
|
||||
size 400402
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:637458e0d78df82752746e519cd7300a830c8ce211f21625694ad0c162ca316d
|
||||
size 481637
|
||||
oid sha256:6471e611beebf647f3a191eb16588571a404cc52a43067883a2b6f06dd936376
|
||||
size 594676
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dc3b64c9e48153641af62ed43fbc1d89a31d1a8a61e7e71cfc550c805000310d
|
||||
size 288290
|
||||
oid sha256:3aa8d972bba40d78152f9f0c762b9f5ec616d8052c45fa52b7dd1c679ed81d61
|
||||
size 325245
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b842683d238664a3e35d04358fecfc56cefd013f7dca5f13357b0376f881e1b3
|
||||
size 245043
|
||||
oid sha256:674712a0563f51837383184652efeb28b7bec13378be636e89d2959bfba39d1e
|
||||
size 281519
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b22d5980fe5cc4b8466ba0797113dc8fa83fab8df24b5dacceaf97e62e2e25b0
|
||||
size 187649
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:90e4f74be795b530e711faa87bc6eb2b3bf6abb68d8fac8ebff7aaf30c6fbe53
|
||||
size 222032
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d33b9b3affcdb07086b5aebca8a3b9c2b009cdfc6f360950a0e72e65fbcb8f17
|
||||
size 201602
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:adac93a0bb062d7199b819a600a0983ff011a75d16928fb80322cbb41f9284ea
|
||||
size 158866
|
||||
oid sha256:04d2069a9b5155ad1e7d842147fd78535ad9106d6856520439c33a9868a47499
|
||||
size 156694
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:69f201cff948cb0a19810b7c4ad26d346f869ee2dd3141eba4f353332efa2e21
|
||||
size 373649
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:97e7210cd8f1ad195f4d5e25e778d82df3c08c5c6e0f09722e84a7a453714867
|
||||
size 411664
|
||||
oid sha256:14824b81fa87f9a29e9b182b54132d3e438dd83f880b20b111b4bf51bc4b39d1
|
||||
size 317947
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4c3214ef416c1dfe4fa17834c2b6f4071a8093cfdb2b862848ca79938f726a13
|
||||
size 252599
|
||||
oid sha256:a872556d111185f4531d1b68ee892b4ce5042d7ccf277e2cad08beb6932c9803
|
||||
size 191144
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c9823a41e09611c5c0530d9fc12ad4139cfcc3ae238dc665b2888ec94d7d6781
|
||||
size 195708
|
||||
oid sha256:e7c3e40c10425d721f833390ae3531c09af501157fd3142531ba4eba86ff719d
|
||||
size 197112
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:733aa17cbfdab60a601cac6adf439c657dd3535e3d6c33c69c2ef93ba8ec5989
|
||||
size 251042
|
||||
oid sha256:b047cdc318c505f0f0895a65e14c5cc7552716053055cca57fa0a77db150e618
|
||||
size 255458
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f4b2a2010335f986511c8dabaf49ec046ac02e577f9bc9924897e045f860bb13
|
||||
size 248808
|
||||
oid sha256:af5ab3126bf685afe68e24bc4b0ed97371d0ebdb77bf4d76c0331ab120580cc0
|
||||
size 248809
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6049cc0b07480df88d0d93aa977a1e97f64b41588325ff41d98be0e39431fc5c
|
||||
size 431712
|
||||
oid sha256:8a5957b71822656098cbe779ca619b7e56e9b11b0f1c4eac4fc880ed62b71a2a
|
||||
size 358670
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f55e177e0838a16d9bc4f07b162b4b6a966cc596c9d0a022d35a3c84f23e7b02
|
||||
size 221452
|
||||
oid sha256:cc4c511c34a2e5d286fd128c3509405a5b240ca02a4bafb395d2e94d002a5b8b
|
||||
size 293203
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:96176a09e65e90dadc32d5e9ed778423842be89204d2cf382225f53a90cfaf01
|
||||
size 258547
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:98ba80fa1dab4d37299e61be079a6fbc8740fc3ab92196f828a765f74caf4556
|
||||
size 200720
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a6b7769aa7e732ffbeb1eb7f5b65273a135fb3a78d9802ec36d3b92801c34f6b
|
||||
size 427745
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:79a690c466a5d6f6d4292d78a27b9474e9e9c1373fa80e17dfe37700238c8af8
|
||||
size 382508
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c89628ed917dfd576c1af75c68fe5fed9beadaaee9dcea7aa7a1643867c4f1b9
|
||||
size 344323
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:06fe076f0835a891e00afc804fd1805196ebde9fc0d34998c7873e87287f982b
|
||||
size 346887
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1c21910e3916be789b0377c8a0dcc8f47d66a967861a543d5bdd0c26da185259
|
||||
size 309584
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ba14003062fec7eb9eaca7a3de945767e40bdd821468e19e7d1edcfa7ce1eb41
|
||||
size 193581
|
||||
+13
-45
@@ -1,6 +1,6 @@
|
||||
# Docker Deployment
|
||||
|
||||
Docker Compose stack for running the full turnstone platform or the simulator.
|
||||
Docker Compose stack for running the full turnstone platform.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -10,9 +10,6 @@ cp .env.example .env
|
||||
|
||||
# Full stack (needs an LLM API on the host)
|
||||
docker compose up
|
||||
|
||||
# Simulator only (no LLM needed)
|
||||
docker compose --profile sim up redis console sim
|
||||
```
|
||||
|
||||
Console dashboard: http://localhost:8090
|
||||
@@ -23,18 +20,14 @@ Console dashboard: http://localhost:8090
|
||||
|
||||
| Service | Port | Profile | Description |
|
||||
|---------|------|---------|-------------|
|
||||
| `redis` | 6379 | default | Message broker, pub/sub, node registry |
|
||||
| `server` | 8080 | default | Web UI + chat workstreams + LLM |
|
||||
| `bridge` | — | default | Redis-to-HTTP bridge (multi-node routing) |
|
||||
| `console` | 8090 | default | Cluster dashboard |
|
||||
| `channel` | — | production | Channel gateway (Discord, Slack, etc.) |
|
||||
| `server-1`…`server-10` | — | cluster | 10-node server fleet (PostgreSQL required) |
|
||||
| `bridge-1`…`bridge-10` | — | cluster | Matching bridge fleet |
|
||||
| `sim` | — | sim | Multi-node cluster simulator |
|
||||
|
||||
## Profiles
|
||||
|
||||
**Default** (no flag) — starts `redis`, `server`, `bridge`, `console`. Requires an OpenAI-compatible LLM API running on the host (default: `http://localhost:8000/v1`).
|
||||
**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 up
|
||||
@@ -46,22 +39,12 @@ docker compose up
|
||||
docker compose --profile production up
|
||||
```
|
||||
|
||||
**Cluster** — 10-node server/bridge fleet sharing PostgreSQL and Redis. Access all nodes via the console at `:8090`. Requires `POSTGRES_PASSWORD`:
|
||||
**Cluster** — 10-node server fleet sharing PostgreSQL. Access all nodes via the console at `:8090`. Requires `POSTGRES_PASSWORD`:
|
||||
|
||||
```bash
|
||||
docker compose --profile cluster up
|
||||
```
|
||||
|
||||
**Sim** — adds the simulator. Can run alongside the full stack or standalone with just Redis and the console:
|
||||
|
||||
```bash
|
||||
# Sim + console (no LLM needed)
|
||||
docker compose --profile sim up redis console sim
|
||||
|
||||
# Everything including sim
|
||||
docker compose --profile sim up
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is via environment variables in `.env` (copy from `.env.example`):
|
||||
@@ -74,13 +57,6 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
|
||||
| `OPENAI_API_KEY` | `dummy` | API key (`dummy` for local servers) |
|
||||
| `TAVILY_API_KEY` | — | Web search API key (only needed for local/vLLM models; Anthropic and OpenAI search models use native search) |
|
||||
|
||||
### Redis
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `REDIS_PASSWORD` | — | Redis auth password (empty = no auth) |
|
||||
| `REDIS_PORT` | `6379` | Host port mapping |
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -100,7 +76,7 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_AUTH_ENABLED` | — | Set to `1` to require authentication |
|
||||
| `TURNSTONE_AUTH_TOKEN` | — | Config-file token for server/bridge/console (backward compat, works alongside JWT) |
|
||||
| `TURNSTONE_AUTH_TOKEN` | — | Config-file token for server/console (backward compat, works alongside JWT) |
|
||||
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required when using user identity / JWT auth) |
|
||||
|
||||
### Database
|
||||
@@ -109,9 +85,12 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
|
||||
|----------|---------|-------------|
|
||||
| `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
|
||||
@@ -127,35 +106,24 @@ The database stores workstream history, user accounts, and API tokens. When usin
|
||||
| `TURNSTONE_DISCORD_TOKEN` | — | Discord bot token (required to enable Discord adapter) |
|
||||
| `TURNSTONE_DISCORD_GUILD` | `0` | Restrict to a single Discord guild (0 = all guilds) |
|
||||
|
||||
The channel service runs in the `production` profile. When `TURNSTONE_DISCORD_TOKEN` is set, the Discord adapter connects to the Discord Gateway and routes messages through Redis MQ to the bridge and server. See [Channel Integrations](channels.md) for full setup instructions including Discord application creation and user account linking.
|
||||
|
||||
### Simulator
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SIM_NODES` | `100` | Number of simulated nodes |
|
||||
| `SIM_SCENARIO` | `steady` | Scenario: `steady`, `burst`, `node_failure`, `directed`, `lifecycle` |
|
||||
| `SIM_DURATION` | `60` | Duration in seconds |
|
||||
| `SIM_MPS` | `5.0` | Messages per second (steady scenario) |
|
||||
| `SIM_LOG_LEVEL` | `INFO` | Log verbosity |
|
||||
| `SIM_SEED` | — | Random seed for reproducibility |
|
||||
| `SIM_METRICS_FILE` | — | Write JSON report to file |
|
||||
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 dedicated server+bridge pairs with unique node IDs (`node-1` through `node-10`), resource limits, and shared PostgreSQL:
|
||||
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
|
||||
POSTGRES_PASSWORD=secret docker compose --profile cluster up
|
||||
```
|
||||
|
||||
The default `server` and `bridge` also run alongside the cluster nodes (11 total). All nodes are accessible via the console dashboard at `:8090`.
|
||||
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 | Mount | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `redis-data` | `/data` | Redis persistence |
|
||||
| `turnstone-data` | `/data` | SQLite database (`.turnstone.db`) |
|
||||
|
||||
## Building
|
||||
@@ -170,7 +138,7 @@ docker compose build
|
||||
docker compose build --no-cache
|
||||
```
|
||||
|
||||
All entry points are installed in a single image: `turnstone-server`, `turnstone-bridge`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`, `turnstone-sim`, `turnstone-eval`.
|
||||
All entry points are installed in a single image: `turnstone-server`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`, `turnstone-eval`.
|
||||
|
||||
## Cleanup
|
||||
|
||||
|
||||
+254
-68
@@ -2,7 +2,8 @@
|
||||
|
||||
`turnstone-eval` is the evaluation and prompt optimization system for turnstone. It
|
||||
runs test cases against the LLM, scores tool call sequences against expected
|
||||
actions, and optionally uses the model to self-optimize the developer prompt.
|
||||
actions, and optionally uses a multi-agent pipeline to optimize the developer
|
||||
prompt and tool descriptions.
|
||||
|
||||
Source: `turnstone/eval.py`
|
||||
|
||||
@@ -10,15 +11,24 @@ Source: `turnstone/eval.py`
|
||||
|
||||
## Overview
|
||||
|
||||
The system works in an iterative loop:
|
||||
The system uses UCB tree search to explore prompt variants:
|
||||
|
||||
1. Run each test case N times against the current developer prompt.
|
||||
2. Score each run by comparing the actual tool call sequence to expected actions.
|
||||
3. If not all tests pass, use the model to rewrite the prompt based on failures.
|
||||
4. Repeat until all tests pass or max iterations are reached.
|
||||
1. Maintain an **evolution tree** of prompt variants, starting from the initial prompt.
|
||||
2. Each iteration, **UCB1 selects** the most promising node to evaluate.
|
||||
3. Run each test case N times against the selected prompt.
|
||||
4. Score each run by comparing the actual tool call sequence to expected actions.
|
||||
5. If not all tests pass, run a **three-phase optimization pipeline**:
|
||||
- Phase 1: Analyst diagnoses semantic failure patterns
|
||||
- Phase 2: Tool optimizer adjusts tool descriptions (when `--optimize-tools`)
|
||||
- Phase 3: Prompt optimizer proposes a child variant (when not `--optimize-tools`)
|
||||
6. Add the child to the tree and repeat until all tests pass or max iterations reached.
|
||||
|
||||
When optimization is disabled (`--no-optimize`), only step 1 and 2 execute
|
||||
(a single iteration).
|
||||
This approach (inspired by [Learning to Self-Evolve](https://arxiv.org/abs/2603.18620))
|
||||
prevents irrecoverable collapse from bad edits — UCB naturally backtracks to
|
||||
high-scoring ancestors instead of following a linear chain.
|
||||
|
||||
When optimization is disabled (`--no-optimize`), only steps 2-4 execute
|
||||
(a single iteration evaluating the root node).
|
||||
|
||||
---
|
||||
|
||||
@@ -65,6 +75,7 @@ Test suites are JSON files with this structure:
|
||||
| `match_mode` | no | `"ordered_subset"` | How to match actual vs expected actions (see Scoring). |
|
||||
| `max_turns` | no | `10` | Maximum conversation turns before stopping. |
|
||||
| `n_runs` | no | suite default or 3 | Per-case override for number of runs. |
|
||||
| `holdout` | no | `false` | If `true`, this case is evaluated but excluded from optimizer feedback. Used to measure progress without overfitting. |
|
||||
|
||||
### Expected Action Specs
|
||||
|
||||
@@ -135,6 +146,7 @@ deterministic, non-interactive execution suitable for automated testing.
|
||||
| Stdout | Normal | Suppressed during execution |
|
||||
| Tool logging | Display only | Structured `tool_call_log` |
|
||||
| System prompt | Built-in developer prompt | Overridable via constructor |
|
||||
| Cancellation | N/A | `_cancelled` event for timeout cleanup |
|
||||
|
||||
### NullUI
|
||||
|
||||
@@ -156,20 +168,34 @@ def send_headless(
|
||||
Runs a complete multi-turn conversation:
|
||||
|
||||
1. Appends the user message.
|
||||
2. Calls the model API (non-streaming).
|
||||
3. If tool calls are returned, executes them (with stdout suppressed) and
|
||||
2. Checks `_cancelled` event — stops if set (timeout cleanup).
|
||||
3. Calls the model API (non-streaming).
|
||||
4. If tool calls are returned, executes them (with stdout suppressed) and
|
||||
logs each call to `self.tool_call_log`.
|
||||
4. Repeats up to `max_turns` or until the model responds without tool calls.
|
||||
5. Returns the tool call log: list of dicts with keys `tool`, `args`,
|
||||
5. Repeats up to `max_turns` or until the model responds without tool calls.
|
||||
6. Returns the tool call log: list of dicts with keys `tool`, `args`,
|
||||
`result` (truncated to 500 chars), and `turn`.
|
||||
|
||||
Parallel tool calls are capped at 10 per turn to prevent degenerate repetition.
|
||||
|
||||
### Timeout and Cancellation
|
||||
|
||||
Each test runs in a `ThreadPoolExecutor(max_workers=1)` with a per-test
|
||||
timeout (`--test-timeout`). Each attempt gets its own `OpenAI` client with
|
||||
a matching httpx read timeout. On timeout, three layers of defense prevent
|
||||
zombie connections:
|
||||
|
||||
1. **httpx timeout**: Per-request read timeout aborts the HTTP call and
|
||||
releases the server slot.
|
||||
2. **`_cancelled` event**: Prevents the orphan thread from starting new turns.
|
||||
3. **`run_client.close()`**: Closes the connection pool to abort any
|
||||
in-flight request.
|
||||
|
||||
### Retry Logic
|
||||
|
||||
`send_headless()` is called inside `_run_single_test()` with retry logic:
|
||||
3 attempts with exponential backoff (sleep `2^attempt` seconds) on any
|
||||
exception. This prevents transient API errors from poisoning eval scores.
|
||||
exception. `TimeoutError` is re-raised immediately (no retry).
|
||||
|
||||
---
|
||||
|
||||
@@ -180,68 +206,175 @@ Each test case runs in isolation:
|
||||
1. A fresh temp directory is created.
|
||||
2. Setup files are written to the temp directory.
|
||||
3. The working directory is changed to the temp directory.
|
||||
4. A new `HeadlessSession` is created with the current developer prompt.
|
||||
5. `send_headless()` runs the user prompt through the conversation loop.
|
||||
6. The tool log is scored against expected actions.
|
||||
7. The temp directory is cleaned up.
|
||||
4. A per-attempt `OpenAI` client is created with httpx timeout matching `--test-timeout`.
|
||||
5. A new `HeadlessSession` is created with the current developer prompt.
|
||||
6. `send_headless()` runs the user prompt through the conversation loop.
|
||||
7. The tool log is scored against expected actions.
|
||||
8. The temp directory is cleaned up.
|
||||
|
||||
The memory database is also isolated per test (an ephemeral SQLite database
|
||||
in the temp directory) so tests do not pollute each other or the user's
|
||||
real memory store.
|
||||
|
||||
### Parallel Execution
|
||||
|
||||
With `--parallel N` (N > 1), tests run in a `ProcessPoolExecutor` with N
|
||||
workers. Each subprocess creates its own `OpenAI` client. This is suitable
|
||||
for remote API endpoints but will overwhelm local inference servers. The
|
||||
default (`--parallel 1`) runs tests serially.
|
||||
|
||||
---
|
||||
|
||||
## Optimization Loop
|
||||
## Model Roles
|
||||
|
||||
`run_optimization()` is the main entry point for iterative prompt optimization.
|
||||
The eval pipeline uses up to five separate model roles, each independently
|
||||
configurable. All roles inherit from the test model by default, with a
|
||||
cascade chain:
|
||||
|
||||
```
|
||||
test model (--base-url, --model)
|
||||
└─ optimizer (--optimizer-*)
|
||||
├─ observer (--observer-*)
|
||||
├─ analyst (--analyst-*)
|
||||
├─ diversifier (--diversifier-*)
|
||||
└─ tool optimizer (--tool-optimizer-*)
|
||||
```
|
||||
|
||||
| Role | Purpose | When it runs |
|
||||
|------|---------|--------------|
|
||||
| **Test** | The model being evaluated | Every iteration |
|
||||
| **Analyst** | Diagnoses semantic failure patterns with tool use | When pass rate < 100% |
|
||||
| **Optimizer** | Rewrites the developer prompt | Every iteration (unless `--optimize-tools`) |
|
||||
| **Tool optimizer** | Rewrites tool descriptions | When `--optimize-tools` is set |
|
||||
| **Observer** | Tunes the optimizer's strategy | Every 3 iterations |
|
||||
| **Diversifier** | Generates prompt paraphrases | Once before the loop (when `--diversify N`) |
|
||||
|
||||
Typical setup: local model for test, Opus for analyst, Sonnet for
|
||||
optimizer/observer/diversifier.
|
||||
|
||||
---
|
||||
|
||||
## Optimization Pipeline
|
||||
|
||||
### Flow
|
||||
|
||||
```
|
||||
for iteration in 0..max_iterations:
|
||||
1. Run all test cases n_runs times with current prompt
|
||||
2. Score and aggregate results
|
||||
3. Save intermediate results to JSON
|
||||
4. If all tests pass -> stop
|
||||
5. Every 3 iterations (at iteration 2, 5, 8, ...):
|
||||
-> Observer reviews optimizer strategy
|
||||
-> Reset prompt to best-performing iteration
|
||||
6. Propose new prompt via optimizer model call
|
||||
7. If prompt unchanged -> stop
|
||||
8. Continue with new prompt
|
||||
1. UCB select → pick the most promising tree node
|
||||
2. Run all test cases n_runs times with selected node's prompt
|
||||
3. Update node score (rolling mean) and visit count
|
||||
4. Save intermediate results + tree state to JSON
|
||||
5. If all tests pass → stop
|
||||
6. Phase 1: Analyst diagnoses semantic failure patterns
|
||||
7. Phase 2 (--optimize-tools only): Tool optimizer adjusts descriptions
|
||||
8. Phase 3 (default only): Prompt optimizer proposes new prompt
|
||||
9. Every 3 iterations: Observer tunes the optimizer's strategy
|
||||
10. Add child node to tree (if prompt or tools changed)
|
||||
```
|
||||
|
||||
### Prompt Proposal (`_propose_prompt_modification`)
|
||||
### Phase 1: Analyst (`_run_analyst`)
|
||||
|
||||
Uses the model to rewrite the developer prompt based on test results:
|
||||
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:
|
||||
|
||||
- **Input**: Current prompt, test case definitions, per-case results with
|
||||
actual vs expected tool sequences, and a history of the last 3 iterations.
|
||||
- **Optimizer system prompt** (`OPTIMIZER_SYSTEM`): Instructs the model to
|
||||
act as a text rewriter. Key guidance includes:
|
||||
- Address critical failure modes (text-only responses, write_file vs edit_file,
|
||||
unnecessary search before create, missing plan calls).
|
||||
- Preserve phrasing that drives 100% pass rate on passing tests.
|
||||
- Use direct imperative style with concrete tool call examples.
|
||||
- Stay within 130% of original prompt length.
|
||||
- **Output**: The rewritten prompt text (stripped of reasoning tags and code fences).
|
||||
- **Failure patterns**: Shared root causes across failing cases
|
||||
- **Success/failure contrast**: What distinguishes passing from failing cases
|
||||
- **Consistency signals**: Systematic (0%), flaky (1-79%), marginal (80-99%)
|
||||
- **Recommended fixes**: Priority-ordered patterns/examples to add or adjust
|
||||
|
||||
### Observer System (`_observe_and_update_optimizer`)
|
||||
The analyst is instructed to frame fixes as patterns and examples, not
|
||||
imperative rules — this feeds cleaner signal to the optimizer.
|
||||
|
||||
Every 3 iterations, a meta-level "observer" reviews the optimizer's strategy:
|
||||
In `--optimize-tools` mode, the analyst receives the current tool descriptions
|
||||
(with any overrides applied) and focuses on tool confusion and description
|
||||
issues rather than system prompt patterns.
|
||||
|
||||
- Analyzes the iteration history: score trends, regressions, prompt length changes,
|
||||
### Phase 2: Tool Optimizer (`_propose_tool_overrides`)
|
||||
|
||||
Runs when `--optimize-tools` is set. Receives the current tool descriptions,
|
||||
confusion failures (where the model picked the wrong tool), and the analyst's
|
||||
diagnosis. Returns a JSON override dict that modifies tool descriptions.
|
||||
|
||||
Overrides are validated against known tool names — only `description` and
|
||||
`parameters` changes are accepted (no tool renaming at eval time).
|
||||
|
||||
After each iteration, changed descriptions are logged as old → new diffs
|
||||
for easy visual inspection.
|
||||
|
||||
### Phase 3: Prompt Optimizer (`_propose_prompt_modification`)
|
||||
|
||||
Skipped in `--optimize-tools` mode. Receives the current prompt, test
|
||||
results with per-case pass rates and deltas from the parent node, and the
|
||||
analyst's diagnosis. Returns a rewritten prompt.
|
||||
|
||||
The optimizer is instructed to prefer patterns over rules — concrete tool
|
||||
chain examples teach better than imperative directives like "ALWAYS" or
|
||||
"NEVER." If the current prompt contains rule-heavy language, the optimizer
|
||||
is guided to replace it with examples.
|
||||
|
||||
### Two Optimization Surfaces
|
||||
|
||||
The system supports alternating between two optimization surfaces:
|
||||
|
||||
1. **System prompt optimization** (default): Freeze tool descriptions,
|
||||
optimize the developer prompt. Run until scores plateau.
|
||||
2. **Tool description optimization** (`--optimize-tools`): Freeze the system
|
||||
prompt, optimize tool descriptions only. Run until scores plateau.
|
||||
|
||||
Each surface lifts the floor for the other — tool description improvements
|
||||
may unlock system prompt gains that weren't reachable before, and vice versa.
|
||||
|
||||
### Observer (`_observe_and_update_optimizer`)
|
||||
|
||||
Every 3 iterations, a meta-level observer reviews the optimizer's strategy:
|
||||
|
||||
- Analyzes iteration history: score trends, regressions, prompt length changes,
|
||||
and diffs between iterations.
|
||||
- Summarizes the optimizer's behavioral patterns (list style, header usage, length).
|
||||
- Uses `OBSERVER_SYSTEM` to rewrite the optimizer's own system prompt.
|
||||
- Detects whether the optimizer is producing rule-heavy or pattern-based output.
|
||||
- Rewrites the optimizer's own system prompt to correct course.
|
||||
- Rejects degenerate outputs (over 200% of input length).
|
||||
- After updating the optimizer prompt, resets the developer prompt to the
|
||||
best-performing iteration so far.
|
||||
|
||||
This two-level optimization (optimizer + observer) helps the system escape
|
||||
local minima and adjust its rewriting strategy.
|
||||
### Prompt Diversification
|
||||
|
||||
### Result Persistence
|
||||
When `--diversify N` is set, the diversifier generates N paraphrased variants
|
||||
of each test case's user prompt before the optimization loop. Each run cycles
|
||||
through variants (round-robin), testing robustness across phrasings.
|
||||
|
||||
Variants can be cached back to the test suite JSON with `--save-variants`,
|
||||
and auto-loaded on subsequent runs even without `--diversify`.
|
||||
|
||||
---
|
||||
|
||||
## Evolution Tree
|
||||
|
||||
The optimization maintains a tree of prompt variants (`EvolutionNode`), where
|
||||
each node stores its prompt text, tool overrides, aggregated score, and visit
|
||||
count. The root node (ID 0) contains the initial prompt.
|
||||
|
||||
**UCB1 selection**: Each iteration picks the node with the highest Upper
|
||||
Confidence Bound score: `R_bar + C * sqrt(ln(N) / v)`, where `R_bar` is the
|
||||
node's mean score, `N` is total visits across all nodes, `v` is the node's
|
||||
visit count, and `C` is the exploration constant (`--explore-constant`,
|
||||
default sqrt(2)). Unvisited nodes are always selected first.
|
||||
|
||||
### Holdout Cases
|
||||
|
||||
Test cases with `"holdout": true` are evaluated every iteration but excluded
|
||||
from the optimizer's feedback. This prevents the optimizer from overfitting
|
||||
to specific test cases. Node scores are computed from holdout cases only
|
||||
(when present). If fewer than 2 non-holdout cases remain, holdout is disabled.
|
||||
|
||||
### Improvement-Based Feedback
|
||||
|
||||
The optimizer sees delta scores (`delta=+20%`) alongside absolute pass rates,
|
||||
showing how each case improved relative to the parent node's evaluation. This
|
||||
provides a cleaner signal than absolute scores alone — the optimizer can
|
||||
distinguish beneficial edits from harmful ones regardless of starting point.
|
||||
|
||||
---
|
||||
|
||||
## Result Persistence
|
||||
|
||||
After each iteration, results are written to the output JSON file. The
|
||||
structure is:
|
||||
@@ -251,9 +384,15 @@ structure is:
|
||||
"meta": {
|
||||
"model": "model-name",
|
||||
"base_url": "http://localhost:8000/v1",
|
||||
"optimizer_model": "claude-opus-4-6",
|
||||
"observer_model": "claude-opus-4-6",
|
||||
"started": "2025-01-01T00:00:00",
|
||||
"test_suite": "tests.json",
|
||||
"n_runs_default": 3
|
||||
"n_runs_default": 3,
|
||||
"explore_constant": 1.414,
|
||||
"holdout_ids": [],
|
||||
"diversify": 10,
|
||||
"prompt_variants": {"case_id": ["variant1", "variant2"]}
|
||||
},
|
||||
"iterations": [
|
||||
{
|
||||
@@ -261,7 +400,11 @@ structure is:
|
||||
"prompt": "the developer prompt used",
|
||||
"prompt_diff": null,
|
||||
"optimizer_system": "the optimizer system prompt",
|
||||
"analyst": "analyst diagnosis output",
|
||||
"tool_overrides": {"bash": {"description": "..."}},
|
||||
"timestamp": "2025-01-01T00:01:00",
|
||||
"tree_node_id": 0,
|
||||
"tree_child_id": 1,
|
||||
"cases": {
|
||||
"test_name": {
|
||||
"runs": [
|
||||
@@ -287,9 +430,21 @@ structure is:
|
||||
"overall_pass_rate": 0.8,
|
||||
"overall_avg_score": 0.87,
|
||||
"json_dumps": 0,
|
||||
"per_case_pass_rates": {"test_name": 1.0, ...}
|
||||
"per_case_pass_rates": {"test_name": 1.0}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tree": [
|
||||
{
|
||||
"node_id": 0,
|
||||
"parent_id": null,
|
||||
"prompt": "initial prompt",
|
||||
"tool_overrides": {},
|
||||
"score": 0.85,
|
||||
"visit_count": 3,
|
||||
"children": [1, 2],
|
||||
"iteration": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -306,26 +461,57 @@ turnstone-eval tests.json # evaluate + optimize
|
||||
turnstone-eval tests.json --no-optimize # evaluate only (single iteration)
|
||||
turnstone-eval tests.json --n-runs 5 --max-iter 10 # more thorough evaluation
|
||||
turnstone-eval tests.json --prompt custom.txt # start from a custom prompt
|
||||
turnstone-eval tests.json --optimize-tools # optimize tool descriptions only
|
||||
turnstone-eval tests.json --diversify 10 # test with prompt variants
|
||||
turnstone-eval tests.json -v # verbose per-turn logging
|
||||
```
|
||||
|
||||
### Multi-model setup (local test model, cloud optimizer)
|
||||
|
||||
```
|
||||
turnstone-eval tests.json \
|
||||
--base-url http://localhost:8000/v1 \
|
||||
--optimizer-base-url https://api.anthropic.com \
|
||||
--optimizer-model claude-sonnet-4-6 \
|
||||
--analyst-model claude-opus-4-6
|
||||
```
|
||||
|
||||
### All Options
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---------------------|-------------------------------|-------------|
|
||||
| `test_file` | (positional, required) | Path to test cases JSON file. |
|
||||
| `--base-url` | `http://localhost:8000/v1` | API base URL. |
|
||||
| `--model` | auto-detect | Model name. Auto-detected from the API if not specified. |
|
||||
| `--prompt` | turnstone built-in prompt | Path to initial prompt text file. |
|
||||
| `--n-runs` | from tests.json or 3 | Number of runs per test case. |
|
||||
| `--max-iter` | 5 | Maximum optimization iterations. |
|
||||
| `--no-optimize` | false | Run evaluation only (sets max-iter to 1). |
|
||||
| `--temperature` | 0.7 | Sampling temperature. |
|
||||
| `--max-tokens` | 32768 | Max completion tokens. |
|
||||
| `--reasoning-effort` | `medium` | Reasoning effort: `low`, `medium`, or `high`. |
|
||||
| `--context-window` | 131072 | Context window size. |
|
||||
| `--output` | `eval_results.json` | Output results file path. |
|
||||
| `-v`, `--verbose` | false | Show detailed per-turn logging (API calls, tool args, results). |
|
||||
| Flag | Default | Description |
|
||||
|-------------------------|----------------------------|-------------|
|
||||
| `test_file` | (positional, required) | Path to test cases JSON file. |
|
||||
| `--base-url` | `http://localhost:8000/v1` | API base URL for the test model. |
|
||||
| `--model` | auto-detect | Model name. Auto-detected from the API if not specified. |
|
||||
| `--prompt` | turnstone built-in prompt | Path to initial prompt text file. |
|
||||
| `--n-runs` | from tests.json or 3 | Number of runs per test case. |
|
||||
| `--max-iter` | 5 | Maximum optimization iterations. |
|
||||
| `--no-optimize` | false | Run evaluation only (sets max-iter to 1). |
|
||||
| `--temperature` | 0.7 | Sampling temperature. |
|
||||
| `--max-tokens` | 32768 | Max completion tokens. |
|
||||
| `--reasoning-effort` | `medium` | Reasoning effort: `low`, `medium`, or `high`. |
|
||||
| `--context-window` | 131072 | Context window size. |
|
||||
| `--output` | `eval_results.json` | Output results file path. |
|
||||
| `-v`, `--verbose` | false | Show detailed per-turn logging. |
|
||||
| `--explore-constant` | 1.414 (sqrt(2)) | UCB exploration constant C. |
|
||||
| `--test-timeout` | 300 | Per-test timeout in seconds. |
|
||||
| `--suite-timeout` | 0 (unlimited) | Total suite timeout in seconds. |
|
||||
| `--no-fast-fail` | false | Disable early termination on all-zero initial runs. |
|
||||
| `--parallel` | 1 (serial) | Parallel workers (0=auto, N=use N workers). |
|
||||
| `--optimizer-model` | same as `--model` | Model for prompt optimization. |
|
||||
| `--optimizer-base-url` | same as `--base-url` | Base URL for optimizer model. |
|
||||
| `--observer-model` | same as optimizer | Model for meta-optimization (observer). |
|
||||
| `--observer-base-url` | same as optimizer | Base URL for observer model. |
|
||||
| `--analyst-model` | same as optimizer | Model for failure analysis. |
|
||||
| `--analyst-base-url` | same as optimizer | Base URL for analyst model. |
|
||||
| `--diversify` | 0 (disabled) | Generate N prompt variants per test case. |
|
||||
| `--diversifier-model` | same as optimizer | Model for prompt diversification. |
|
||||
| `--diversifier-base-url`| same as optimizer | Base URL for diversifier model. |
|
||||
| `--save-variants` | false | Save generated variants back to test suite JSON. |
|
||||
| `--optimize-tools` | false | Optimize tool descriptions only (freeze system prompt). |
|
||||
| `--tool-optimizer-model` | same as optimizer | Model for tool description optimization. |
|
||||
| `--tool-optimizer-base-url` | same as optimizer | Base URL for tool optimizer model. |
|
||||
| `--save-tools` | false | Write optimized tool descriptions back to `turnstone/tools/*.json`. |
|
||||
|
||||
### Precedence for n_runs
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# Governance
|
||||
|
||||
Turnstone governance provides role-based access control (RBAC), tool execution
|
||||
policies, skills, usage tracking, and audit logging for the admin console.
|
||||
|
||||
## Architecture
|
||||
|
||||
See [diagram: 19-governance-architecture.puml](diagrams/19-governance-architecture.puml).
|
||||
|
||||
### RBAC (Roles & Permissions)
|
||||
|
||||
The permission model has two layers:
|
||||
|
||||
1. **Scopes** (legacy) — `read`, `write`, `approve`. Checked by `AuthMiddleware`
|
||||
on every request based on URL path classification.
|
||||
2. **Permissions** (granular) — 15 permission strings checked per-endpoint by
|
||||
`require_permission()`.
|
||||
|
||||
**Built-in roles** (seeded by migration 008):
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| admin | read, write, approve, admin.users, admin.roles, admin.orgs, admin.policies, admin.skills, admin.audit, admin.usage, admin.schedules, admin.watches, tools.approve, workstreams.create, workstreams.close |
|
||||
| operator | read, write, workstreams.create, workstreams.close |
|
||||
| viewer | read |
|
||||
|
||||
Custom roles can be created with any subset of the 15 valid permissions.
|
||||
|
||||
**Auth flow:**
|
||||
1. User logs in (password or API token) → `_load_user_permissions()` aggregates
|
||||
permissions from all assigned roles
|
||||
2. `_permissions_to_scopes()` derives legacy scopes (any `admin.*` → `approve`)
|
||||
3. JWT created with both `scopes` and `permissions` claims
|
||||
4. Middleware checks scope → handler checks permission via `require_permission()`
|
||||
|
||||
### Tool Policies
|
||||
|
||||
Admin-defined rules that control tool execution:
|
||||
|
||||
- **Pattern matching**: Glob syntax via `fnmatch` (e.g., `bash*`, `file_write`, `*`)
|
||||
- **Actions**: `allow` (auto-approve), `deny` (block), `ask` (normal approval flow)
|
||||
- **Priority**: Higher priority evaluated first, first match wins
|
||||
- **Enforcement**: `evaluate_tool_policies_batch()` called in `WebUI.approve_tools()`
|
||||
before the `auto_approve` check
|
||||
- **MCP granular policies**: MCP resources and prompts are evaluated using their
|
||||
`approval_label` for fine-grained control:
|
||||
- Resource reads: `mcp_resource__{uri}` (e.g., `mcp_resource__file:///docs/*` to allow,
|
||||
`mcp_resource__*` to deny all)
|
||||
- Prompt invocations: `mcp__{server}__{prompt}` (e.g., `mcp__trusted__*` to allow,
|
||||
`mcp__*` to require approval for all)
|
||||
- Built-in tools continue to use `func_name` for backward compatibility
|
||||
|
||||
### Skills
|
||||
|
||||
Admin-curated system message skills injected at workstream startup. Skills also
|
||||
include session configuration (model, temperature, auto-approve, token budget,
|
||||
etc.) since workstream templates were merged into the skills system in v0.8.0.
|
||||
|
||||
- **Runtime behavior**: Skills are loaded once at session creation and injected
|
||||
into the system message *before* user `instructions`. Skills set the baseline;
|
||||
instructions customize per-workstream behavior.
|
||||
- **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**: `--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**: `/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
|
||||
approval since it changes session behavior). Main session only.
|
||||
- **Categories**: general, engineering, support, custom, mcp
|
||||
- **Content limit**: 32 KB per skill (enforced on create/update)
|
||||
- **Storage**: `prompt_templates` table (stores skills) with JSON `variables`
|
||||
array. Migration 010 adds `template` column to `scheduled_tasks`.
|
||||
- **MCP sync**: MCP server prompts auto-sync into the `prompt_templates` table
|
||||
with `origin="mcp"`, `mcp_server` set, and `readonly=True`. Manual skills take
|
||||
precedence on name collision. MCP-synced content updates reset `is_default` to
|
||||
prevent compromised servers from injecting defaults. Admin UI shows origin badge
|
||||
and disables edit/delete for MCP-sourced skills.
|
||||
- **Spec fields**: Skills support the full Agent Skills standard frontmatter:
|
||||
`name`, `description`, `license`, `compatibility`, `metadata` (author, version),
|
||||
`allowed-tools`. The `license` and `compatibility` fields are preserved on import
|
||||
and editable in the admin UI. See https://agentskills.io/specification.
|
||||
- **Security scanning**: Skills are automatically scanned at creation and update
|
||||
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 `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:
|
||||
- `GET /v1/api/admin/skills/discover?q=...` — search the skills.sh registry
|
||||
(or a custom registry via `skills.discovery_url` setting)
|
||||
- `POST /v1/api/admin/skills/install` — install from skills.sh or GitHub.
|
||||
Fetches the `SKILL.md` file, parses YAML frontmatter, creates a skill with
|
||||
`origin="source"` and `readonly=True`, stores bundled resources.
|
||||
- Admin UI: Skills tab has "Installed" / "Discover" pill toggle.
|
||||
Discovery view has search bar, result cards, and "Import from GitHub" modal.
|
||||
- SDK: `discover_skills(q)` and `install_skill(source, skill_id=..., url=...)`
|
||||
on both Python and TypeScript console clients.
|
||||
- **Runtime config on installed skills**: Installed (readonly) skills can have
|
||||
their runtime configuration edited — model, temperature, reasoning effort,
|
||||
token budget, max tokens, agent max turns, auto-approve, allowed tools,
|
||||
and enabled flag. The server restricts updates to these fields only via
|
||||
`_SKILL_RUNTIME_CONFIG_FIELDS` filtering; spec/content fields (name,
|
||||
description, tags, license, compatibility, content, activation) remain
|
||||
immutable. The admin UI shows "Save Config" instead of "Save" for these
|
||||
skills. Audit action: `skill.update.config`.
|
||||
- **Admin UI**: Create/Edit skill modals use a two-column spec manifest layout
|
||||
(left: Identity / Manifest / Deployment; right: Skill Content editor with
|
||||
monospace font). Runtime Config is a collapsible 3-column grid below.
|
||||
License uses an SPDX identifier dropdown (MIT, Apache-2.0, GPL-3.0, etc.).
|
||||
Installed skills show a cyan origin badge with source URL, spec fields are
|
||||
disabled, and all collapsible sections auto-expand in view mode.
|
||||
|
||||
### Usage Tracking
|
||||
|
||||
Per-LLM-request token and tool call metrics:
|
||||
|
||||
- **Recording**: `on_status()` in `WebUI` records a `usage_event` after each
|
||||
LLM response with prompt/completion tokens, cache tokens, tool call count,
|
||||
model, ws_id
|
||||
- **Prompt caching**: Anthropic automatic caching (`cache_control: ephemeral`)
|
||||
and OpenAI extended retention (`prompt_cache_retention: 24h` for GPT-5.x)
|
||||
are enabled by default. `cache_creation_tokens` and `cache_read_tokens` are
|
||||
tracked per request in `usage_events` and surfaced in the Usage admin tab
|
||||
- **Querying**: `GET /v1/api/admin/usage` with `group_by` (day/hour/model/user)
|
||||
and time range filtering — includes cache token aggregates
|
||||
- **Prometheus**: `turnstone_tokens_total{type="cache_creation|cache_read"}`
|
||||
counters on `/metrics`
|
||||
- **Pruning**: `prune_usage_events(retention_days=90)` and
|
||||
`prune_audit_events(retention_days=365)` run automatically via the
|
||||
console scheduler's periodic cleanup cycle
|
||||
|
||||
### Audit Logging
|
||||
|
||||
Append-only trail of admin actions:
|
||||
|
||||
- **Recording**: `record_audit()` helper called from all admin mutation handlers
|
||||
- **Events captured**: user.create, user.delete, token.create, token.revoke,
|
||||
channel.link, channel.unlink, role.create, role.update, role.delete,
|
||||
role.assign, role.unassign, policy.create, policy.update, policy.delete,
|
||||
template.create, template.update, template.delete,
|
||||
skill.create, skill.update, skill.delete, org.update
|
||||
- **Querying**: `GET /v1/api/admin/audit` with action/user/time filters + pagination
|
||||
|
||||
## Database Schema
|
||||
|
||||
Migration 008 adds 7 tables:
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `orgs` | Organizations (single default org for now) |
|
||||
| `roles` | Named permission bundles (3 builtin + custom) |
|
||||
| `user_roles` | User-to-role assignments (composite PK) |
|
||||
| `tool_policies` | Per-tool approve/deny/ask rules |
|
||||
| `prompt_templates` | Reusable system message skills |
|
||||
| `usage_events` | Per-request token/tool/cache metrics |
|
||||
| `audit_events` | Admin action log |
|
||||
|
||||
Also adds `org_id` column to `users` table.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All under `/v1/api/admin/` (requires `approve` scope + granular permission).
|
||||
|
||||
| Group | Endpoints | Permission |
|
||||
|-------|-----------|------------|
|
||||
| Users / Tokens / Channels | 9 (CRUD) | `admin.users` |
|
||||
| Roles | 7 (CRUD + assignment) | `admin.roles` / `admin.users` |
|
||||
| Orgs | 3 (list, get, update) | `admin.orgs` |
|
||||
| Tool Policies | 4 (CRUD) | `admin.policies` |
|
||||
| Skills | 4 (CRUD) | `admin.skills` |
|
||||
| Schedules | 6 (CRUD + runs) | `admin.schedules` |
|
||||
| Watches | 3 (list, create, cancel) | `admin.watches` |
|
||||
| Usage | 1 (aggregated query) | `admin.usage` |
|
||||
| Audit | 1 (paginated, filtered) | `admin.audit` |
|
||||
|
||||
Full OpenAPI spec at `/openapi.json` and Swagger UI at `/docs`.
|
||||
|
||||
## Admin Console UI
|
||||
|
||||
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)
|
||||
- **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.
|
||||
|
||||
## SDK
|
||||
|
||||
Both Python and TypeScript console SDKs expose governance methods:
|
||||
|
||||
**Python** (`TurnstoneConsole` / `AsyncTurnstoneConsole`):
|
||||
- `list_roles()`, `create_role()`, `update_role()`, `delete_role()`
|
||||
- `list_user_roles()`, `assign_role()`, `unassign_role()`
|
||||
- `list_orgs()`, `get_org()`, `update_org()`
|
||||
- `list_policies()`, `create_policy()`, `update_policy()`, `delete_policy()`
|
||||
- `list_templates()`, `create_template()`, `update_template()`, `delete_template()`
|
||||
- `get_usage(since, group_by=...)`, `get_audit(action=..., limit=...)`
|
||||
|
||||
**TypeScript** (`TurnstoneConsole`):
|
||||
- Same methods with camelCase naming and typed interfaces
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Privilege escalation prevented**: `admin_assign_role` blocks self-assignment
|
||||
and requires caller to hold a superset of the target role's permissions
|
||||
- **Permission validation**: Role create/update validates permissions against
|
||||
a 15-item allowlist (`_VALID_PERMISSIONS`)
|
||||
- **Self-deletion blocked**: `admin_delete_user` rejects attempts to delete
|
||||
your own account (matching the self-assignment guard on role endpoints)
|
||||
- **Field allowlists**: Storage `update_*` methods filter fields against
|
||||
allowlists (`_ROLE_MUTABLE`, `_POLICY_MUTABLE`, etc.) — handler bugs
|
||||
cannot overwrite `role_id`, `builtin`, `created`, or other protected columns
|
||||
- **Bootstrap safety**: `handle_auth_setup` fails and rolls back if admin role
|
||||
assignment fails, preventing locked-out first user
|
||||
- **API token RBAC**: `_authenticate_api_token` loads permissions from user's
|
||||
roles, ensuring API tokens are subject to RBAC enforcement
|
||||
- **Policy evaluation is fail-open**: If storage is unavailable, tool policies
|
||||
degrade to the existing approval flow (not auto-approve)
|
||||
- **Audit IP resolution**: `_audit_context()` prefers `X-Forwarded-For` for
|
||||
client IP when behind a reverse proxy, falling back to `request.client.host`
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
# Intent Validation (Judge)
|
||||
|
||||
> See also: [Judge Architecture diagram](diagrams/png/22-judge-architecture.png)
|
||||
|
||||
Intent validation provides advisory risk assessments for tool calls that require
|
||||
human approval. An LLM judge evaluates each tool call and presents a structured
|
||||
verdict alongside the approval prompt, helping users make informed decisions.
|
||||
|
||||
## Overview
|
||||
|
||||
When a tool call requires approval, the intent validation system runs a two-tier
|
||||
evaluation:
|
||||
|
||||
1. **Heuristic tier** (instant) -- Pattern-based risk classification using a
|
||||
rule table. Zero cost, sub-millisecond latency.
|
||||
2. **LLM judge tier** (async) -- Semantic evaluation using an LLM with
|
||||
read-only tool access. Runs on a daemon thread and delivers its verdict
|
||||
progressively.
|
||||
|
||||
The verdict is purely advisory -- the user always makes the final decision.
|
||||
|
||||
The heuristic verdict is attached to the `approve_request` SSE event immediately.
|
||||
The LLM verdict arrives later via an `intent_verdict` SSE event, allowing the
|
||||
UI to show a spinner that resolves into a richer assessment. Both verdicts are
|
||||
persisted to the `intent_verdicts` table for audit and future calibration.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### config.toml
|
||||
|
||||
```toml
|
||||
[judge]
|
||||
enabled = true
|
||||
model = "" # empty = same as session model
|
||||
provider = "" # empty = same as session provider
|
||||
base_url = ""
|
||||
api_key = ""
|
||||
confidence_threshold = 0.7 # reserved for v2 smart approvals (not used in v1)
|
||||
max_context_ratio = 0.5 # max % of judge context window for history
|
||||
timeout = 60.0 # seconds (generous for local models)
|
||||
read_only_tools = true # judge can use read_file/list_directory
|
||||
```
|
||||
|
||||
All fields are optional. The judge is enabled by default; use `enabled = false`
|
||||
(or `--no-judge` on the command line) to disable it.
|
||||
|
||||
### CLI flags
|
||||
|
||||
```
|
||||
--judge / --no-judge Enable/disable (default: enabled)
|
||||
--judge-model MODEL Model for judge
|
||||
--judge-provider PROVIDER Provider for judge
|
||||
--judge-timeout SECONDS LLM judge timeout (default: 60)
|
||||
--judge-confidence FLOAT Confidence threshold (default: 0.7)
|
||||
```
|
||||
|
||||
CLI flags override `config.toml` values.
|
||||
|
||||
---
|
||||
|
||||
## Judge Model Selection
|
||||
|
||||
- **Default (self-consistency)**: When `model` is empty, the session model
|
||||
evaluates its own tool calls. Research shows self-consistency achieves
|
||||
comparable accuracy to multi-agent debate at a fraction of the cost.
|
||||
- **Cross-model**: Use a different model for the judge (e.g. local model for
|
||||
the session, commercial model for the judge). Set `model` and `provider`
|
||||
in the `[judge]` config section, or use `--judge-model` / `--judge-provider`
|
||||
CLI flags.
|
||||
- **Cross-provider**: When both `model` and `provider` are set, the judge
|
||||
creates its own LLM client. You can optionally specify `base_url` and
|
||||
`api_key` for non-default endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Heuristic Rules
|
||||
|
||||
The heuristic tier scans a priority-ordered rule table (critical first, low
|
||||
last) and returns the first matching rule. Each rule has:
|
||||
|
||||
- **Tool pattern**: fnmatch glob matched against `func_name` and `approval_label`
|
||||
- **Argument patterns**: Regex patterns matched against the tool's primary
|
||||
argument text (command string for bash, path for file tools, JSON for others)
|
||||
- **Risk level, confidence, and recommendation**: Pre-assigned per rule
|
||||
|
||||
### Rule tiers (36 rules)
|
||||
|
||||
| Tier | Confidence | Recommendation | Examples |
|
||||
|----------|-----------|----------------|----------|
|
||||
| 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`, `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.
|
||||
|
||||
The bash "read-only" rule handles simple pipelines and command chains by
|
||||
splitting on `|`, `&&`, `||`, and `;`, then checking each segment individually.
|
||||
|
||||
### Rules derived from audit data
|
||||
|
||||
Several rules were calibrated using analysis of 25K public agent skill
|
||||
security audits across three independent auditors:
|
||||
|
||||
- **`download-exec`**: Two-step download-then-execute chains that bypass the
|
||||
existing `pipe-to-shell` rule. 8% of critical-tier skills use this pattern.
|
||||
- **`transitive-install`**: Installing packages from URLs or git repos rather
|
||||
than vetted registries. Socket flags this as supply-chain critical in 36%
|
||||
of dangerous skills.
|
||||
- **`browser-data-export`**: Browser automation combined with cookie/session/
|
||||
profile export. OpenClaw treats browser profile access as operator-level
|
||||
capability.
|
||||
- **`control-plane-mutation`**: Persistent system changes (crontab, systemd)
|
||||
that outlive the session. OpenClaw denies control-plane tools by default.
|
||||
- **`content-ingestion`**: Fetch-and-process pipelines where remote content
|
||||
feeds into an interpreter (Snyk W011 pattern — indirect prompt injection
|
||||
surface).
|
||||
- **`interpreter-exec`**: Running a script file whose content hasn't been
|
||||
inspected. Opaque to command-level heuristics.
|
||||
- **`cloud-infra-mutation`**: Distinguishes destructive cloud CLI verbs
|
||||
(`create`, `delete`, `destroy`) from read-only ones (`show`, `list`, `get`).
|
||||
|
||||
---
|
||||
|
||||
## LLM Judge
|
||||
|
||||
The LLM judge runs on a daemon thread and performs a multi-turn evaluation:
|
||||
|
||||
1. **Context preparation**: Recent conversation history is FIFO-truncated to
|
||||
fit within `max_context_ratio` of the judge's context window. The tool call
|
||||
details (name, approval label, full arguments) are appended as a user message.
|
||||
2. **Multi-turn loop** (up to 5 turns): The judge can use `read_file` and
|
||||
`list_directory` to gather evidence before rendering its verdict. Each tool
|
||||
result is appended to the conversation and the judge is called again. On
|
||||
the final turn, tools are stripped and a forcing message instructs the
|
||||
judge to render its verdict immediately.
|
||||
3. **Verdict parsing**: The judge's final text response is parsed as JSON using
|
||||
a four-stage strategy: direct parse, markdown code block extraction,
|
||||
brace-counting, and regex field extraction as a last resort.
|
||||
4. **Arbitration**: If the LLM verdict has higher confidence than the heuristic,
|
||||
it replaces the heuristic via the `intent_verdict` SSE event.
|
||||
|
||||
### Read-only tools
|
||||
|
||||
When `read_only_tools` is enabled (default), the judge can use two tools:
|
||||
|
||||
- **`read_file`**: Read file contents (capped at 32 KB)
|
||||
- **`list_directory`**: List directory entries (capped at 200 entries)
|
||||
|
||||
Security hardening blocks access to sensitive paths:
|
||||
|
||||
| Category | Blocked patterns |
|
||||
|----------|-----------------|
|
||||
| System directories | `/etc/`, `/root/`, `/proc/`, `/sys/`, `/dev/` |
|
||||
| Credential directories | `.ssh`, `.gnupg`, `.aws`, `.config` |
|
||||
| Key files | `*.pem`, `*.key`, `*.p12`, `*.pfx` |
|
||||
|
||||
### Timeout
|
||||
|
||||
The `timeout` setting (default 60 seconds) is a total budget across all judge
|
||||
turns. Time is decremented after each LLM call. If the budget expires mid-turn,
|
||||
the judge attempts to parse whatever partial response is available.
|
||||
|
||||
---
|
||||
|
||||
## Verdict Structure
|
||||
|
||||
Each verdict (heuristic or LLM) is an `IntentVerdict` with these fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------------|------------|-------------|
|
||||
| `verdict_id` | string | Unique identifier (UUID prefix) |
|
||||
| `call_id` | string | Correlates with the tool call's `call_id` |
|
||||
| `func_name` | string | Tool function name |
|
||||
| `intent_summary` | string | One-sentence description of what the tool call does |
|
||||
| `risk_level` | string | `"low"`, `"medium"`, `"high"`, or `"critical"` |
|
||||
| `confidence` | float | 0.0--1.0, how certain the assessment is |
|
||||
| `recommendation` | string | `"approve"`, `"review"`, or `"deny"` |
|
||||
| `reasoning` | string | Explanation of the assessment |
|
||||
| `evidence` | list[str] | Supporting evidence (rule name or file excerpts) |
|
||||
| `tier` | string | `"heuristic"` or `"llm"` |
|
||||
| `judge_model` | string | Model used (empty for heuristic tier) |
|
||||
| `latency_ms` | int | Evaluation time in milliseconds |
|
||||
|
||||
---
|
||||
|
||||
## Session Integration
|
||||
|
||||
The judge is lazy-initialized on first use. When `ChatSession` prepares tool
|
||||
calls for approval, it calls `_evaluate_intent()` which:
|
||||
|
||||
1. Instantiates `IntentJudge` if not already created
|
||||
2. Extracts `func_name`, `func_args`, and `approval_label` from each pending item
|
||||
3. Calls `judge.evaluate()` which returns heuristic verdicts immediately
|
||||
4. Attaches each heuristic verdict to its item as `_heuristic_verdict`
|
||||
5. The daemon thread runs the LLM judge and delivers results via `ui.on_intent_verdict()`
|
||||
|
||||
Sub-agents (plan agent, task agent) are exempt from intent validation -- they
|
||||
always get full tool visibility without judge evaluation.
|
||||
|
||||
---
|
||||
|
||||
## Storage and Audit
|
||||
|
||||
All verdicts are persisted to the `intent_verdicts` table (migration 012):
|
||||
|
||||
- Heuristic verdicts are stored when the `approve_request` event is emitted
|
||||
- LLM verdicts are stored when the `intent_verdict` event is delivered
|
||||
- The `user_decision` column is updated when the user approves or denies
|
||||
|
||||
The console admin panel exposes verdict history via:
|
||||
|
||||
```
|
||||
GET /v1/api/admin/verdicts?ws_id=&since=&until=&risk_level=&limit=100&offset=0
|
||||
```
|
||||
|
||||
This endpoint requires the `admin.judge` permission.
|
||||
|
||||
---
|
||||
|
||||
## SSE Events
|
||||
|
||||
### `approve_request` (extended)
|
||||
|
||||
When the judge is active, `approve_request` items include a `verdict` field
|
||||
with the heuristic verdict, and the event includes a `judge_pending` flag
|
||||
indicating that an LLM verdict is in flight:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "approve_request",
|
||||
"judge_pending": true,
|
||||
"items": [
|
||||
{
|
||||
"call_id": "call_abc123",
|
||||
"header": "bash: npm install express",
|
||||
"preview": "",
|
||||
"func_name": "bash",
|
||||
"approval_label": "bash",
|
||||
"needs_approval": true,
|
||||
"error": null,
|
||||
"verdict": {
|
||||
"verdict_id": "a1b2c3d4e5f6",
|
||||
"call_id": "call_abc123",
|
||||
"func_name": "bash",
|
||||
"intent_summary": "Package installation: npm install express",
|
||||
"risk_level": "medium",
|
||||
"confidence": 0.70,
|
||||
"recommendation": "review",
|
||||
"reasoning": "Command installs a software package which may modify the environment.",
|
||||
"evidence": ["Matched rule: package-install"],
|
||||
"tier": "heuristic",
|
||||
"judge_model": "",
|
||||
"latency_ms": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `intent_verdict`
|
||||
|
||||
Delivered asynchronously when the LLM judge completes. The UI replaces the
|
||||
heuristic verdict badge with the LLM verdict:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "intent_verdict",
|
||||
"verdict_id": "f7e8d9c0b1a2",
|
||||
"call_id": "call_abc123",
|
||||
"func_name": "bash",
|
||||
"intent_summary": "Install Express.js web framework via npm",
|
||||
"risk_level": "medium",
|
||||
"confidence": 0.85,
|
||||
"recommendation": "review",
|
||||
"reasoning": "The command installs express from npm. This is a well-known package but will modify node_modules and package.json.",
|
||||
"evidence": ["Checked package.json — express is not currently a dependency"],
|
||||
"tier": "llm",
|
||||
"judge_model": "gpt-5",
|
||||
"latency_ms": 2340
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Skill Scanner
|
||||
|
||||
Skills are evaluated by a content scanner at creation and update time. The
|
||||
scanner runs the same class of pattern analysis as the heuristic rules but
|
||||
operates on SKILL.md content rather than individual tool calls. It evaluates
|
||||
four independent risk axes:
|
||||
|
||||
1. **Content risk** — command execution scope, external downloads, credential
|
||||
handling, eval/exec, sudo, data exfiltration, browser automation
|
||||
2. **Supply chain risk** — pipe-to-shell, transitive installs (`npx skills add`),
|
||||
obfuscation, download-execute chains, executable URLs from untrusted domains
|
||||
3. **Vulnerability risk** — prompt injection patterns, insecure credential
|
||||
handling, third-party content exposure (indirect prompt injection surface)
|
||||
4. **Declared capability risk** — parsed from `allowed-tools` in the skill's SKILL.md.
|
||||
`Bash(*)` (unrestricted shell) is high risk. `Bash(git:*)` is low.
|
||||
Read-only tools are safe.
|
||||
|
||||
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.
|
||||
|
||||
The scanner is a pure function (~2ms) with no I/O. It runs synchronously in
|
||||
the storage layer. Scanner failures are silently caught to never block skill
|
||||
creation.
|
||||
|
||||
See [docs/governance.md](governance.md) for the skill governance model.
|
||||
|
||||
---
|
||||
|
||||
## Output Guard
|
||||
|
||||
The output guard evaluates tool execution results *after* execution but *before*
|
||||
they enter the conversation context. It catches content-level threats that the
|
||||
input heuristic (which evaluates commands) cannot see — prompt injection
|
||||
payloads in fetched web pages, credential leakage in command output, encoded
|
||||
payloads, and adversarial URLs.
|
||||
|
||||
The guard runs as a synchronous heuristic on the tool result text with a
|
||||
configurable time budget (default 5 seconds). Pattern checks run in priority
|
||||
order: prompt injection first, then credentials, then encoded payloads, then
|
||||
lower-priority checks. If the budget is exhausted mid-evaluation, whatever
|
||||
flags have been found so far are returned.
|
||||
|
||||
The guard **annotates but does not gate** — it surfaces warnings via the
|
||||
`on_output_warning` SSE event and optionally redacts detected credentials
|
||||
from the output before it enters the conversation.
|
||||
|
||||
### Detection priorities
|
||||
|
||||
| Priority | Category | Risk | Examples |
|
||||
|----------|----------|------|----------|
|
||||
| 1 | Prompt injection | high | Override phrases, role injection (`{"role":"system"}`), instruction override markers |
|
||||
| 2 | Credential leakage | high | API keys, private key blocks, connection strings, `.env` format secrets, JSON secrets (`"api_key": "..."`, `"password": "..."`, etc.) |
|
||||
| 3 | Encoded payloads | medium | Script data URIs, hex shellcode sequences |
|
||||
| 4 | Adversarial URLs | medium | Cloud metadata endpoints, credential-bearing query parameters |
|
||||
| 5 | System info disclosure | low | Private IP addresses, sensitive file paths |
|
||||
|
||||
### Credential redaction
|
||||
|
||||
When `redact_secrets` is enabled (default), detected credentials in tool output
|
||||
are replaced with `[REDACTED:<type>]` markers before the output enters the
|
||||
conversation. The original unredacted output is never shown to the model.
|
||||
Redaction types: `api_key`, `private_key`, `password`, `secret`.
|
||||
|
||||
### Configuration
|
||||
|
||||
```toml
|
||||
[judge]
|
||||
output_guard = true # enable output evaluation (default)
|
||||
redact_secrets = true # auto-redact detected credentials (default)
|
||||
```
|
||||
|
||||
Configurable at runtime via the admin Settings tab.
|
||||
|
||||
### SSE event: `output_warning`
|
||||
|
||||
When the output guard detects risk signals, an `output_warning` SSE event is
|
||||
emitted to the frontend:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "output_warning",
|
||||
"call_id": "call_abc123",
|
||||
"func_name": "bash",
|
||||
"risk_level": "high",
|
||||
"flags": ["credential_leak"],
|
||||
"annotations": ["API key detected (sk-proj-...)"],
|
||||
"output_length": 1024,
|
||||
"redacted": true
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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 `scan_status` of `high` or `critical` is loaded into a
|
||||
session, a warning is emitted via `on_info`:
|
||||
|
||||
```
|
||||
⚠ Skill 'my-skill' has scan status: high.
|
||||
Review scan report in admin panel before enabling in production.
|
||||
```
|
||||
|
||||
This ensures operators see a warning even if they missed the scan badge in
|
||||
the admin skills tab.
|
||||
|
||||
---
|
||||
|
||||
## Data Collection for v2 Calibration
|
||||
|
||||
All three evaluation systems persist their assessments for future calibration:
|
||||
|
||||
| Table | Source | Key columns |
|
||||
|-------|--------|-------------|
|
||||
| `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 | `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:
|
||||
|
||||
- Identify tools that are always approved (candidates for auto-approve policies)
|
||||
- Detect false positives in heuristic rules (intent + output guard)
|
||||
- Measure LLM judge accuracy against human decisions
|
||||
- Recommend policy changes to reduce approval fatigue
|
||||
- Tune output guard sensitivity per tool (e.g., `bash` output needs more
|
||||
scrutiny than `read_file`)
|
||||
|
||||
Output assessments are queryable via `GET /v1/api/admin/output-assessments`
|
||||
(requires `admin.judge` permission). Skills can be re-scanned via
|
||||
`POST /v1/api/admin/skills/{id}/rescan` when the scanner is updated.
|
||||
|
||||
This data-driven approach means v1 is both useful on its own and a foundation
|
||||
for automated policy tuning.
|
||||
@@ -0,0 +1,193 @@
|
||||
# MCP Registry Integration
|
||||
|
||||
Turnstone integrates with the [official MCP Registry](https://registry.modelcontextprotocol.io) to let administrators discover and install MCP servers directly from the console admin panel.
|
||||
|
||||
## Overview
|
||||
|
||||
The MCP Registry is maintained by the [Agentic AI Foundation](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation) (Linux Foundation) and serves as the canonical discovery layer for MCP servers. Turnstone queries its REST API (v0.1) for server metadata and provides a one-click install flow.
|
||||
|
||||
Three sources of MCP servers coexist in Turnstone:
|
||||
|
||||
| Source | Badge | Description |
|
||||
|--------|-------|-------------|
|
||||
| **Config** | `CONFIG` (magenta) | Imported from `config.toml` or JSON file. Read-only in admin UI. |
|
||||
| **Manual** | `MANUAL` (cyan) | Added through the admin UI or API. Full CRUD. |
|
||||
| **Registry** | `REGISTRY` (green) | Installed from the MCP Registry. Tracked by `registry_name`. |
|
||||
|
||||
## Admin UI
|
||||
|
||||
The MCP admin tab has two views, toggled by a pill selector:
|
||||
|
||||
### Servers View
|
||||
|
||||
Lists all installed MCP servers regardless of source. Each server shows:
|
||||
|
||||
- **Source badge** — CONFIG, MANUAL, or REGISTRY
|
||||
- **Transport badge** — stdio or streamable-http
|
||||
- **Tool/resource/prompt counts** — aggregated across cluster nodes
|
||||
- **Per-node connection status** — connected (magenta dot), error (red), disabled (gray)
|
||||
- **Actions** — Edit / Delete (DB-managed servers only)
|
||||
|
||||
Clicking a server name opens the detail modal. For registry-installed servers, the detail modal includes a **Registry** section showing the registry name, installed version, description, and website link.
|
||||
|
||||
### Registry View
|
||||
|
||||
Search and browse the MCP Registry. Switching to this view auto-loads a listing. Type a query and press Enter or click Search to filter.
|
||||
|
||||
Each result card shows:
|
||||
|
||||
- **Server name and description**
|
||||
- **Source type badges** — remote (streamable-http), npm, pypi
|
||||
- **Version number**
|
||||
- **Install / Installed / Update button**
|
||||
|
||||
#### Install flow
|
||||
|
||||
- **One-click**: Remote servers with no required headers or URL variables install immediately — no modal, no form. The server is added to the database, all cluster nodes are notified, and a toast confirms success.
|
||||
|
||||
- **Modal**: Servers that require configuration (API keys, headers, URL template variables) or offer multiple install sources (both remote and package) open an install modal with:
|
||||
- Source selector (radio group) — only shown when both remote and package are available
|
||||
- Dynamic form fields for required/optional configuration
|
||||
- Secret fields rendered as password inputs
|
||||
|
||||
## Configuration
|
||||
|
||||
### Registry URL
|
||||
|
||||
By default, Turnstone queries `https://registry.modelcontextprotocol.io`. Override this for enterprise or private registries:
|
||||
|
||||
**Via admin Settings tab:**
|
||||
|
||||
Set `mcp.registry_url` to your registry's base URL.
|
||||
|
||||
**Via config.toml:**
|
||||
|
||||
```toml
|
||||
[mcp]
|
||||
registry_url = "https://registry.internal.example.com"
|
||||
```
|
||||
|
||||
The resolution order is: database setting > config.toml > default.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Both endpoints require `admin.mcp` permission.
|
||||
|
||||
### Search
|
||||
|
||||
```
|
||||
GET /v1/api/admin/mcp-registry/search?search=github&limit=20&cursor=...
|
||||
```
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `search` | string | `""` | Search query. Empty returns a browsable listing. |
|
||||
| `limit` | integer | `20` | Results per page (max 100). |
|
||||
| `cursor` | string | — | Opaque cursor from `next_cursor` for pagination. |
|
||||
|
||||
The response annotates each server with `installed`, `installed_server_id`, `installed_version`, and `update_available` by cross-referencing the `mcp_servers` table.
|
||||
|
||||
### Install
|
||||
|
||||
```
|
||||
POST /v1/api/admin/mcp-registry/install
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"registry_name": "io.example/mcp-server",
|
||||
"source": "remote",
|
||||
"index": 0,
|
||||
"name": "",
|
||||
"variables": {},
|
||||
"env": {"API_KEY": "sk-..."},
|
||||
"headers": {"Authorization": "Bearer ..."}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `registry_name` | Yes | Server name from registry search results. |
|
||||
| `source` | Yes | `"remote"` (streamable-http) or `"package"` (npm/pypi). |
|
||||
| `index` | No | Which remote or package entry to use (default `0`). |
|
||||
| `name` | No | Custom server name. Auto-derived from registry name if empty. |
|
||||
| `variables` | No | Values for URL template `{var}` placeholders. |
|
||||
| `env` | No | Environment variable values for package servers. |
|
||||
| `headers` | No | Header values for remote servers. |
|
||||
|
||||
On success, the server is created in the database and all cluster nodes are automatically reloaded. Returns the created `McpServerDetail`.
|
||||
|
||||
Errors: `400` (validation), `404` (not found in registry), `409` (already installed or name collision), `502` (registry unreachable).
|
||||
|
||||
## SDK
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from turnstone.sdk.console import TurnstoneConsole
|
||||
|
||||
with TurnstoneConsole("http://localhost:8081", token="...") as client:
|
||||
# Search
|
||||
results = client.search_mcp_registry(q="github", limit=10)
|
||||
for srv in results.servers:
|
||||
print(f"{srv.name} v{srv.version} - {srv.description}")
|
||||
|
||||
# Install a remote server
|
||||
detail = client.install_from_registry(
|
||||
"io.example/mcp-server",
|
||||
"remote",
|
||||
headers={"Authorization": "Bearer sk-..."},
|
||||
)
|
||||
print(f"Installed: {detail.name}")
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
```typescript
|
||||
import { TurnstoneConsole } from "@anthropic/turnstone-sdk";
|
||||
|
||||
const client = new TurnstoneConsole({
|
||||
baseUrl: "http://localhost:8081",
|
||||
token: "...",
|
||||
});
|
||||
|
||||
// Search
|
||||
const results = await client.searchMcpRegistry({ q: "github", limit: 10 });
|
||||
for (const srv of results.servers) {
|
||||
console.log(`${srv.name} v${srv.version} - ${srv.description}`);
|
||||
}
|
||||
|
||||
// Install
|
||||
const detail = await client.installFromRegistry({
|
||||
registry_name: "io.example/mcp-server",
|
||||
source: "remote",
|
||||
headers: { Authorization: "Bearer sk-..." },
|
||||
});
|
||||
```
|
||||
|
||||
## Storage
|
||||
|
||||
Registry-installed servers are stored in the existing `mcp_servers` table with three additional columns (migration 019):
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `registry_name` | TEXT (nullable, unique) | Reverse-DNS name from the registry (e.g. `io.example/mcp-server`). |
|
||||
| `registry_version` | TEXT | Version at time of install. |
|
||||
| `registry_meta` | TEXT (JSON) | Snapshot of description, title, website, icons for display. |
|
||||
|
||||
The partial unique index on `registry_name` prevents duplicate installs while allowing multiple non-registry servers with `NULL` registry_name.
|
||||
|
||||
## Package Type Support
|
||||
|
||||
| Registry Type | Transport | Command | Status |
|
||||
|--------------|-----------|---------|--------|
|
||||
| Remote (streamable-http) | `streamable-http` | — (URL-based) | Supported |
|
||||
| `npm` | `stdio` | `npx -y @scope/package@version` | Supported |
|
||||
| `pypi` | `stdio` | `uvx package==version` | Supported |
|
||||
| `oci` | — | — | Not supported (no runtime available) |
|
||||
| `nuget` | — | — | Not supported |
|
||||
| `mcpb` | — | — | Not supported |
|
||||
|
||||
For `npm` and `pypi` packages, the corresponding runtime (`node`/`npx` or `python`/`uvx`) must be available on the cluster nodes. Connection failures due to missing runtimes appear in the per-node MCP status display.
|
||||
+569
@@ -0,0 +1,569 @@
|
||||
# Structured Memory
|
||||
|
||||
> See also: [Memory Architecture diagram](diagrams/png/23-memory-architecture.png)
|
||||
|
||||
The structured memory system gives the AI persistent, typed, scoped memories
|
||||
that survive across sessions and workstreams. Memories are automatically
|
||||
surfaced in the system message via BM25 relevance scoring, so the model has
|
||||
contextual recall without explicit search.
|
||||
|
||||
## Overview
|
||||
|
||||
Each memory has three dimensions:
|
||||
|
||||
- **Type** -- categorizes the memory's purpose
|
||||
- **Scope** -- controls visibility boundaries
|
||||
- **Name** -- unique identifier within a scope (snake_case, normalized)
|
||||
|
||||
### Memory types
|
||||
|
||||
| Type | Purpose |
|
||||
|-------------|------------------------------------------------------------|
|
||||
| `user` | User preferences, conventions, working style |
|
||||
| `project` | Project-specific knowledge, architecture, patterns |
|
||||
| `feedback` | Corrections, lessons learned, things to avoid |
|
||||
| `reference` | Reference material, documentation, specifications |
|
||||
|
||||
### Memory scopes
|
||||
|
||||
| Scope | Visibility |
|
||||
|--------------|-----------------------------------------------------------|
|
||||
| `global` | Visible to all workstreams and users |
|
||||
| `workstream` | Visible only within the originating workstream |
|
||||
| `user` | Follows the authenticated user across workstreams |
|
||||
|
||||
A memory's identity is the tuple `(name, scope, scope_id)`. Saving a memory
|
||||
with the same identity upserts -- updating content while preserving the ID.
|
||||
|
||||
### BM25 relevance injection
|
||||
|
||||
On every conversation turn, the system:
|
||||
|
||||
1. Fetches up to `fetch_limit` memories visible in the current scope
|
||||
2. Extracts context from the last 3 user messages
|
||||
3. Scores memories against that context using a BM25 index
|
||||
4. Injects the top `relevance_k` memories into the system message as
|
||||
`<memories>` XML tags
|
||||
5. Appends a hint telling the model how many memories are in scope
|
||||
|
||||
This means the model always has its most relevant memories available without
|
||||
explicit recall -- but can still use `memory(action='search')` for deeper
|
||||
lookup.
|
||||
|
||||
### Nudges
|
||||
|
||||
The metacognition layer can nudge the model to save memories at appropriate
|
||||
moments (e.g., after a correction or when resuming a workstream). Nudges are
|
||||
rate-limited by `nudge_cooldown` and can be disabled entirely.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### config.toml
|
||||
|
||||
```toml
|
||||
[memory]
|
||||
relevance_k = 5 # top-k memories injected per turn
|
||||
fetch_limit = 50 # max memories fetched from storage for scoring
|
||||
max_content = 32768 # max content length per memory (characters)
|
||||
nudge_cooldown = 300 # minimum seconds between memory nudges
|
||||
nudges = true # enable/disable metacognitive nudges
|
||||
```
|
||||
|
||||
All fields are optional. Defaults are shown above.
|
||||
|
||||
---
|
||||
|
||||
## Tool Usage
|
||||
|
||||
The `memory` tool supports four actions:
|
||||
|
||||
### save
|
||||
|
||||
Store or update a memory.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "save",
|
||||
"name": "project_architecture",
|
||||
"content": "The project uses a hexagonal architecture with...",
|
||||
"description": "Core architecture patterns",
|
||||
"type": "project",
|
||||
"scope": "global"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
|---------------|----------|-------------|------------------------------------------|
|
||||
| `name` | yes | -- | Snake_case identifier (max 256 chars) |
|
||||
| `content` | yes | -- | Memory content (max `max_content` chars) |
|
||||
| `description` | no | `""` | Short description for relevance matching |
|
||||
| `type` | no | `"project"` | One of: user, project, feedback, reference |
|
||||
| `scope` | no | `"global"` | One of: global, workstream, user |
|
||||
|
||||
### search
|
||||
|
||||
Find memories by query (BM25 full-text search).
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "search",
|
||||
"query": "authentication patterns",
|
||||
"type": "project",
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
|-----------|----------|---------|--------------------------------------|
|
||||
| `query` | yes | -- | Search query |
|
||||
| `type` | no | `""` | Filter by type |
|
||||
| `scope` | no | `""` | Filter by scope |
|
||||
| `limit` | no | `20` | Max results (capped at 50) |
|
||||
|
||||
### delete
|
||||
|
||||
Remove a memory by name.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "delete",
|
||||
"name": "outdated_pattern",
|
||||
"scope": "global"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
|------------|----------|------------|--------------------------|
|
||||
| `name` | yes | -- | Memory name to delete |
|
||||
| `scope` | no | `"global"` | Scope of the memory |
|
||||
|
||||
### list
|
||||
|
||||
List all memories with optional filters.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list",
|
||||
"type": "feedback",
|
||||
"limit": 50
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
|-----------|----------|---------|----------------------------|
|
||||
| `type` | no | `""` | Filter by type |
|
||||
| `scope` | no | `""` | Filter by scope |
|
||||
| `limit` | no | `20` | Max results (capped at 50) |
|
||||
|
||||
---
|
||||
|
||||
## Server API
|
||||
|
||||
Four endpoints on the server for programmatic memory access.
|
||||
|
||||
### `GET /v1/api/memories`
|
||||
|
||||
List memories with optional filters.
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|------------|--------|----------|---------|------------------------------|
|
||||
| `type` | string | no | `""` | Filter by memory type |
|
||||
| `scope` | string | no | `""` | Filter by scope |
|
||||
| `scope_id` | string | no | `""` | Filter by scope ID |
|
||||
| `limit` | int | no | `100` | Max results (capped at 200) |
|
||||
|
||||
When `scope=user` and `scope_id` is omitted, the authenticated user's ID is
|
||||
used automatically.
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"memory_id": "a1b2c3d4-e5f6-...",
|
||||
"name": "project_architecture",
|
||||
"description": "Core architecture patterns",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "The project uses a hexagonal architecture...",
|
||||
"created": "2026-03-10T10:00:00",
|
||||
"updated": "2026-03-12T14:30:00"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/memories`
|
||||
|
||||
Save or upsert a structured memory.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "deployment_process",
|
||||
"content": "Deploy via GitHub Actions. Staging auto-deploys on push to main.",
|
||||
"description": "CI/CD deployment workflow",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": ""
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|--------------|--------|----------|-------------|--------------------------------------|
|
||||
| `name` | string | yes | -- | Memory name (max 256 chars) |
|
||||
| `content` | string | yes | -- | Memory content (max 65536 chars) |
|
||||
| `description`| string | no | `""` | Short description for search ranking |
|
||||
| `type` | string | no | `"project"` | One of: user, project, feedback, reference |
|
||||
| `scope` | string | no | `"global"` | One of: global, workstream, user |
|
||||
| `scope_id` | string | no | `""` | Scope qualifier (auto-resolved for user scope) |
|
||||
|
||||
**Response (created):** `201`
|
||||
|
||||
```json
|
||||
{
|
||||
"memory_id": "a1b2c3d4-e5f6-...",
|
||||
"name": "deployment_process",
|
||||
"description": "CI/CD deployment workflow",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "Deploy via GitHub Actions...",
|
||||
"created": "2026-03-14T10:00:00",
|
||||
"updated": "2026-03-14T10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (updated):** `200` -- same schema, returned when a memory with the
|
||||
same `(name, scope, scope_id)` already existed.
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Condition |
|
||||
|--------|------------------------------------|
|
||||
| 400 | Missing name, empty content, invalid type/scope, content too long |
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/memories/search`
|
||||
|
||||
Search memories by query. Uses POST for the request body but is non-mutating
|
||||
(requires only `read` scope).
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "authentication",
|
||||
"type": "project",
|
||||
"scope": "",
|
||||
"scope_id": "",
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|------------|--------|----------|---------|--------------------------------|
|
||||
| `query` | string | yes | -- | Search query |
|
||||
| `type` | string | no | `""` | Filter by type |
|
||||
| `scope` | string | no | `""` | Filter by scope |
|
||||
| `scope_id` | string | no | `""` | Filter by scope ID |
|
||||
| `limit` | int | no | `20` | Max results (capped at 50) |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"memory_id": "a1b2c3d4-e5f6-...",
|
||||
"name": "auth_patterns",
|
||||
"description": "Authentication architecture",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "JWT tokens with HS256...",
|
||||
"created": "2026-03-10T10:00:00",
|
||||
"updated": "2026-03-12T14:30:00"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /v1/api/memories/{name}`
|
||||
|
||||
Delete a memory by name and scope.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------|----------------------|
|
||||
| `name` | string | Memory name |
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|------------|--------|----------|------------|---------------------|
|
||||
| `scope` | string | no | `"global"` | Scope of the memory |
|
||||
| `scope_id` | string | no | `""` | Scope qualifier |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{"status": "ok", "name": "deployment_process"}
|
||||
```
|
||||
|
||||
**Response (not found):** `404`
|
||||
|
||||
```json
|
||||
{"error": "Memory 'deployment_process' not found"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Console Admin API
|
||||
|
||||
Four admin endpoints for cross-workstream memory management. All require the
|
||||
`admin.memories` permission.
|
||||
|
||||
### `GET /v1/api/admin/memories`
|
||||
|
||||
List memories across all scopes (no automatic scope resolution).
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|------------|--------|----------|---------|------------------------------|
|
||||
| `type` | string | no | `""` | Filter by type |
|
||||
| `scope` | string | no | `""` | Filter by scope |
|
||||
| `scope_id` | string | no | `""` | Filter by scope ID |
|
||||
| `limit` | int | no | `100` | Max results (capped at 200) |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"memory_id": "a1b2c3d4-e5f6-...",
|
||||
"name": "project_architecture",
|
||||
"description": "Core architecture patterns",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "The project uses...",
|
||||
"created": "2026-03-10T10:00:00",
|
||||
"updated": "2026-03-12T14:30:00"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/admin/memories/search`
|
||||
|
||||
Search memories by query (uses query parameters, not POST body).
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|------------|--------|----------|---------|-------------------------------|
|
||||
| `q` | string | yes | -- | Search query |
|
||||
| `type` | string | no | `""` | Filter by type |
|
||||
| `scope` | string | no | `""` | Filter by scope |
|
||||
| `scope_id` | string | no | `""` | Filter by scope ID |
|
||||
| `limit` | int | no | `20` | Max results (capped at 50) |
|
||||
|
||||
**Response:** `200` -- same schema as `GET /v1/api/admin/memories`.
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/admin/memories/{memory_id}`
|
||||
|
||||
Get a single memory by ID.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-------------|--------|------------------------|
|
||||
| `memory_id` | string | Memory UUID |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"memory_id": "a1b2c3d4-e5f6-...",
|
||||
"name": "project_architecture",
|
||||
"description": "Core architecture patterns",
|
||||
"type": "project",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "The project uses...",
|
||||
"created": "2026-03-10T10:00:00",
|
||||
"updated": "2026-03-12T14:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (not found):** `404`
|
||||
|
||||
```json
|
||||
{"error": "Memory not found"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /v1/api/admin/memories/{memory_id}`
|
||||
|
||||
Delete a memory by ID. Records an audit event (`memory.delete`).
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-------------|--------|------------------------|
|
||||
| `memory_id` | string | Memory UUID |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
**Response (not found):** `404`
|
||||
|
||||
```json
|
||||
{"error": "Memory not found"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SDK
|
||||
|
||||
### Python
|
||||
|
||||
The server SDK uses `mem_type` (not `type`) to avoid shadowing the Python
|
||||
builtin.
|
||||
|
||||
```python
|
||||
from turnstone.sdk import TurnstoneServer
|
||||
|
||||
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
# Save a memory
|
||||
mem = client.save_memory(
|
||||
"api_conventions",
|
||||
"All endpoints use /v1/ prefix. JSON responses.",
|
||||
description="API design patterns",
|
||||
mem_type="project",
|
||||
scope="global",
|
||||
)
|
||||
print(mem.memory_id)
|
||||
|
||||
# Search memories
|
||||
results = client.search_memories("authentication", mem_type="project", limit=10)
|
||||
for m in results.memories:
|
||||
print(f"{m['name']}: {m['description']}")
|
||||
|
||||
# List memories
|
||||
all_mems = client.list_memories(mem_type="feedback", limit=50)
|
||||
|
||||
# Delete a memory
|
||||
client.delete_memory("api_conventions", scope="global")
|
||||
```
|
||||
|
||||
Console admin SDK:
|
||||
|
||||
```python
|
||||
from turnstone.sdk import TurnstoneConsole
|
||||
|
||||
with TurnstoneConsole("http://localhost:9090", token="tok_xxx") as admin:
|
||||
# List all memories (admin view, no scope auto-resolution)
|
||||
result = admin.list_memories(scope="global", limit=100)
|
||||
|
||||
# Search
|
||||
result = admin.search_memories("architecture", mem_type="project")
|
||||
|
||||
# Get by ID
|
||||
mem = admin.get_memory("a1b2c3d4-e5f6-...")
|
||||
|
||||
# Delete by ID
|
||||
admin.delete_memory("a1b2c3d4-e5f6-...")
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
```typescript
|
||||
import { TurnstoneServer } from "@turnstone/sdk";
|
||||
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://localhost:8080",
|
||||
token: "tok_xxx",
|
||||
});
|
||||
|
||||
// Save a memory
|
||||
const mem = await client.saveMemory({
|
||||
name: "api_conventions",
|
||||
content: "All endpoints use /v1/ prefix. JSON responses.",
|
||||
description: "API design patterns",
|
||||
type: "project",
|
||||
scope: "global",
|
||||
});
|
||||
|
||||
// Search memories
|
||||
const results = await client.searchMemories({
|
||||
query: "authentication",
|
||||
type: "project",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
// List memories
|
||||
const all = await client.listMemories({ type: "feedback", limit: 50 });
|
||||
|
||||
// Delete a memory
|
||||
await client.deleteMemory("api_conventions", { scope: "global" });
|
||||
```
|
||||
|
||||
Console admin SDK:
|
||||
|
||||
```typescript
|
||||
import { TurnstoneConsole } from "@turnstone/sdk";
|
||||
|
||||
const admin = new TurnstoneConsole({
|
||||
baseUrl: "http://localhost:9090",
|
||||
token: "tok_xxx",
|
||||
});
|
||||
|
||||
// List, search, get, delete by ID
|
||||
const mems = await admin.listMemories({ scope: "global" });
|
||||
const found = await admin.searchMemories({ q: "auth", limit: 20 });
|
||||
const one = await admin.getMemory("a1b2c3d4-e5f6-...");
|
||||
await admin.deleteMemory("a1b2c3d4-e5f6-...");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Storage
|
||||
|
||||
Memories are stored in the `structured_memories` table (migration 013).
|
||||
The unique constraint on `(name, scope, scope_id)` ensures upsert semantics.
|
||||
The name is normalized on save: lowercased, hyphens and spaces replaced with
|
||||
underscores.
|
||||
|
||||
## Architecture
|
||||
|
||||
See [Memory Architecture diagram](diagrams/png/23-memory-architecture.png) for
|
||||
the full data flow covering the session tool path, API path, admin path, and
|
||||
BM25 relevance injection.
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
# OpenID Connect (OIDC) Single Sign-On
|
||||
|
||||
Turnstone supports OpenID Connect for federated authentication, allowing
|
||||
users to log in with their existing corporate identity provider instead of
|
||||
managing a separate password. OIDC is opt-in: when configured, the login
|
||||
screen shows a "Continue with SSO" button alongside the existing
|
||||
username/password form. When not configured, the login experience is
|
||||
unchanged.
|
||||
|
||||
Any OIDC-compliant provider works: Google, Okta, Azure AD, Keycloak,
|
||||
Auth0, OneLogin, and others that publish a
|
||||
`.well-known/openid-configuration` discovery document.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A registered **confidential** OIDC client at your identity provider
|
||||
2. The client's redirect URI must include:
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
3. A local admin user must exist in Turnstone (complete the initial setup
|
||||
wizard before enabling OIDC)
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
OIDC is configured via environment variables (preferred) or the `[oidc]`
|
||||
section of `config.toml`. Environment variables take precedence when both
|
||||
are set.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `TURNSTONE_OIDC_ISSUER` | Yes | — | Issuer URL (e.g. `https://accounts.google.com`). Must serve `/.well-known/openid-configuration`. |
|
||||
| `TURNSTONE_OIDC_CLIENT_ID` | Yes | — | OAuth 2.0 client ID from your provider |
|
||||
| `TURNSTONE_OIDC_CLIENT_SECRET` | Yes | — | OAuth 2.0 client secret (confidential client) |
|
||||
| `TURNSTONE_OIDC_SCOPES` | No | `openid email profile` | Space-separated OAuth scopes to request |
|
||||
| `TURNSTONE_OIDC_PROVIDER_NAME` | No | `SSO` | Display name for the login button (e.g. "Google", "Okta") |
|
||||
| `TURNSTONE_OIDC_ROLE_CLAIM` | No | — | ID token claim containing role/group values (see [Role Mapping](#role-mapping)) |
|
||||
| `TURNSTONE_OIDC_ROLE_MAP` | No | — | Mapping from claim values to Turnstone role IDs (see [Role Mapping](#role-mapping)) |
|
||||
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens and config-file tokens still work. |
|
||||
| `TURNSTONE_OIDC_REDIRECT_BASE` | No | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Recommended when running behind a reverse proxy. When unset, derived from the request Host header. |
|
||||
|
||||
OIDC is enabled when all three required fields (issuer, client ID, client
|
||||
secret) are non-empty. If any is missing, OIDC is silently disabled and
|
||||
the login screen shows only the password form.
|
||||
|
||||
### Reverse Proxy / Load Balancer
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### config.toml alternative
|
||||
|
||||
```toml
|
||||
[oidc]
|
||||
issuer = "https://accounts.google.com"
|
||||
client_id = "your-client-id"
|
||||
client_secret = "your-client-secret"
|
||||
scopes = "openid email profile"
|
||||
provider_name = "Google"
|
||||
role_claim = "groups"
|
||||
password_enabled = true
|
||||
redirect_base = "https://app.example.com"
|
||||
|
||||
[oidc.role_map]
|
||||
admin = "builtin-admin"
|
||||
engineering = "builtin-operator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Provider-Specific Setup
|
||||
|
||||
### Google
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/) >
|
||||
**APIs & Services** > **Credentials**
|
||||
2. Click **Create Credentials** > **OAuth 2.0 Client ID**
|
||||
3. Application type: **Web application**
|
||||
4. Add authorized redirect URI:
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
5. Copy the **Client ID** and **Client secret**
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ISSUER=https://accounts.google.com
|
||||
TURNSTONE_OIDC_CLIENT_ID=123456789.apps.googleusercontent.com
|
||||
TURNSTONE_OIDC_CLIENT_SECRET=GOCSPX-...
|
||||
TURNSTONE_OIDC_PROVIDER_NAME=Google
|
||||
```
|
||||
|
||||
### Okta
|
||||
|
||||
1. In the Okta Admin Console, go to **Applications** > **Create App
|
||||
Integration**
|
||||
2. Sign-in method: **OIDC - OpenID Connect**
|
||||
3. Application type: **Web Application**
|
||||
4. Add sign-in redirect URI:
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
5. Note the **Issuer** (your Okta domain, e.g.
|
||||
`https://dev-123456.okta.com`)
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ISSUER=https://dev-123456.okta.com
|
||||
TURNSTONE_OIDC_CLIENT_ID=0oaXXXXXXXXXXXXX
|
||||
TURNSTONE_OIDC_CLIENT_SECRET=...
|
||||
TURNSTONE_OIDC_PROVIDER_NAME=Okta
|
||||
TURNSTONE_OIDC_ROLE_CLAIM=groups
|
||||
TURNSTONE_OIDC_ROLE_MAP="admin:builtin-admin,everyone:builtin-operator"
|
||||
```
|
||||
|
||||
### Azure AD (Entra ID)
|
||||
|
||||
1. In the Azure Portal, go to **App registrations** > **New registration**
|
||||
2. Redirect URI: **Web** >
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
3. Under **Certificates & secrets**, create a new **Client secret** and
|
||||
copy the value immediately
|
||||
4. The issuer URL is
|
||||
`https://login.microsoftonline.com/{tenant-id}/v2.0`
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ISSUER=https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0
|
||||
TURNSTONE_OIDC_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
TURNSTONE_OIDC_CLIENT_SECRET=...
|
||||
TURNSTONE_OIDC_PROVIDER_NAME="Azure AD"
|
||||
TURNSTONE_OIDC_ROLE_CLAIM=roles
|
||||
TURNSTONE_OIDC_ROLE_MAP="Admin:builtin-admin,User:builtin-operator"
|
||||
```
|
||||
|
||||
### Keycloak
|
||||
|
||||
1. In the Keycloak Admin Console, select your **Realm**
|
||||
2. Go to **Clients** > **Create client**
|
||||
3. Client type: **OpenID Connect**
|
||||
4. Set **Client authentication** to **On** (confidential)
|
||||
5. Add valid redirect URI:
|
||||
`https://your-turnstone-host/v1/api/auth/oidc/callback`
|
||||
6. The issuer URL is
|
||||
`https://keycloak.example.com/realms/your-realm`
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ISSUER=https://keycloak.example.com/realms/your-realm
|
||||
TURNSTONE_OIDC_CLIENT_ID=turnstone
|
||||
TURNSTONE_OIDC_CLIENT_SECRET=...
|
||||
TURNSTONE_OIDC_PROVIDER_NAME=Keycloak
|
||||
TURNSTONE_OIDC_ROLE_CLAIM=realm_access.roles
|
||||
TURNSTONE_OIDC_ROLE_MAP="admin:builtin-admin,operator:builtin-operator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Role Mapping
|
||||
|
||||
OIDC role mapping assigns Turnstone roles to users based on claims in the
|
||||
ID token. This is optional — without it, OIDC users are provisioned with
|
||||
the `builtin-viewer` role (read-only access) by default.
|
||||
|
||||
### Configuration
|
||||
|
||||
Set `TURNSTONE_OIDC_ROLE_CLAIM` to the name of the claim in the ID token
|
||||
that contains the user's group or role memberships. Then set
|
||||
`TURNSTONE_OIDC_ROLE_MAP` to map claim values to Turnstone role IDs.
|
||||
|
||||
The role map is a comma-separated list of `claim_value:turnstone_role`
|
||||
pairs:
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_ROLE_CLAIM=groups
|
||||
TURNSTONE_OIDC_ROLE_MAP="admin:builtin-admin,engineering:builtin-operator,viewer:builtin-viewer"
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- **Synced on every login**: roles are added when new claim values appear,
|
||||
and OIDC-assigned roles are revoked when the corresponding claim value
|
||||
is no longer present. Roles assigned manually (or by other sources) are
|
||||
never touched — only roles with `assigned_by="oidc"` are subject to
|
||||
revocation.
|
||||
- **List or string**: the claim value can be a JSON array
|
||||
(`["admin", "engineering"]`) or a single string (`"admin"`). Both are
|
||||
handled correctly.
|
||||
- **Unknown values**: claim values not present in the role map are silently
|
||||
ignored.
|
||||
- **Missing roles**: if the role map references a Turnstone role ID that
|
||||
does not exist in the database, the assignment is skipped (no error).
|
||||
- **Evaluated on every login**: roles are checked and applied each time
|
||||
the user authenticates via OIDC, so new group memberships are picked
|
||||
up on the next login.
|
||||
|
||||
### Built-in Roles
|
||||
|
||||
| Role ID | Permissions |
|
||||
|---------|-------------|
|
||||
| `builtin-admin` | All permissions |
|
||||
| `builtin-operator` | read, write, workstreams.create, workstreams.close |
|
||||
| `builtin-viewer` | read |
|
||||
|
||||
---
|
||||
|
||||
## User Provisioning
|
||||
|
||||
When a user logs in via OIDC for the first time, Turnstone automatically
|
||||
creates a local user account:
|
||||
|
||||
1. The OIDC identity (`issuer` + `sub` claim) is stored in the
|
||||
`oidc_identities` table and linked to the new user
|
||||
2. The **username** is derived from the `preferred_username` claim,
|
||||
falling back to the email local part, with deduplication if needed
|
||||
3. The **display name** comes from the `name` claim, falling back to
|
||||
`preferred_username` or email
|
||||
4. The user's password hash is set to a sentinel value (`!oidc`) — OIDC
|
||||
users cannot log in with a password
|
||||
|
||||
On subsequent logins, the existing user is matched by `(issuer, sub)` and
|
||||
the `last_login` timestamp is updated. Role mapping is re-evaluated on
|
||||
every login.
|
||||
|
||||
---
|
||||
|
||||
## OIDC-Only Mode
|
||||
|
||||
To enforce OIDC for all logins and hide the password form, set:
|
||||
|
||||
```bash
|
||||
TURNSTONE_OIDC_PASSWORD_ENABLED=false
|
||||
```
|
||||
|
||||
In this mode the login screen shows only the "Continue with SSO" button.
|
||||
The password form, token toggle, and sign-in button are all hidden.
|
||||
All username/password logins are blocked at the API level, including
|
||||
admin accounts.
|
||||
|
||||
The first admin account must be created via the setup wizard (with a
|
||||
password) before OIDC is enabled. The setup wizard always works
|
||||
regardless of this setting because it is only available when zero users
|
||||
exist in the database.
|
||||
|
||||
API token login (`POST /v1/api/auth/login` with a `ts_` token) and
|
||||
config-file tokens (`Authorization: Bearer tok_xxx`) continue to work
|
||||
regardless of this setting. OIDC-only mode affects password-based
|
||||
authentication only.
|
||||
|
||||
---
|
||||
|
||||
## Login Flow
|
||||
|
||||
Both the server and console support OIDC login. The flow is identical:
|
||||
|
||||
1. The browser fetches `GET /v1/api/auth/status` at page load
|
||||
2. If the response includes `oidc_enabled: true`, the login screen shows
|
||||
a "Continue with {provider_name}" button
|
||||
3. Clicking the button navigates to `GET /v1/api/auth/oidc/authorize`
|
||||
4. Turnstone generates a state token, nonce, and PKCE verifier, stores
|
||||
them in the database, and redirects the browser to the identity
|
||||
provider's authorization endpoint
|
||||
5. The user authenticates at the identity provider
|
||||
6. The IdP redirects back to
|
||||
`GET /v1/api/auth/oidc/callback?code=...&state=...`
|
||||
7. Turnstone validates the state, exchanges the authorization code for
|
||||
tokens using the PKCE verifier, validates the ID token against the
|
||||
provider's JWKS public keys, provisions or matches the user, and
|
||||
issues a Turnstone JWT
|
||||
8. The browser is redirected to `/?oidc_success=1` with the JWT set in
|
||||
an `HttpOnly` session cookie
|
||||
9. The browser JavaScript detects the `oidc_success` query parameter,
|
||||
strips it from the URL, hides the login overlay, and calls
|
||||
`onLoginSuccess()` to initialize the application
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/v1/api/auth/oidc/authorize` | Public | Redirects to identity provider |
|
||||
| GET | `/v1/api/auth/oidc/callback` | Public | Handles IdP callback, issues JWT |
|
||||
|
||||
Both endpoints are public (no authentication required) because they are
|
||||
part of the login flow itself.
|
||||
|
||||
### Auth status response
|
||||
|
||||
When OIDC is enabled, `GET /v1/api/auth/status` includes additional
|
||||
fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"auth_enabled": true,
|
||||
"has_users": true,
|
||||
"setup_required": false,
|
||||
"oidc_enabled": true,
|
||||
"oidc_provider_name": "Google",
|
||||
"password_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
Migration 018 creates two tables:
|
||||
|
||||
```sql
|
||||
CREATE TABLE oidc_identities (
|
||||
issuer TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
created TEXT NOT NULL,
|
||||
last_login TEXT NOT NULL,
|
||||
PRIMARY KEY (issuer, subject)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_oidc_identities_user_id ON oidc_identities(user_id);
|
||||
|
||||
CREATE TABLE oidc_pending_states (
|
||||
state TEXT PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
code_verifier TEXT NOT NULL,
|
||||
audience TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
The `oidc_identities` table links an OIDC subject (identified by
|
||||
`issuer` + `subject`) to a Turnstone `user_id`. A single user can have
|
||||
multiple OIDC identities (e.g. from different providers).
|
||||
|
||||
The `oidc_pending_states` table stores authorization flow state for
|
||||
callback validation. Entries are automatically cleaned up after 5 minutes.
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Authorization Code Flow with PKCE**: the recommended OAuth 2.0 flow
|
||||
for web applications. PKCE prevents authorization code interception
|
||||
attacks even without a client secret (though the client secret is still
|
||||
used for additional security).
|
||||
- **ID token validation**: all tokens are validated using the provider's
|
||||
JWKS public keys (RS256 or ES256). The signature, issuer, audience,
|
||||
and expiry are all checked.
|
||||
- **State parameter**: a cryptographically random state token prevents
|
||||
CSRF attacks on the callback endpoint. The state is stored server-side
|
||||
and verified on callback.
|
||||
- **Nonce**: a random nonce is included in the authorization request and
|
||||
verified in the ID token to prevent replay attacks.
|
||||
- **Client secret**: never leaves the server — it is only used in the
|
||||
server-to-IdP token exchange, not exposed to the browser.
|
||||
- **OIDC users cannot use password login**: the sentinel password hash
|
||||
(`!oidc`) ensures `verify_password()` always rejects password attempts
|
||||
for OIDC-provisioned users.
|
||||
- **Rate limiting**: the callback endpoint shares the login rate limiter
|
||||
(5 attempts per 5-minute window per IP).
|
||||
- **State TTL**: pending authorization states expire after 5 minutes.
|
||||
Expired states are lazily cleaned up on each callback.
|
||||
- **Setup guard**: OIDC login requires at least one local admin user to
|
||||
exist. This ensures the initial admin account is always created via the
|
||||
setup wizard with a password, not hijacked by an external identity.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "OIDC not configured"
|
||||
|
||||
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"
|
||||
|
||||
The authorization flow must complete within 5 minutes. If the user takes
|
||||
too long at the identity provider, the pending state expires. Try again.
|
||||
|
||||
### "Initial setup required"
|
||||
|
||||
OIDC login is blocked until at least one local admin user exists.
|
||||
Complete the setup wizard first (navigate to the Turnstone URL and follow
|
||||
the prompts to create an admin user with a password).
|
||||
|
||||
### Discovery fails at startup
|
||||
|
||||
Check that the issuer URL is reachable from the Turnstone server and
|
||||
serves a valid `/.well-known/openid-configuration` document. The server
|
||||
logs the discovery attempt at startup:
|
||||
|
||||
```
|
||||
OIDC discovery failed for https://your-issuer.example.com: ...
|
||||
```
|
||||
|
||||
OIDC is automatically disabled when discovery fails. Restart the server
|
||||
after fixing the connectivity issue.
|
||||
|
||||
### Redirect URI mismatch
|
||||
|
||||
The redirect URI configured at the identity provider must exactly match
|
||||
`https://your-host/v1/api/auth/oidc/callback`. Common issues:
|
||||
|
||||
- **Scheme mismatch**: the redirect uses `https://` — make sure TLS is
|
||||
configured or a reverse proxy sets the `X-Forwarded-Proto` header
|
||||
- **Port mismatch**: if running on a non-standard port, include it in
|
||||
the redirect URI
|
||||
- **Path mismatch**: the path must include the `/v1` API version prefix
|
||||
|
||||
### User not assigned expected roles
|
||||
|
||||
Check that:
|
||||
|
||||
1. `TURNSTONE_OIDC_ROLE_CLAIM` matches the exact claim name in the ID
|
||||
token (case-sensitive)
|
||||
2. `TURNSTONE_OIDC_ROLE_MAP` maps the correct claim values to valid
|
||||
Turnstone role IDs
|
||||
3. The roles referenced in the map exist in the database (check the
|
||||
admin panel > Roles tab)
|
||||
4. The identity provider is configured to include the claim in the ID
|
||||
token (some providers require explicit scope or claim configuration)
|
||||
@@ -0,0 +1,286 @@
|
||||
# OpenShell Sandbox Integration
|
||||
|
||||
Turnstone can run inside an [OpenShell](https://github.com/NVIDIA/OpenShell)
|
||||
sandbox for kernel-enforced security boundaries around tool execution. OpenShell
|
||||
provides four layers of defense that Turnstone's application-level safety model
|
||||
does not cover:
|
||||
|
||||
| Layer | Mechanism | What it prevents |
|
||||
|-------|-----------|------------------|
|
||||
| Filesystem | Landlock | Writes to `/etc`, `~/.ssh`, system paths |
|
||||
| Network | Network namespace + seccomp + HTTP CONNECT proxy | Connections to unlisted hosts |
|
||||
| Process | `setuid` drop + verification | Privilege escalation to root |
|
||||
| Credentials | Proxy-level secret resolution | API keys in sandbox memory |
|
||||
|
||||
Turnstone's own safety layers (human approval, intent judge, tool policies,
|
||||
output guard) remain active inside the sandbox and handle threats at the semantic
|
||||
level -- what the LLM *means* to do with its legitimate access.
|
||||
|
||||
> See also: [Security and Authentication](security.md),
|
||||
> [Intent Validation](judge.md), [Governance](governance.md)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run turnstone-server in an OpenShell sandbox
|
||||
openshell sandbox run \
|
||||
--policy deploy/openshell/turnstone-policy.yaml \
|
||||
--workdir /path/to/project \
|
||||
-- python3 -m turnstone.server --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
With inference routing (API keys never enter the sandbox):
|
||||
|
||||
```bash
|
||||
openshell sandbox run \
|
||||
--policy deploy/openshell/turnstone-policy.yaml \
|
||||
--inference-routes deploy/openshell/routes.yaml \
|
||||
--workdir /path/to/project \
|
||||
-- python3 -m turnstone.server --host 0.0.0.0 --port 8080 \
|
||||
--base-url https://inference.local
|
||||
```
|
||||
|
||||
The `inference.local` hostname is intercepted by the OpenShell proxy before
|
||||
network policy evaluation -- no network policy entry is needed for it.
|
||||
|
||||
---
|
||||
|
||||
## Policy Files
|
||||
|
||||
### `deploy/openshell/turnstone-policy.yaml`
|
||||
|
||||
The main sandbox policy. Covers filesystem, process, and network rules.
|
||||
|
||||
### `deploy/openshell/routes.yaml`
|
||||
|
||||
Inference routing configuration. Maps `inference.local` to real LLM API
|
||||
backends. Uncomment and configure the provider(s) you use.
|
||||
|
||||
---
|
||||
|
||||
## Filesystem Policy
|
||||
|
||||
The policy uses Landlock (Linux 5.13+) for kernel-enforced filesystem access
|
||||
control. Paths are locked at sandbox creation and cannot be changed at runtime.
|
||||
|
||||
| Path | Access | Purpose |
|
||||
|------|--------|---------|
|
||||
| `--workdir` | read-write | Project files (auto-added via `include_workdir`) |
|
||||
| `/tmp` | read-write | Bash tool temp scripts, eval workdirs |
|
||||
| `/dev/null` | read-write | Shell redirections (`2>/dev/null`) |
|
||||
| `/var/log` | read-write | Log files |
|
||||
| `/usr`, `/lib`, `/lib64` | read-only | Python runtime, installed packages |
|
||||
| `/etc` | read-only | System config, SSL certificates |
|
||||
| `/proc`, `/dev/urandom` | read-only | Process info, entropy |
|
||||
| `~/.config/turnstone` | read-only | Config file (writes go to database) |
|
||||
|
||||
Landlock runs in `best_effort` mode by default -- degrades gracefully on kernels
|
||||
without Landlock support. Set `compatibility: hard_requirement` for production
|
||||
hardened deployments.
|
||||
|
||||
---
|
||||
|
||||
## Network Policy
|
||||
|
||||
Default-deny. Only explicitly listed host:port pairs are reachable. All child
|
||||
processes (MCP servers, bash commands, grep) inherit the network namespace and
|
||||
cannot bypass the proxy.
|
||||
|
||||
### Included endpoints
|
||||
|
||||
| Policy | Hosts | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `openai_api` | `api.openai.com` | OpenAI LLM API |
|
||||
| `anthropic_api` | `api.anthropic.com` | Anthropic LLM API |
|
||||
| `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 |
|
||||
| `web_fetch_common` | readthedocs, python docs, GitHub Pages, PyPI, npm, Stack Overflow, Wikipedia | Curated web_fetch domains |
|
||||
| `bash_network_tools` | Same as `web_fetch_common` | curl/wget from bash tool |
|
||||
| `package_registries` | `pypi.org`, `files.pythonhosted.org` | pip/uv package installs |
|
||||
| `git_operations` | `github.com`, `gitlab.com` (L7: clone/fetch only, no push) | Git read-only operations |
|
||||
|
||||
### L7 enforcement
|
||||
|
||||
Endpoints marked with `protocol: rest` and `tls: terminate` get HTTP-level
|
||||
inspection. The proxy TLS-terminates using an ephemeral per-sandbox CA, parses
|
||||
each request, and evaluates method + path against the rules.
|
||||
|
||||
The `github_api`, `mcp_registry`, and `git_operations` policies use L7
|
||||
enforcement:
|
||||
|
||||
- **GitHub API / MCP Registry**: `access: read-only` -- only GET, HEAD, OPTIONS
|
||||
allowed
|
||||
- **Git operations**: explicit rules allowing only `info/refs` (GET) and
|
||||
`git-upload-pack` (POST) -- clone and fetch work, push is blocked
|
||||
|
||||
### Commented-out sections
|
||||
|
||||
The policy includes commented blocks for optional integrations. Uncomment and
|
||||
configure as needed:
|
||||
|
||||
- **OIDC** -- add your identity provider's hostname
|
||||
- **Discord** -- `discord.com`, `gateway.discord.gg`, `cdn.discordapp.com`
|
||||
- **MCP HTTP servers** -- any MCP servers using streamable-http transport
|
||||
|
||||
---
|
||||
|
||||
## Customizing the Domain Allowlist
|
||||
|
||||
The `web_fetch` tool lets the LLM fetch arbitrary public URLs, but OpenShell
|
||||
cannot allow "all HTTPS" -- bare wildcard hosts are rejected by policy
|
||||
validation. Instead, the policy ships with a curated set of common reference
|
||||
domains.
|
||||
|
||||
To add domains your workloads need:
|
||||
|
||||
```yaml
|
||||
# In turnstone-policy.yaml, under web_fetch_common.endpoints:
|
||||
- host: docs.example.com
|
||||
port: 443
|
||||
|
||||
# Also add to bash_network_tools.endpoints if curl/wget should reach it:
|
||||
- host: docs.example.com
|
||||
port: 443
|
||||
```
|
||||
|
||||
Wildcard patterns are supported:
|
||||
|
||||
- `*.example.com` -- matches one subdomain level (e.g. `api.example.com`)
|
||||
- `**.example.com` -- matches any depth (e.g. `deep.sub.example.com`)
|
||||
|
||||
Unlisted domains return connection errors, which the LLM handles gracefully by
|
||||
telling the user it cannot reach that site.
|
||||
|
||||
---
|
||||
|
||||
## Inference Routing
|
||||
|
||||
Inference routing keeps real API keys completely outside the sandbox. The
|
||||
sandbox process only sees opaque placeholder tokens in its environment
|
||||
(`openshell:resolve:env:ANTHROPIC_API_KEY`). The proxy rewrites these to real
|
||||
credentials on the wire before forwarding to the upstream API.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Edit `deploy/openshell/routes.yaml` -- uncomment your provider:
|
||||
|
||||
```yaml
|
||||
routes:
|
||||
# OpenAI
|
||||
- name: inference.local
|
||||
endpoint: https://api.openai.com/v1
|
||||
model: gpt-5
|
||||
provider_type: openai
|
||||
protocols:
|
||||
- openai_chat_completions
|
||||
- model_discovery
|
||||
api_key_env: OPENAI_API_KEY
|
||||
|
||||
# Or Anthropic
|
||||
- name: inference.local
|
||||
endpoint: https://api.anthropic.com
|
||||
model: claude-sonnet-4-6
|
||||
provider_type: anthropic
|
||||
protocols:
|
||||
- anthropic_messages
|
||||
api_key_env: ANTHROPIC_API_KEY
|
||||
```
|
||||
|
||||
2. Start with `--inference-routes` and point turnstone at `inference.local`:
|
||||
|
||||
```bash
|
||||
openshell sandbox run \
|
||||
--inference-routes deploy/openshell/routes.yaml \
|
||||
--base-url https://inference.local \
|
||||
...
|
||||
```
|
||||
|
||||
3. When inference routing is active, the `openai_api` and `anthropic_api`
|
||||
network policies can be removed from the sandbox policy -- the proxy handles
|
||||
LLM traffic on a separate code path that bypasses OPA entirely.
|
||||
|
||||
### Local model servers
|
||||
|
||||
For local servers (vLLM, llama.cpp) with no authentication, omit both
|
||||
`api_key` and `api_key_env` from the route config. No credential resolution
|
||||
is needed.
|
||||
|
||||
---
|
||||
|
||||
## MCP Server Subprocesses
|
||||
|
||||
MCP servers using stdio transport are spawned as child processes of turnstone.
|
||||
They automatically inherit all sandbox constraints:
|
||||
|
||||
- **Network namespace** -- kernel-level, cannot be bypassed
|
||||
- **Landlock filesystem** -- kernel-level, cannot be relaxed
|
||||
- **Seccomp socket filter** -- kernel-level, inherited on fork
|
||||
|
||||
No per-subprocess policy entries are needed for these constraints. However, if
|
||||
an MCP server makes outbound network requests (through the proxy), its binary
|
||||
must appear in a `binaries[]` entry for the relevant network policy. The proxy
|
||||
identifies the requesting process via `/proc/<pid>/exe` (not `argv[0]`, which
|
||||
is spoofable).
|
||||
|
||||
Example for a Python-based MCP server that calls an external API:
|
||||
|
||||
```yaml
|
||||
mcp_external_api:
|
||||
name: mcp-external
|
||||
endpoints:
|
||||
- host: api.example.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
```
|
||||
|
||||
MCP servers using streamable-http transport are remote -- they need a network
|
||||
policy entry for their host:port but no binary entry (the Python process making
|
||||
the HTTP call is already covered by the standard `python3*` binary entries).
|
||||
|
||||
---
|
||||
|
||||
## Security Model: Which Layer Enforces What
|
||||
|
||||
```
|
||||
OpenShell (infrastructure) Turnstone (application)
|
||||
───────────────────────────── ──────────────────────────────
|
||||
Filesystem access Landlock kernel enforcement (no enforcement)
|
||||
Network egress Netns + seccomp + proxy + OPA SSRF check on web_fetch
|
||||
Credentials Placeholder injection + proxy Output guard redaction
|
||||
Privilege level setuid drop + verification (no enforcement)
|
||||
Tool semantics (no visibility) Heuristic + LLM judge
|
||||
Tool policies (no visibility) fnmatch admin policies
|
||||
Prompt injection (no visibility) Output guard detection
|
||||
Human approval (no visibility) Approval gate + "always"
|
||||
```
|
||||
|
||||
OpenShell constrains what the process can physically reach. Turnstone constrains
|
||||
what the LLM does with its legitimate access. Neither layer is sufficient alone:
|
||||
|
||||
- Without OpenShell: a bash command can `curl` secrets to any endpoint, write to
|
||||
`/etc/crontab`, or read `~/.ssh/id_rsa` -- all gated only by human approval
|
||||
- Without Turnstone: the LLM can `rm -rf` the entire workdir, run destructive
|
||||
commands, or consume prompt injection payloads -- all within the sandbox's
|
||||
allowed scope
|
||||
|
||||
---
|
||||
|
||||
## Hardening Checklist
|
||||
|
||||
For production deployments:
|
||||
|
||||
- [ ] Set `landlock.compatibility: hard_requirement`
|
||||
- [ ] Enable inference routing (removes API keys from sandbox)
|
||||
- [ ] Remove `openai_api`/`anthropic_api` network policies when using inference
|
||||
routing (traffic goes through the router, not direct)
|
||||
- [ ] Review and trim `web_fetch_common` domains to your actual needs
|
||||
- [ ] Remove `package_registries` policy if pip/uv installs are not needed
|
||||
- [ ] Add your OIDC provider endpoint if using SSO
|
||||
- [ ] Consider removing `bash_network_tools` entirely if bash should not have
|
||||
network access
|
||||
@@ -0,0 +1,200 @@
|
||||
# PgBouncer Connection Pooling
|
||||
|
||||
Turnstone cluster deployments share a single PostgreSQL instance across
|
||||
all server nodes and the console. Each process
|
||||
maintains a small connection pool (2 base + 3 overflow = 5 max). At
|
||||
scale this adds up — a 100-node cluster opens up to 500 connections,
|
||||
and a 1000-node cluster up to 5,000.
|
||||
|
||||
PostgreSQL's default `max_connections` is 100, and each real connection
|
||||
allocates ~5–10 MB of backend memory. PgBouncer sits between turnstone
|
||||
and PostgreSQL, multiplexing thousands of lightweight client connections
|
||||
down to a small number of real database connections.
|
||||
|
||||
---
|
||||
|
||||
## Why PgBouncer works well with turnstone
|
||||
|
||||
All turnstone database operations are short-burst queries: acquire a
|
||||
connection, execute 1–3 statements, commit, release. No operation holds
|
||||
a connection for more than a few milliseconds. This makes **transaction
|
||||
pooling mode** ideal — PgBouncer assigns a real connection only for the
|
||||
duration of each transaction, then returns it to the pool.
|
||||
|
||||
| Cluster size | Client connections (max) | PgBouncer server connections needed |
|
||||
|--------------|------------------------|-------------------------------------|
|
||||
| 10 nodes | 50 | 10–20 |
|
||||
| 100 nodes | 500 | 20–40 |
|
||||
| 500 nodes | 2,500 | 30–60 |
|
||||
| 1,000 nodes | 5,000 | 40–80 |
|
||||
|
||||
The server connection count stays low because most client connections
|
||||
are idle at any given moment.
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Add PgBouncer between turnstone services and PostgreSQL:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
pgbouncer:
|
||||
image: bitnami/pgbouncer:latest
|
||||
environment:
|
||||
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:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-p", "6432"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
```
|
||||
|
||||
Then point turnstone services at PgBouncer instead of PostgreSQL
|
||||
directly by changing the `DATABASE_URL` (or `TURNSTONE_DB_URL`):
|
||||
|
||||
```bash
|
||||
# Before (direct)
|
||||
TURNSTONE_DB_URL=postgresql://turnstone:secret@postgres:5432/turnstone
|
||||
|
||||
# After (via PgBouncer)
|
||||
TURNSTONE_DB_URL=postgresql://turnstone:secret@pgbouncer:6432/turnstone
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Helm / Kubernetes
|
||||
|
||||
Add a PgBouncer deployment or use a Helm chart like
|
||||
[bitnami/pgbouncer](https://github.com/bitnami/charts/tree/main/bitnami/pgbouncer).
|
||||
|
||||
In `values.yaml`, point the database at PgBouncer:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
backend: postgresql
|
||||
external:
|
||||
host: pgbouncer
|
||||
port: 6432
|
||||
database: turnstone
|
||||
username: turnstone
|
||||
existingSecret: turnstone-db-secret
|
||||
```
|
||||
|
||||
PgBouncer configuration:
|
||||
|
||||
```yaml
|
||||
pgbouncer:
|
||||
poolMode: transaction
|
||||
defaultPoolSize: 40
|
||||
maxClientConn: 5000
|
||||
maxDbConnections: 80
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration reference
|
||||
|
||||
| PgBouncer setting | Recommended | Notes |
|
||||
|-------------------|-------------|-------|
|
||||
| `pool_mode` | `transaction` | Required — turnstone uses short-burst queries with no session state |
|
||||
| `default_pool_size` | 40 | Real PostgreSQL connections per database. Start here, increase if you see `no more connections allowed` |
|
||||
| `max_client_conn` | 5000 | Upper bound on client connections. Set to `cluster_nodes × 5` |
|
||||
| `max_db_connections` | 80 | Hard cap on real connections to PostgreSQL. Keep below PG `max_connections` minus headroom for admin/monitoring |
|
||||
| `server_idle_timeout` | 300 | Close idle server connections after 5 minutes |
|
||||
| `server_lifetime` | 3600 | Recycle server connections after 1 hour |
|
||||
|
||||
On the PostgreSQL side:
|
||||
|
||||
| PostgreSQL setting | Recommended | Notes |
|
||||
|--------------------|-------------|-------|
|
||||
| `max_connections` | 100 | Default is fine — PgBouncer is the only client. Set higher than `max_db_connections` to leave room for admin connections |
|
||||
| `shared_buffers` | 25% of RAM | Standard PostgreSQL tuning |
|
||||
|
||||
---
|
||||
|
||||
## Turnstone pool settings
|
||||
|
||||
Each turnstone process maintains its own SQLAlchemy connection pool to
|
||||
PgBouncer (which then multiplexes to PostgreSQL):
|
||||
|
||||
| Environment variable | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `TURNSTONE_DB_POOL_SIZE` | 2 | Base pool size per process |
|
||||
| `TURNSTONE_DB_BACKEND` | sqlite | Set to `postgresql` for cluster deployments |
|
||||
| `TURNSTONE_DB_URL` | — | Connection URL (point at PgBouncer, not PostgreSQL directly) |
|
||||
|
||||
The default pool of 2 + 3 overflow = 5 connections per process is
|
||||
intentionally small to support large clusters. You should not need to
|
||||
increase this — turnstone's database operations are all short-burst
|
||||
context-managed queries that hold connections for milliseconds.
|
||||
|
||||
SQLAlchemy `pool_pre_ping` is enabled, so stale connections (e.g. after
|
||||
PgBouncer restarts) are automatically detected and replaced.
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
PgBouncer exposes stats via its admin console (connect to
|
||||
PgBouncer port with user `pgbouncer`):
|
||||
|
||||
```sql
|
||||
-- Active and waiting clients
|
||||
SHOW POOLS;
|
||||
|
||||
-- Per-database stats
|
||||
SHOW STATS;
|
||||
|
||||
-- Current client connections
|
||||
SHOW CLIENTS;
|
||||
```
|
||||
|
||||
Key metrics to watch:
|
||||
|
||||
- **`cl_active`** — clients with a server connection assigned. Should be
|
||||
well below `max_db_connections`.
|
||||
- **`cl_waiting`** — clients waiting for a server connection. Sustained
|
||||
non-zero values mean you need more `default_pool_size`.
|
||||
- **`sv_active`** — active server (PostgreSQL) connections. Should stay
|
||||
below PostgreSQL `max_connections`.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"no more connections allowed (max_client_conn)"** — PgBouncer is
|
||||
rejecting new client connections. Increase `max_client_conn` to match
|
||||
your cluster size × 5.
|
||||
|
||||
**"no more connections allowed (max_db_connections)"** — PgBouncer
|
||||
cannot open more connections to PostgreSQL. Increase
|
||||
`max_db_connections` and ensure PostgreSQL `max_connections` is higher.
|
||||
|
||||
**Connections timing out on startup** — If all nodes start
|
||||
simultaneously, the burst of initial connections (migrations, health
|
||||
checks) can temporarily exceed the pool. PgBouncer queues excess
|
||||
clients by default — this resolves itself within seconds.
|
||||
|
||||
**Prepared statements not supported** — PgBouncer in `transaction` mode
|
||||
does not support prepared statements. Turnstone's SQLAlchemy layer does
|
||||
not use server-side prepared statements by default, so this is not an
|
||||
issue.
|
||||
|
||||
See also: [Docker deployment](docker.md) · [Security](security.md)
|
||||
+28
-4
@@ -69,12 +69,13 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|
||||
|----------|--------|---------|
|
||||
| **Workstreams** | `list_workstreams()` | `ListWorkstreamsResponse` |
|
||||
| | `dashboard()` | `DashboardResponse` |
|
||||
| | `create_workstream(*, name, model, auto_approve)` | `CreateWorkstreamResponse` |
|
||||
| | `create_workstream(*, name, model, auto_approve, skill)` | `CreateWorkstreamResponse` |
|
||||
| | `close_workstream(ws_id)` | `StatusResponse` |
|
||||
| **Chat** | `send(message, ws_id)` | `SendResponse` |
|
||||
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
|
||||
| | `plan_feedback(*, ws_id, feedback)` | `StatusResponse` |
|
||||
| | `command(*, ws_id, command)` | `StatusResponse` |
|
||||
| | `cancel(ws_id, *, force=False)` | `StatusResponse` |
|
||||
| **Streaming** | `stream_events(ws_id)` | `Iterator[ServerEvent]` |
|
||||
| | `stream_global_events()` | `Iterator[ServerEvent]` |
|
||||
| **High-level** | `send_and_wait(message, ws_id, *, timeout, on_event)` | `TurnResult` |
|
||||
@@ -95,13 +96,18 @@ Both `TurnstoneConsole` (sync) and `AsyncTurnstoneConsole` (async) expose:
|
||||
| | `nodes(*, sort, limit, offset)` | `ClusterNodesResponse` |
|
||||
| | `workstreams(*, state, node, search, sort, page, per_page)` | `ClusterWorkstreamsResponse` |
|
||||
| | `node_detail(node_id)` | `NodeDetailResponse` |
|
||||
| | `create_workstream(*, node_id, name, model, initial_message)` | `ConsoleCreateWsResponse` |
|
||||
| | `snapshot()` | `ClusterSnapshotResponse` |
|
||||
| | `create_workstream(*, node_id, name, model, initial_message, skill)` | `ConsoleCreateWsResponse` |
|
||||
| **Schedules** | `list_schedules()` | `ListSchedulesResponse` |
|
||||
| | `create_schedule(*, name, schedule_type, initial_message, ...)` | `ScheduleInfo` |
|
||||
| | `get_schedule(task_id)` | `ScheduleInfo` |
|
||||
| | `update_schedule(task_id, *, name=..., enabled=..., ...)` | `ScheduleInfo` |
|
||||
| | `delete_schedule(task_id)` | `StatusResponse` |
|
||||
| | `list_schedule_runs(task_id, *, limit=50)` | `ListScheduleRunsResponse` |
|
||||
| **MCP Registry** | `search_mcp_registry(q="", *, limit=20, cursor=None)` | `RegistrySearchResponse` |
|
||||
| | `install_from_registry(registry_name, source, *, index=0, name="", variables=None, env=None, headers=None)` | `McpServerDetail` |
|
||||
| **Skill Discovery** | `discover_skills(q="", *, limit=20)` | `SkillDiscoverResponse` |
|
||||
| | `install_skill(source, *, skill_id="", url="")` | `dict` |
|
||||
| **Streaming** | `stream_cluster_events()` | `Iterator[ClusterEvent]` |
|
||||
| **Auth** | `login(username=..., password=...)` / `login(token="ts_xxx")` | `AuthLoginResponse` |
|
||||
| | `logout()` | `StatusResponse` |
|
||||
@@ -121,13 +127,14 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
|
||||
| `reasoning` | `ReasoningEvent` | `text` |
|
||||
| `tool_info` | `ToolInfoEvent` | `items` |
|
||||
| `approve_request` | `ApproveRequestEvent` | `items` |
|
||||
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output` |
|
||||
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output`, `is_error` |
|
||||
| `tool_output_chunk` | `ToolOutputChunkEvent` | `call_id`, `chunk` |
|
||||
| `status` | `StatusEvent` | `prompt_tokens`, `total_tokens`, `pct`, `effort` |
|
||||
| `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` | — |
|
||||
| `cancelled` | `CancelledEvent` | — |
|
||||
|
||||
**Global events** (from `stream_global_events()`):
|
||||
|
||||
@@ -146,6 +153,9 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
|
||||
| `node_lost` | `NodeLostEvent` | `node_id` |
|
||||
| `cluster_state` | `ClusterStateEvent` | `ws_id`, `node_id`, `state`, `tokens` |
|
||||
| `ws_created` | `ClusterWsCreatedEvent` | `ws_id`, `node_id`, `name` |
|
||||
| `ws_closed` | `ClusterWsClosedEvent` | `ws_id` |
|
||||
| `ws_rename` | `ClusterWsRenameEvent` | `ws_id`, `name` |
|
||||
| `snapshot` | `ClusterSnapshotEvent` | `nodes`, `overview`, `timestamp` |
|
||||
|
||||
### TurnResult
|
||||
|
||||
@@ -216,6 +226,20 @@ await client.login({ username: "alice", password: "s3cret" });
|
||||
const overview = await client.overview();
|
||||
console.log(`Nodes: ${overview.nodes}, Workstreams: ${overview.workstreams}`);
|
||||
|
||||
// Search and install from the MCP Registry
|
||||
const results = await client.searchMcpRegistry({ q: "github", limit: 10 });
|
||||
const server = await client.installFromRegistry({
|
||||
registry_name: results.servers[0].name,
|
||||
source: "remote",
|
||||
});
|
||||
|
||||
// Search and install skills from external registries
|
||||
const skills = await client.discoverSkills({ q: "code review" });
|
||||
const skill = await client.installSkill({
|
||||
source: "github",
|
||||
url: "https://github.com/owner/skill-repo",
|
||||
});
|
||||
|
||||
// Stream cluster events
|
||||
for await (const event of client.clusterEvents()) {
|
||||
console.log(event.type, event);
|
||||
|
||||
+169
-20
@@ -54,7 +54,7 @@ Claims:
|
||||
|-------|-------------|
|
||||
| `sub` | User ID |
|
||||
| `scopes` | Comma-separated scope list (`read,write,approve`) |
|
||||
| `src` | Token source (`password`, `api_token`, `config`) |
|
||||
| `src` | Token source (`password`, `api_token`, `config`, `oidc`) |
|
||||
| `iss` | Issuer — always `turnstone` |
|
||||
| `aud` | Audience — `turnstone-server` or `turnstone-console` |
|
||||
| `iat` | Issued-at timestamp |
|
||||
@@ -90,7 +90,36 @@ Scopes are hierarchical — higher scopes imply all lower ones.
|
||||
|
||||
Public paths bypass authentication entirely: `/`, `/health`, `/metrics`,
|
||||
`/static/*`, `/shared/*`, `/docs`, `/openapi.json`, `/api/auth/login`,
|
||||
`/api/auth/logout`, `/api/auth/status`, `/api/auth/setup`.
|
||||
`/api/auth/logout`, `/api/auth/status`, `/api/auth/setup`,
|
||||
`/api/auth/oidc/authorize`, `/api/auth/oidc/callback`.
|
||||
|
||||
### RBAC (Granular Permissions)
|
||||
|
||||
> See also: [Governance documentation](governance.md)
|
||||
|
||||
Scopes provide coarse endpoint-level access control. For finer-grained
|
||||
enforcement, the governance layer adds 15 named permissions checked
|
||||
per-endpoint by `require_permission()`. Permissions are bundled into
|
||||
roles; users are assigned roles via the `user_roles` join table.
|
||||
|
||||
At login, `_load_user_permissions()` aggregates all permissions from
|
||||
the user's assigned roles. `_permissions_to_scopes()` derives legacy
|
||||
scopes for backward compatibility (e.g., any `admin.*` permission
|
||||
implies the `approve` scope). The JWT carries both `scopes` and
|
||||
`permissions` claims.
|
||||
|
||||
Three built-in roles are seeded by migration 008:
|
||||
|
||||
| Role | Permissions |
|
||||
|------|-------------|
|
||||
| admin | All 15 permissions |
|
||||
| operator | read, write, workstreams.create, workstreams.close |
|
||||
| viewer | read |
|
||||
|
||||
Custom roles can be created with any subset of the valid permissions.
|
||||
Role creation and update validate permissions against a static allowlist.
|
||||
Self-assignment is blocked, and assigning a role requires the caller to
|
||||
hold a superset of the target role's permissions.
|
||||
|
||||
---
|
||||
|
||||
@@ -171,6 +200,94 @@ Response:
|
||||
The response also sets an `HttpOnly` session cookie containing the JWT,
|
||||
so the browser is immediately authenticated after setup completes.
|
||||
|
||||
### OIDC SSO (Single Sign-On)
|
||||
|
||||
Turnstone supports OIDC Authorization Code Flow with PKCE for
|
||||
single sign-on with external identity providers (Okta, Azure AD,
|
||||
Google, etc.). SSO is opt-in — enabled when the three required
|
||||
environment variables are set. Users are auto-provisioned on first
|
||||
login.
|
||||
|
||||
#### Configuration
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `TURNSTONE_OIDC_ISSUER` | Yes | OIDC issuer URL (e.g., `https://accounts.google.com`) |
|
||||
| `TURNSTONE_OIDC_CLIENT_ID` | Yes | Client ID from the identity provider |
|
||||
| `TURNSTONE_OIDC_CLIENT_SECRET` | Yes | Client secret (confidential client) |
|
||||
| `TURNSTONE_OIDC_SCOPES` | No | OIDC scopes (default: `openid email profile`) |
|
||||
| `TURNSTONE_OIDC_PROVIDER_NAME` | No | Display name for the SSO button (default: `SSO`) |
|
||||
| `TURNSTONE_OIDC_ROLE_CLAIM` | No | Claim name in the ID token for role mapping (e.g., `groups`) |
|
||||
| `TURNSTONE_OIDC_ROLE_MAP` | No | Comma-separated `claim_value:role_id` pairs (e.g., `admin:builtin-admin,eng:builtin-operator`) |
|
||||
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | Set to `false` to hide password login and force SSO-only |
|
||||
|
||||
OIDC is enabled when all three required variables (`ISSUER`,
|
||||
`CLIENT_ID`, `CLIENT_SECRET`) are set.
|
||||
|
||||
#### Login flow
|
||||
|
||||
1. User clicks "Continue with [Provider]" on the login page
|
||||
2. `GET /v1/api/auth/oidc/authorize` generates state, nonce, and PKCE
|
||||
challenge, stores them in the database, and redirects to the IdP
|
||||
3. User authenticates at the identity provider
|
||||
4. IdP redirects to `/v1/api/auth/oidc/callback` with `code` + `state`
|
||||
5. Server validates state, exchanges the authorization code (with PKCE
|
||||
verifier), and validates the ID token (JWKS signature, issuer,
|
||||
audience, nonce)
|
||||
6. Provisions or matches the user by `(issuer, sub)` — never by
|
||||
username or email
|
||||
7. Issues a JWT (`src: oidc`), sets a session cookie, and redirects to
|
||||
the application
|
||||
|
||||
#### Security measures
|
||||
|
||||
- **PKCE (S256)** — prevents authorization code interception
|
||||
- **State parameter** — one-time use, 5-minute TTL, database-backed
|
||||
(multi-node safe)
|
||||
- **Nonce** — prevents ID token replay
|
||||
- **JWKS validation** — asymmetric algorithm allowlist (RS/ES/PS
|
||||
256-512), HMAC excluded
|
||||
- **Algorithm allowlist enforced** — the signing key is resolved from
|
||||
the JWKS by ``kid``; PyJWK infers the key's algorithm from the JWKS
|
||||
``alg``/``kty`` fields; the token header's ``alg`` must be in the
|
||||
allowlist AND match the key type, preventing algorithm confusion
|
||||
- **Identity matching by (issuer, sub) only** — prevents account
|
||||
takeover via email or username reuse
|
||||
- **`password_enabled=false` enforced server-side** — not just a UI
|
||||
toggle
|
||||
- **Rate limiting** on both authorize and callback endpoints
|
||||
- **OIDC-provisioned users cannot password-login** — the password hash
|
||||
is set to the `!oidc` sentinel, which never matches bcrypt verify
|
||||
|
||||
#### Role mapping
|
||||
|
||||
When `TURNSTONE_OIDC_ROLE_CLAIM` is set (e.g., `groups`), the server
|
||||
reads that claim from the ID token and maps values to Turnstone roles
|
||||
via `TURNSTONE_OIDC_ROLE_MAP`. Roles are synced on every login:
|
||||
matching claim values are added, and stale OIDC-assigned roles are
|
||||
revoked. Roles assigned manually (not by OIDC) are never touched.
|
||||
|
||||
If no role mapping is configured, OIDC users are provisioned with the
|
||||
`builtin-viewer` role by default.
|
||||
|
||||
#### OIDC-only mode
|
||||
|
||||
Setting `TURNSTONE_OIDC_PASSWORD_ENABLED=false` hides the password
|
||||
form on the login page and blocks password-based login at the API
|
||||
level. The setup wizard always works regardless of this setting — the
|
||||
first admin user is created with a password before OIDC is relevant.
|
||||
API tokens and config-file tokens are unaffected by this setting.
|
||||
|
||||
#### Known limitations
|
||||
|
||||
- **No session revocation** — deprovisioned IdP users retain their JWT
|
||||
until the 24-hour expiry
|
||||
- **Single IdP** — configuration supports one issuer (the database
|
||||
schema supports multiple for future expansion)
|
||||
- **Redirect URI** — defaults to request Host header; deployments behind
|
||||
reverse proxies should set `TURNSTONE_OIDC_REDIRECT_BASE` to the
|
||||
externally-reachable origin to pin the redirect URI
|
||||
|
||||
---
|
||||
|
||||
## Token Detection Order
|
||||
@@ -222,10 +339,9 @@ secret. If no secret is configured, an ephemeral key is generated at
|
||||
startup and a warning is logged — JWTs will not survive restarts or work
|
||||
across nodes.
|
||||
|
||||
The bridge and console **require** `TURNSTONE_JWT_SECRET` when no
|
||||
`--auth-token` is provided. They exit with an error if the secret is
|
||||
missing, since ephemeral secrets would silently break inter-service
|
||||
communication.
|
||||
The console **requires** `TURNSTONE_JWT_SECRET` when no `--auth-token`
|
||||
is provided. It exits with an error if the secret is missing, since
|
||||
ephemeral secrets would silently break inter-service communication.
|
||||
|
||||
---
|
||||
|
||||
@@ -340,31 +456,54 @@ without any database.
|
||||
### Proxy auth forwarding
|
||||
|
||||
When the console proxies requests to server nodes (via `/node/{id}/...`
|
||||
routes), it uses a dedicated **service proxy token** with
|
||||
`aud: turnstone-server` and `write` scope. The user's console JWT
|
||||
(which has `aud: turnstone-console`) is **not** forwarded — it would be
|
||||
rejected by the server's audience validation.
|
||||
routes), it mints a **short-lived user-scoped JWT** with
|
||||
`aud: turnstone-server` carrying the real user's `user_id`, `scopes`,
|
||||
and `permissions`. The user's console JWT (which has
|
||||
`aud: turnstone-console`) is **not** forwarded directly — it would be
|
||||
rejected by the server's audience validation. Instead, the console
|
||||
re-signs a new JWT targeted at the server audience.
|
||||
|
||||
The proxy token is managed by a `ServiceTokenManager` that auto-rotates
|
||||
1-hour JWTs, refreshing at 80% of lifetime. If `--auth-token` is
|
||||
provided, that static token is used instead.
|
||||
Each proxied request gets a fresh JWT (5-minute expiry). This ensures:
|
||||
|
||||
- **Audit attribution** — the upstream server records the real user in
|
||||
`ctx_user_id` and audit events, not a generic service identity.
|
||||
- **Scope narrowing** — a read-only console user's proxied request
|
||||
carries only `read` scope, not the full `{read, write, approve}` set.
|
||||
The server enforces this as defense in depth.
|
||||
- **Permission forwarding** — granular RBAC permissions from the
|
||||
console JWT are carried through to the server.
|
||||
|
||||
The JWT `src` claim is set to `"console-proxy"`, allowing servers to
|
||||
distinguish proxied requests from direct logins in audit logs.
|
||||
|
||||
When no user context is available (auth disabled, or internal requests),
|
||||
the proxy falls back to a `ServiceTokenManager` with service identity
|
||||
`console-proxy` and full scopes. If `--auth-token` is provided, that
|
||||
static token is used as a final fallback.
|
||||
|
||||
### Service-to-service authentication
|
||||
|
||||
The bridge and console collector use `ServiceTokenManager` for
|
||||
auto-rotating JWTs when communicating with server nodes:
|
||||
The console collector uses `ServiceTokenManager` for auto-rotating
|
||||
JWTs when communicating with server nodes:
|
||||
|
||||
| Service | Identity | Scope | Audience | Purpose |
|
||||
|---------|----------|-------|----------|---------|
|
||||
| Bridge | `bridge` | `approve` | `turnstone-server` | Tool approval proxy, message relay |
|
||||
| Console collector | `console-collector` | `read` | `turnstone-server` | Node health polling |
|
||||
| Console proxy | `console-proxy` | `write` | `turnstone-server` | Proxied API calls |
|
||||
| Console proxy (fallback) | `console-proxy` | `approve` | `turnstone-server` | Proxied API calls when no user context |
|
||||
| Channel notify | `system` | `write` | `turnstone-channel` | Notification delivery to channel gateway |
|
||||
|
||||
Service tokens use 1-hour expiry with automatic refresh via
|
||||
`ServiceTokenManager`. The bridge injects auth headers per-request via
|
||||
httpx event hooks to ensure rotated tokens are picked up on SSE
|
||||
reconnects.
|
||||
`ServiceTokenManager`.
|
||||
|
||||
### User identity in MQ-dispatched workstreams
|
||||
|
||||
When the console creates a workstream (the normal path), the
|
||||
authenticated user's `user_id` is forwarded in the HTTP payload when
|
||||
calling the server's `POST /v1/api/workstreams/new`. The server
|
||||
accepts a `user_id` from the request body **only when the caller is a
|
||||
trusted service** — identified by `token_source` matching
|
||||
`console-proxy` or `console`. Regular API callers cannot
|
||||
override `user_id`; the server always uses their JWT identity.
|
||||
|
||||
Note that the channel gateway uses a distinct JWT audience
|
||||
(`turnstone-channel`) from the server (`turnstone-server`) and console
|
||||
@@ -455,3 +594,13 @@ and browsers enforce same-origin policy.
|
||||
refresh, eliminating long-lived static tokens for inter-service auth.
|
||||
- **Secret strength validation** — warning logged when JWT secret is
|
||||
shorter than 32 characters.
|
||||
- **OIDC PKCE enforcement** — S256 code challenge on every
|
||||
authorization request prevents code interception in transit.
|
||||
- **OIDC state/nonce in database** — one-time-use, TTL-bounded tokens
|
||||
stored in the database, safe for multi-node deployments.
|
||||
- **OIDC JWKS-only validation** — ID tokens are verified using the
|
||||
provider's published JWKS keys with asymmetric algorithms only;
|
||||
HMAC-based algorithms are rejected to prevent algorithm confusion.
|
||||
- **OIDC identity binding by (issuer, sub)** — user matching uses the
|
||||
immutable subject identifier, not email or username, preventing
|
||||
account takeover via IdP attribute changes.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user