mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 23:42:25 -06:00
Compare commits
181 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eedf700d3b | |||
| 4d667a2cdc | |||
| 581a8c41b1 | |||
| 412df28fe4 | |||
| 29d3953e52 | |||
| a5d3e1b83c | |||
| 2cfe6c23a0 | |||
| 78865c75d6 | |||
| a76d93b6c6 | |||
| 67aaa236e8 | |||
| 7d61f9a37c | |||
| a7be0e1610 | |||
| 7f40141c16 | |||
| 7c16b0dfa8 | |||
| 9826ea15c5 | |||
| cab57f244d | |||
| bd6670d748 | |||
| 334edbd580 | |||
| c17eddbbd8 | |||
| 553d73109b | |||
| b0a040c8fa | |||
| 3bdcf9870e | |||
| 294d6f5766 | |||
| 37ed6bbf5b | |||
| d3f6514e11 | |||
| d056e375ef | |||
| c397668d21 | |||
| 8650370790 | |||
| e42add1b77 | |||
| a917bf2690 | |||
| 471d1a3311 | |||
| 879be89bbd | |||
| 86d981eb73 | |||
| b1e7c82e95 | |||
| aff449116e | |||
| ddf7b3c2f0 | |||
| a6c6b71d66 | |||
| 0d3516d6e0 | |||
| a8dcccafa3 | |||
| e19032f369 | |||
| d3ff5e5ac7 | |||
| a0b3c35d28 | |||
| 6cbd3eb2c1 | |||
| 551fc43c15 | |||
| 6c026710ff | |||
| 54dd557476 | |||
| 87a9af1075 | |||
| a6c4abe82a | |||
| 30c89f46c6 | |||
| aaea4d302d | |||
| b8daeb3be2 | |||
| 97fbfb9f8e | |||
| 4da751c1c6 | |||
| 8068ae105d | |||
| 6e99bb8b0b | |||
| eb59cdefda | |||
| 06d7cf8896 | |||
| 934cb075d6 | |||
| a793d009fd | |||
| 2a05ba5915 | |||
| cba379d994 | |||
| 50e6e64c3d | |||
| 440e93846d | |||
| 0dd31e45ca | |||
| 8c64ea0687 | |||
| bacb72a880 | |||
| c75b66a630 | |||
| 6559976f2b | |||
| 83cfea36b0 | |||
| 12516ffa04 | |||
| c33ad168c7 | |||
| fd1fb7d849 | |||
| 58b2d01b1c | |||
| b8440d70ac | |||
| b2206337fe | |||
| fadb198898 | |||
| b1e78b79fb | |||
| 98d3289852 | |||
| 2025bf8a6f | |||
| 100bb02e3b | |||
| 2b3b229da6 | |||
| 76ecb99374 | |||
| c578051cb8 | |||
| 701c3fc717 | |||
| 92ad5bd439 | |||
| 58c81b2b46 | |||
| a2d4598012 | |||
| 4f83dba1b9 | |||
| 2629f217d2 | |||
| d1162b2eb9 | |||
| 217688547e | |||
| 5dc98f75fb | |||
| 6980ba5aae | |||
| 57912faa52 | |||
| 0625fac87b | |||
| dc3a1b7a64 | |||
| a3140da3a5 | |||
| 8838bd0f8d | |||
| 7f63cd2d33 | |||
| 24f59a6c53 | |||
| 5cbc4bc87c | |||
| eba2f29cd1 | |||
| 66c856eb6e | |||
| 40a560b39c | |||
| bc945852f7 | |||
| ca70e79d43 | |||
| ebcfb56f0e | |||
| 33d29e3316 | |||
| bfda91cd25 | |||
| 6fe9f75c3c | |||
| c093df274d | |||
| 49cdb3d0d3 | |||
| 04c62f90ff | |||
| 1bbaf50214 | |||
| 38e49b6f9c | |||
| 99b0e8db12 | |||
| d22f5a4baf | |||
| da5eae5352 | |||
| adb42c66da | |||
| 7968f1b361 | |||
| 8de53f5cc1 | |||
| 8808a56801 | |||
| c071236927 | |||
| 035ccb0603 | |||
| 2c050b2520 | |||
| 72dd7b50bd | |||
| d7cac3716f | |||
| 2b93598d68 | |||
| 9d4d7a5346 | |||
| 0e3788a54f | |||
| 7f3d6c4da1 | |||
| b30e1394e0 | |||
| af0bf5270c | |||
| d100ac92d9 | |||
| 57df445224 | |||
| f978e7facd | |||
| 0872f5f5ba | |||
| 205e7818f8 | |||
| caf449e048 | |||
| 2bfc0f2c5d | |||
| c67aba0127 | |||
| db0baefeb2 | |||
| 38fc933c1d | |||
| 39b39fb79d | |||
| 0923add7db | |||
| 01cec062d9 | |||
| 830eb8ba00 | |||
| 5bbf2e65eb | |||
| 4d402fea6b | |||
| 46d14ddd86 | |||
| 7c4157f78d | |||
| 3856d80709 | |||
| 8142d2f1ad | |||
| c234d66ebf | |||
| b180770eff | |||
| 9d2e11f2be | |||
| 57080f4615 | |||
| 45f27fb2a7 | |||
| ebc8e75285 | |||
| 485af92f7f | |||
| 664d44c109 | |||
| 3cf9485169 | |||
| d43b9d1647 | |||
| ea8d9d1798 | |||
| d9aa50dca9 | |||
| 6f89d0cc13 | |||
| 62d2a0fe6a | |||
| 5df37f83a7 | |||
| 651c4d98cd | |||
| e901e859c7 | |||
| 200dcfeac5 | |||
| 8c414feba2 | |||
| d7cea053b6 | |||
| c45e98462b | |||
| e17cbe35a5 | |||
| fd47c23177 | |||
| 9fe988b1be | |||
| 3ce66960bc | |||
| 8a852a12e3 | |||
| 9a518657a3 | |||
| c424176c73 |
+36
-16
@@ -1,29 +1,49 @@
|
||||
# =============================================================================
|
||||
# Turnstone Environment Variables
|
||||
# Copy to .env and adjust values for your deployment
|
||||
# Copy to .env and adjust values for your deployment.
|
||||
#
|
||||
# Usage:
|
||||
# Single node: docker compose --profile production up
|
||||
# 10-node cluster: docker compose --profile cluster up
|
||||
# =============================================================================
|
||||
|
||||
# -- LLM Backend --------------------------------------------------------------
|
||||
LLM_BASE_URL=http://host.docker.internal:8000/v1
|
||||
OPENAI_API_KEY=sk-...
|
||||
# ANTHROPIC_API_KEY=sk-ant-... # Set instead for Anthropic provider
|
||||
# TAVILY_API_KEY=tvly-... # For web search fallback (local models only)
|
||||
OPENAI_API_KEY=dummy
|
||||
# ANTHROPIC_API_KEY=sk-ant-...# Set instead of OPENAI_API_KEY for Anthropic
|
||||
# TAVILY_API_KEY=tvly-... # Web search fallback (local models only)
|
||||
# MODEL=# Override default model alias
|
||||
|
||||
# -- Database (production profile) --------------------------------------------
|
||||
# DB_BACKEND=postgresql
|
||||
# -- Authentication (required) ------------------------------------------------
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
TURNSTONE_JWT_SECRET=changeme-to-32-bytes-of-hex
|
||||
|
||||
# -- Database ------------------------------------------------------------------
|
||||
# Single-node default is SQLite (zero config). Set these for PostgreSQL:
|
||||
# TURNSTONE_DB_BACKEND=postgresql
|
||||
# POSTGRES_USER=turnstone
|
||||
# POSTGRES_PASSWORD=changeme
|
||||
# DATABASE_URL=postgresql+psycopg://turnstone:changeme@postgres:5432/turnstone
|
||||
|
||||
# -- Redis ---------------------------------------------------------------------
|
||||
# REDIS_PASSWORD=
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# -- Authentication ------------------------------------------------------------
|
||||
# TURNSTONE_AUTH_ENABLED=true
|
||||
# TURNSTONE_AUTH_TOKEN=your-secret-token
|
||||
# TURNSTONE_JWT_SECRET=python -c "import secrets; print(secrets.token_hex(32))"
|
||||
# TURNSTONE_DB_URL=postgresql+psycopg://turnstone:changeme@postgres:5432/turnstone
|
||||
|
||||
# -- Ports ---------------------------------------------------------------------
|
||||
# SERVER_PORT=8080
|
||||
# CONSOLE_PORT=8090
|
||||
|
||||
# -- Workspace -----------------------------------------------------------------
|
||||
# Bind-mount a host directory into the container at /workspace.
|
||||
# The model can read/write files here. Default: empty Docker volume.
|
||||
# WORKSPACE_MOUNT=/path/to/your/project
|
||||
|
||||
# -- Agent behavior ------------------------------------------------------------
|
||||
# SKIP_PERMISSIONS=true # Auto-approve all tool calls (dev only)
|
||||
# MCP_CONFIG=/workspace/mcp.json# MCP server configuration file
|
||||
|
||||
# -- Discord channel gateway ---------------------------------------------------
|
||||
# TURNSTONE_DISCORD_TOKEN=
|
||||
# TURNSTONE_DISCORD_GUILD=0
|
||||
|
||||
# -- Cluster (profile: cluster) -----------------------------------------------
|
||||
# These are set per-node in compose.yaml; only override for custom topologies.
|
||||
# TURNSTONE_NODE_ID=node-1
|
||||
# TURNSTONE_ADVERTISE_URL=http://server-1:8080
|
||||
|
||||
|
||||
+10
-3
@@ -41,6 +41,14 @@
|
||||
"matchStrings": ["mermaid-(?<currentValue>[\\d.]+)/"],
|
||||
"depNameTemplate": "mermaid",
|
||||
"datasourceTemplate": "npm"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track vendored hls.js version",
|
||||
"managerFilePatterns": ["/pyproject\\.toml$/"],
|
||||
"matchStrings": ["hls-(?<currentValue>[\\d.]+)/"],
|
||||
"depNameTemplate": "hls.js",
|
||||
"datasourceTemplate": "npm"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
@@ -83,7 +91,7 @@
|
||||
{
|
||||
"description": "Infrastructure dependencies",
|
||||
"groupName": "Infrastructure",
|
||||
"matchPackageNames": ["structlog", "redis", "croniter", "discord.py"],
|
||||
"matchPackageNames": ["structlog", "croniter", "discord.py"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": ["patch"]
|
||||
@@ -91,7 +99,7 @@
|
||||
{
|
||||
"description": "Vendored JS — CI workflow downloads files automatically",
|
||||
"groupName": "Vendored JS",
|
||||
"matchPackageNames": ["katex", "highlight.js", "mermaid"],
|
||||
"matchPackageNames": ["katex", "highlight.js", "mermaid", "hls.js"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": false
|
||||
},
|
||||
@@ -101,7 +109,6 @@
|
||||
"matchPackageNames": [
|
||||
"ruff",
|
||||
"mypy",
|
||||
"types-redis",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pre-commit"
|
||||
|
||||
@@ -2,9 +2,13 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, "stable/*"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, "stable/*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -25,8 +29,8 @@ jobs:
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: pip install mypy types-redis
|
||||
- run: pip install -e ".[mq]"
|
||||
- run: pip install mypy
|
||||
- run: pip install -e ".[all]"
|
||||
- run: mypy turnstone/
|
||||
|
||||
test:
|
||||
@@ -39,9 +43,9 @@ jobs:
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: pip install -e ".[test,mq]"
|
||||
- run: pip install -e ".[test]"
|
||||
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}
|
||||
@@ -68,11 +72,58 @@ jobs:
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: pip install -e ".[test,mq,postgres]"
|
||||
- run: pip install -e ".[test,postgres]"
|
||||
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
|
||||
env:
|
||||
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
|
||||
|
||||
wheel-completeness:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: pip install build
|
||||
- run: python -m build --wheel
|
||||
- name: Check all data files are in wheel
|
||||
run: |
|
||||
SOURCE=$(find turnstone -type f \
|
||||
! -name '*.py' ! -name '*.pyc' ! -path '*__pycache__*' \
|
||||
| sort)
|
||||
WHEEL=$(python -m zipfile -l dist/*.whl \
|
||||
| awk '{print $1}' \
|
||||
| grep -v '\.py$' | grep -v '\.dist-info' | grep -v '\.pyc' | grep -v '^File$' \
|
||||
| sort)
|
||||
|
||||
# Files intentionally excluded from the wheel (one per line)
|
||||
ALLOW="
|
||||
turnstone/core/storage/migrations/script.py.mako
|
||||
"
|
||||
|
||||
MISSING=$(comm -23 <(echo "$SOURCE") <(echo "$WHEEL") \
|
||||
| grep -vFxf <(echo "$ALLOW" | sed '/^[[:space:]]*$/d; s/^[[:space:]]*//' ) || true)
|
||||
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo "::error::Data files in source tree but missing from wheel:"
|
||||
echo "$MISSING"
|
||||
echo ""
|
||||
echo "Add them to [tool.hatch.build.targets.wheel] in pyproject.toml"
|
||||
echo "or to the ALLOW list in this job if intentionally excluded."
|
||||
exit 1
|
||||
fi
|
||||
echo "All source data files present in wheel"
|
||||
- name: Smoke-test entry points from installed wheel
|
||||
run: |
|
||||
python -m venv /tmp/smoke
|
||||
/tmp/smoke/bin/pip install dist/*.whl
|
||||
/tmp/smoke/bin/turnstone --help
|
||||
/tmp/smoke/bin/turnstone-server --help
|
||||
/tmp/smoke/bin/turnstone-console --help
|
||||
/tmp/smoke/bin/turnstone-admin --help
|
||||
/tmp/smoke/bin/turnstone-channel --help
|
||||
/tmp/smoke/bin/turnstone-bootstrap --help
|
||||
|
||||
lock-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -104,7 +155,7 @@ jobs:
|
||||
working-directory: sdk/typescript
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- run: npm ci
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release tag
|
||||
id: tag
|
||||
run: |
|
||||
TAG=$(git tag --points-at HEAD | grep '^v' | head -1)
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "No v* tag at HEAD — skipping publish"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute Docker tags
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
id: tags
|
||||
env:
|
||||
REF: ${{ steps.tag.outputs.tag }}
|
||||
run: |
|
||||
VERSION="${REF#v}"
|
||||
FULL="${REGISTRY}/${IMAGE_NAME}"
|
||||
FULL="${FULL,,}"
|
||||
|
||||
if echo "$VERSION" | grep -qE '(a|b|rc)[0-9]+$'; then
|
||||
TAGS="${FULL}:${VERSION},${FULL}:experimental"
|
||||
else
|
||||
MINOR="${VERSION%.*}"
|
||||
TAGS="${FULL}:${VERSION},${FULL}:${MINOR},${FULL}:stable,${FULL}:latest"
|
||||
fi
|
||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
|
||||
- name: Build and push
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -2,7 +2,7 @@ name: Docker Security Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, "stable/*"]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly Monday 06:00 UTC
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
name: Publish to PyPI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
concurrency:
|
||||
group: publish-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -10,20 +15,43 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release tag
|
||||
id: tag
|
||||
run: |
|
||||
TAG=$(git tag --points-at HEAD | grep '^v' | head -1)
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "No v* tag at HEAD — skipping publish"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: pip install build
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
- run: python -m build
|
||||
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref, '-') }}
|
||||
prerelease: ${{ contains(steps.tag.outputs.tag, 'a') || contains(steps.tag.outputs.tag, 'b') || contains(steps.tag.outputs.tag, 'rc') }}
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
id: detect
|
||||
run: |
|
||||
updates=()
|
||||
for lib in katex hljs mermaid; do
|
||||
for lib in katex hljs mermaid hls; do
|
||||
version=$(grep -oE "${lib}-[0-9.]+" pyproject.toml | head -1 | sed "s/${lib}-//")
|
||||
[[ -z "$version" ]] && continue
|
||||
[[ -d "turnstone/shared_static/${lib}-${version}" ]] && continue
|
||||
|
||||
@@ -17,3 +17,37 @@ CVE-2026-27135
|
||||
# Affects libsystemd0, libudev1
|
||||
# https://avd.aquasec.com/nvd/cve-2026-29111
|
||||
CVE-2026-29111
|
||||
|
||||
# glibc iconv() DoS — fix_deferred, no patched libc in Debian 13 yet
|
||||
# Affects libc-bin, libc6
|
||||
# https://avd.aquasec.com/nvd/cve-2026-4046
|
||||
CVE-2026-4046
|
||||
|
||||
# minimatch ReDoS — transitive npm dep (MCP server), no direct exposure
|
||||
# https://avd.aquasec.com/nvd/cve-2026-27903
|
||||
CVE-2026-27903
|
||||
# https://avd.aquasec.com/nvd/cve-2026-27904
|
||||
CVE-2026-27904
|
||||
|
||||
# picomatch ReDoS — transitive npm dep, no direct exposure
|
||||
# https://avd.aquasec.com/nvd/cve-2026-33671
|
||||
CVE-2026-33671
|
||||
|
||||
# node-tar path traversal — transitive npm dep, not used to extract untrusted archives
|
||||
# https://avd.aquasec.com/nvd/cve-2026-29786
|
||||
CVE-2026-29786
|
||||
# https://avd.aquasec.com/nvd/cve-2026-31802
|
||||
CVE-2026-31802
|
||||
|
||||
# jq out-of-bounds read on non-NUL-terminated buffers — no fix in Debian 13 repos yet.
|
||||
# Affects jq + libjq1 (1.7.1-6+deb13u1). jq is invoked only on trusted
|
||||
# CLI/admin paths against process-controlled JSON input, never on untrusted
|
||||
# network bytes, so the NUL-terminated invariant holds in our usage.
|
||||
# https://avd.aquasec.com/nvd/cve-2026-39979
|
||||
CVE-2026-39979
|
||||
|
||||
# jq DoS via crafted JSON object causing hash collisions — no fix in Debian 13 repos yet.
|
||||
# Affects jq + libjq1 (1.7.1-6+deb13u1). Same trust boundary as above:
|
||||
# jq is not exposed to attacker-controlled JSON in turnstone.
|
||||
# https://avd.aquasec.com/nvd/cve-2026-40164
|
||||
CVE-2026-40164
|
||||
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to turnstone are documented here.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) for
|
||||
version numbers (`X.Y.Z`, with `X.Y.ZaN` / `bN` / `rcN` for pre-releases).
|
||||
|
||||
Three release tracks are maintained:
|
||||
|
||||
- **`stable/1.0`** — patch-only (`v1.0.x`)
|
||||
- **`stable/1.3`** — patch-only (`v1.3.x`)
|
||||
- **`stable/1.4`** — patch-only (`v1.4.x`)
|
||||
- **`main`** — experimental (`v1.5.0aN`)
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.4.0]
|
||||
|
||||
User-visible additions: a full attachment system (images + text documents,
|
||||
including pre-creation uploads), a unified dashboard composer, a Slack
|
||||
channel adapter, per-call plan/task model selection with an admin UI, and
|
||||
provider capability passthrough.
|
||||
|
||||
This release introduces two forward-only schema migrations
|
||||
(`037_workstream_attachments`, `038_workstream_attachments_reserved_at`)
|
||||
that the server applies automatically on first startup against an
|
||||
existing 1.3.x database. Both are additive; no data loss. See
|
||||
**Database migrations** below for details.
|
||||
|
||||
### Added
|
||||
|
||||
- **Workstream attachments** — images (png/jpeg/gif/webp, 4 MiB cap) and
|
||||
text documents (any `text/*` MIME, allowlisted application MIMEs, or
|
||||
known text extensions; 512 KiB cap; UTF-8 enforced). Magic-byte image
|
||||
sniffing on upload; per-(ws, user) pending cap of 10. Three-state
|
||||
lifecycle (`pending → reserved → consumed`) with reservation tokens
|
||||
threaded through `/v1/api/send` so queued multimodal turns can't lose
|
||||
files to overlapping sends. Provider-side translation: Anthropic
|
||||
emits native document blocks; OpenAI Chat Completions inlines them as
|
||||
escaped `<document>` text blocks; Responses API emits `input_text`
|
||||
with the same wrapper. (#356)
|
||||
- **Attachments at workstream-creation time** —
|
||||
`POST /v1/api/workstreams/new` accepts `multipart/form-data` (one
|
||||
`meta` JSON field plus 0..N `file` parts). Files are validated and
|
||||
reserved onto the first turn before the dispatch worker fires; failure
|
||||
rolls back the fresh workstream so no orphan rows leak. Web UI
|
||||
(new-workstream modal + dashboard composer), Python SDK, and
|
||||
TypeScript SDK all gained attachment support. Cluster routing
|
||||
(`/v1/api/route/workstreams/{ws_id}/attachments`) extended to forward
|
||||
multipart bodies + preserve upstream headers (CSP, Content-Disposition).
|
||||
SDKs auto-generate `ws_id` client-side so cluster-routed callers can
|
||||
bind the body to the owning node before it lands. (#362)
|
||||
- **Slack channel adapter** (Socket Mode) — mirrors the Discord adapter:
|
||||
per-user channel sessions via configurable slash command, DM routing
|
||||
without slash command, SSE event consumption, tool approval buttons
|
||||
with per-user owner enforcement, plan-review approve / request-changes
|
||||
modal, notification reply routing back into the workstream, and
|
||||
session recovery after restart via persisted recoverable route keys
|
||||
(the bot re-subscribes to existing Slack-routed workstreams when it
|
||||
comes back). Install with `pip install 'turnstone[slack]'`. (#355)
|
||||
- **Console admin UX support for Slack** — channel-link modal offers
|
||||
Slack alongside Discord; skill notify-on-complete forms expose a
|
||||
per-row channel-type dropdown (and no longer hardcode `discord`);
|
||||
per-platform `.scope-discord` / `.scope-slack` badge classes with
|
||||
theme-aware tokens (`--discord` / `--slack`) so light theme passes
|
||||
WCAG AA. (#365)
|
||||
- **Per-call plan/task model selection** — `plan_model` and `task_model`
|
||||
are now distinct from the conversation model and from each other,
|
||||
with configurable reasoning effort per agent. Three layers:
|
||||
- **Backend split** (`#54dd557`) — `ModelRegistry` gains `plan_model`,
|
||||
`task_model`, `plan_effort`, `task_effort`; per-kind overrides win
|
||||
over the legacy `agent_model`, which still works as the single-knob
|
||||
fallback. `resolve_agent_alias(kind)` and `resolve_agent_effort(kind)`
|
||||
centralise resolution. Loader validates effort against
|
||||
`{none, minimal, low, medium, high, xhigh, max}` with warn+drop on
|
||||
typos.
|
||||
- **Runtime configurability** (`#360`) — `ConfigStore` admin tab in
|
||||
the console UI lets operators switch alias and reasoning effort per
|
||||
agent **without restarting**. `INHERIT_EMPTY_LABEL_KEYS` shows
|
||||
`(inherit)` for empty effort selections — distinct from the literal
|
||||
`none` choice which actually disables reasoning. Routing overrides
|
||||
apply on `/v1/api/_internal/config-reload` (admin saves), and
|
||||
`model-reload` short-circuits when nothing changed so no in-flight
|
||||
clients churn.
|
||||
- **Per-call override** (`#361`) — the calling LLM can pass
|
||||
`model="<alias>"` to `plan_agent` or `task_agent` to override the
|
||||
operator-configured per-kind model for that one invocation. Tool
|
||||
descriptions list the live registered aliases (refreshed when the
|
||||
operator hits "sync to nodes"), so the LLM always sees current
|
||||
options. Bad aliases return a corrective error dict listing the
|
||||
available choices. No whitelist — cost control is intentionally
|
||||
ceded to the model. Plan-retry path reuses the alias so coaching
|
||||
reflects real model behaviour. (#360, #361)
|
||||
- **Provider capability passthrough** — resolved per-model capabilities
|
||||
(vision, reasoning, native web search, thinking_mode, token_param,
|
||||
etc.) flow through to provider clients via a new `capabilities`
|
||||
parameter on `create_streaming` / `create_completion`, so feature
|
||||
gating no longer relies on string matching and admin-UI / config.toml
|
||||
overrides actually reach the provider. Defensive shallow-copy in
|
||||
`_finalize_extra_body` so callers reusing the same dict across models
|
||||
are safe; deep-merge of `chat_template_kwargs` so operators can
|
||||
extend instead of silently overwriting. (#352)
|
||||
- **Server compatibility layer for local model servers** — vLLM and
|
||||
llama.cpp profiles suggest the right thinking mode and per-server
|
||||
workarounds (`skip_special_tokens` for vLLM, `reasoning_format` for
|
||||
llama.cpp) during model detection. Admin UI gains structured fields
|
||||
for server type, thinking mode, and extra body params, hidden for
|
||||
non-local providers (openai/anthropic/google). New `thinking_param`
|
||||
text field surfaces the alias name (default `enable_thinking`;
|
||||
Granite/DeepSeek use `thinking`). Verified end-to-end against real
|
||||
vLLM (Gemma 4 31B) and llama.cpp (Gemma 4 E4B) servers. (#352)
|
||||
- **Claude Opus 4.7 support** — `claude-opus-4-7` capability entry
|
||||
(1M ctx, 128K output, adaptive thinking, `supports_temperature=False`,
|
||||
`thinking_display=summarized`). New `ModelCapabilities.thinking_display`
|
||||
field — Opus 4.7 omits thinking by default but always sends summarized
|
||||
blocks back through the provider boundary. Adds `xhigh` effort level
|
||||
to the global mapping and to Opus 4.7's `effort_levels`; admin-console
|
||||
skill-template dropdowns gained `xhigh` and `max` options. Reasoning
|
||||
effort label capitalization aligned across all console dropdowns.
|
||||
(#357 — also in 1.3.1)
|
||||
- **Dashboard composer refactor** — unified single-flow create from the
|
||||
per-node dashboard. Multi-line textarea + collapsible Options panel
|
||||
(model / judge / skill) + paperclip + drag-drop / paste-image + chip
|
||||
strip. Submit-button label dynamically toggles between `Create`
|
||||
(empty) and `Send` (text or attachments staged); Enter and click both
|
||||
go through the same `dashboardSubmit()`. Replaces the inconsistent
|
||||
prior split where Enter created+sent raw and the button opened a
|
||||
separate modal. Options panel state persists in `localStorage`;
|
||||
active non-default selections render as an inline summary chip beside
|
||||
the Options button; drag-over shows an explicit "Drop to attach"
|
||||
overlay. The tab-bar `+` new-workstream modal also gained a paperclip
|
||||
+ chip strip + first-message field so the same flow is reachable from
|
||||
both entry points. (#362, #366)
|
||||
- **Workstream attachments — orphan reservation sweep** — periodic
|
||||
background sweep clears `reserved_for_msg_id` on rows whose
|
||||
`reserved_at` exceeds a 1-hour threshold, self-healing reservations
|
||||
leaked by process crashes between reserve and consume. Backed by a
|
||||
partial index on `(reserved_at) WHERE reserved_at IS NOT NULL` so the
|
||||
scan stays cheap as the consumed-history grows. Threshold tracks
|
||||
reservation age, not upload age, so a long-pending fresh send can't
|
||||
be racially unreserved. (#363)
|
||||
- **`SendResponse` extended** — `attached_ids`,
|
||||
`dropped_attachment_ids`, `priority`, `msg_id` fields exposed in
|
||||
Pydantic + TypeScript SDKs so attachment-aware clients can detect
|
||||
partial reservations and dequeue queued messages. (#365)
|
||||
|
||||
### Changed
|
||||
|
||||
- **`plan_model` and `task_model` now split** from the conversation
|
||||
model and from each other — operators who rely on a single model for
|
||||
all three should set both `plan_model` and `task_model` explicitly in
|
||||
their config; otherwise both default to the conversation model so
|
||||
behaviour is unchanged. (#54dd557)
|
||||
- **Channel notify-on-complete `channel_type` is no longer hardcoded
|
||||
in the admin UI** — operators creating notify targets through the
|
||||
skill admin form previously got `channel_type: "discord"` regardless
|
||||
of what they wanted. Existing skill JSON values are unaffected; only
|
||||
newly created targets through the form differ. (#365)
|
||||
- **Slack adapter approval previews** — capped at 600 chars per item
|
||||
with a 2700-char total budget so multi-tool approval batches never
|
||||
exceed Slack's 3000-char `section.text` limit. Truncated batches
|
||||
show a `…and N more (preview truncated)` suffix. (#365)
|
||||
- **PostgreSQL deployment image** swapped from `bitnami/pgbouncer` to
|
||||
`edoburu/pgbouncer` to track upstream releases and reduce image size.
|
||||
Environment variables remapped to the edoburu naming, ports updated
|
||||
to match documented expectations, and the Kubernetes Helm Chart link
|
||||
in the deployment docs now points at the same container. Review
|
||||
your helm values if you depend on `bitnami`-specific environment
|
||||
variable conventions. (#353)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`plan_resolved` SSE broadcast** — when one client resolved a plan
|
||||
approval, other clients viewing the same workstream now have the
|
||||
approval card dismissed in sync. (#87a9af1)
|
||||
- **Slack notification reply routing** — one notification reply
|
||||
previously pinned every later assistant response for that workstream
|
||||
to the notification thread until the bot restarted. Reply-route
|
||||
override now clears on `StreamEndEvent`. (#365)
|
||||
- **Slack plan-review mrkdwn fence** — plan content containing triple
|
||||
backticks (very common — plans often quote code) no longer breaks the
|
||||
surrounding fence and lets later content render as live markup. The
|
||||
shared `_sanitize_slack_preview` helper splices a zero-width space
|
||||
inside any ``` ``` `` sequence while keeping single backticks
|
||||
readable. (#365)
|
||||
- **Slack-routed workstreams now load the chat-specific system prompt**
|
||||
via `client_type="chat"`, matching Discord. (#365)
|
||||
- **`/v1/api/workstreams/new` no longer emits a phantom
|
||||
`ws_created`/`ws_closed` SSE pair** when attachment validation
|
||||
rejects a multipart create. Validation runs before the broadcast so
|
||||
failed creates are silent on dashboards. (#362)
|
||||
- **Multipart Content-Type boundary preservation** in console routing
|
||||
proxy — `boundary=` parameter is case-sensitive and was being
|
||||
lowercased before forwarding to the upstream node, breaking parsing
|
||||
for clients that used mixed-case boundaries (most browsers). (#362)
|
||||
- **Local-theme contrast for new badge colors** — `.scope-discord` and
|
||||
`.scope-slack` first shipped with raw hex that failed WCAG AA on
|
||||
light theme (1.8:1 / 2.4:1). Theme-aware `--discord` / `--slack`
|
||||
tokens with proper light variants now pass. (#365)
|
||||
- **Cross-user attachment fetch hardening** — `get_attachment_content`
|
||||
now scopes the row by `user_id` in addition to `ws_id`, so an
|
||||
unowned workstream can't be a vector for cross-user blob fetches via
|
||||
attachment-id guessing. (#356)
|
||||
- **Attachment-list DoS guard** — `/v1/api/send` rejects
|
||||
`attachment_ids` lists longer than the per-(ws, user) pending cap
|
||||
with a 400, preventing hostile clients from blowing up the storage
|
||||
`IN (...)` clause. (#356)
|
||||
- **Bounded LRU for upload locks** — the per-(ws, user) attachment
|
||||
upload-lock map now evicts the oldest unlocked entries past a soft
|
||||
cap, so the in-process map can't grow unbounded on long-running
|
||||
nodes. (#356)
|
||||
- **3.12 CI deadlock on attachment uploads** — the upload-lock was
|
||||
initially an `asyncio.Lock`, but Starlette's `TestClient` runs each
|
||||
request on a fresh anyio task / event loop, so the cached lock's
|
||||
`_waiters` bound to the first loop and a later request would block
|
||||
on a Future from a closed loop (silent deadlock). Switched to
|
||||
`threading.Lock` — loop-agnostic, and the critical section is one
|
||||
COUNT + one INSERT. Same root cause is reproducible against any
|
||||
Starlette TestClient harness on Python ≥ 3.10; 3.12 surfaces it
|
||||
more often. Production users on a single event loop weren't
|
||||
affected, but the test environment was. (#356)
|
||||
|
||||
### Security
|
||||
|
||||
- **Slack approval per-user authentication** — only the session owner
|
||||
can click Approve/Deny on a Slack tool-approval card. Without this,
|
||||
any channel member with view access could approve dangerous tool
|
||||
calls initiated by someone else. (#355)
|
||||
- **Attachment ownership masking** — cross-user/cross-workstream
|
||||
attachment ID lookups return 404 (not 403) so non-owners can't
|
||||
enumerate workstream existence by response code. (#356)
|
||||
- Bumped Debian base image; remaining unfixable `jq` CVEs are
|
||||
documented and exception-listed. (#aaea4d3)
|
||||
|
||||
### Database migrations
|
||||
|
||||
- **`037_workstream_attachments`** — new `workstream_attachments` table
|
||||
with the lifecycle columns described above. Indexes for ws_id,
|
||||
pending lookups, message linkage, and reservation scoping.
|
||||
- **`038_workstream_attachments_reserved_at`** — adds `reserved_at`
|
||||
column for the orphan-sweep staleness signal, plus a partial index
|
||||
on `reserved_at IS NOT NULL` so the periodic scan is cheap.
|
||||
|
||||
Both migrations are additive and idempotent, and the server applies
|
||||
them automatically on first startup against an existing 1.3.x database.
|
||||
No manual `alembic upgrade` step is required — though running it
|
||||
manually beforehand (e.g. as part of a phased deploy) remains safe.
|
||||
|
||||
### SDK
|
||||
|
||||
Python + TypeScript clients gained:
|
||||
|
||||
- `AttachmentUpload` type
|
||||
- `upload_attachment(ws_id, filename, data, mime_type=None)`
|
||||
- `list_attachments(ws_id)`
|
||||
- `get_attachment_content(ws_id, attachment_id) → bytes / Blob`
|
||||
- `delete_attachment(ws_id, attachment_id)`
|
||||
- `send(message, ws_id, attachment_ids=...)` (extended)
|
||||
- `create_workstream(..., attachments=[...])` — multipart variant with
|
||||
client-side `ws_id` generation for cluster-routed callers
|
||||
- Console SDK: `route_create_workstream(attachments=...)`,
|
||||
`route_upload_attachment`, `route_list_attachments`,
|
||||
`route_get_attachment_content`, `route_delete_attachment`
|
||||
- Refusal of `attachments + target_node` combination at the SDK
|
||||
boundary (the multipart routing layer doesn't honor `target_node`,
|
||||
so silently picking the wrong node is now an explicit error)
|
||||
- `PlanResolvedEvent` SSE event with type guard, dispatched when one
|
||||
client (e.g. mobile) resolves a plan so other connected clients can
|
||||
dismiss their plan-approval modal in sync. Available in both the
|
||||
Python and TypeScript SDKs. (#87a9af1)
|
||||
|
||||
### Operational
|
||||
|
||||
- **CI vendor-asset auto-download covers `hls.js`** — the
|
||||
`vendor-js.yml` workflow previously only iterated katex/hljs/mermaid,
|
||||
so Renovate bumps for `hls.js` failed the wheel-completeness check
|
||||
and required manual file downloads. Detection loop now includes
|
||||
`hls`, so future Renovate bumps are merge-ready without intervention.
|
||||
(#354)
|
||||
|
||||
### Contributors
|
||||
|
||||
Thanks to the people who made this release happen — especially the
|
||||
external contributors who picked up substantial pieces of work:
|
||||
|
||||
- **[@daoxley](https://github.com/daoxley)** — designed and shipped
|
||||
the Slack channel adapter (Socket Mode bot, per-user sessions,
|
||||
approvals, plan-review, notification routing). Major new feature
|
||||
surface in #355.
|
||||
- **[@pizzaandcheese](https://github.com/pizzaandcheese)** — replaced
|
||||
the deprecated bitnami pgbouncer image with the edoburu image,
|
||||
remapped environment variables, ports, and helm chart references.
|
||||
Operationally important for anyone running our reference Postgres
|
||||
deployment (#353).
|
||||
- Renovate kept dependencies and the JS vendor tree current via
|
||||
several automated bumps.
|
||||
|
||||
If you're interested in contributing, channel-attachment ingest from
|
||||
Discord + Slack is the headline 1.4.1 feature and a solid place to
|
||||
start — see the open issues on GitHub or open one to scope a piece.
|
||||
|
||||
## [1.3.1]
|
||||
|
||||
### Added
|
||||
|
||||
- Backport: Claude Opus 4.7 support (provider capabilities, tokenizer,
|
||||
adaptive thinking). (#357)
|
||||
+9
-1
@@ -26,7 +26,15 @@ transferring ownership.
|
||||
```
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[test]"
|
||||
pip install -e ".[test,dev]"
|
||||
```
|
||||
|
||||
The `dev` extra installs `ruff` and `mypy`. Before pushing, run:
|
||||
|
||||
```
|
||||
ruff check turnstone tests
|
||||
mypy turnstone
|
||||
pytest
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
+15
-6
@@ -8,7 +8,7 @@ FROM python:3.14-slim
|
||||
LABEL org.opencontainers.image.title="turnstone" \
|
||||
org.opencontainers.image.description="Multi-node AI orchestration platform"
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.2 /uv /usr/local/bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.7 /uv /usr/local/bin/uv
|
||||
|
||||
# Remove the slim image's man page exclusion so man-db has actual content
|
||||
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
|
||||
@@ -18,23 +18,29 @@ RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-reco
|
||||
libpq5 git curl jq man-db manpages procps file \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github)
|
||||
COPY --from=node:24-slim /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node:24-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
# Non-root user
|
||||
RUN useradd --create-home --shell /bin/bash turnstone
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Compile bytecode for faster startup
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
|
||||
# Install dependencies first (cached layer — only re-runs when deps change)
|
||||
COPY pyproject.toml uv.lock README.md LICENSE ./
|
||||
RUN uv sync --frozen --no-install-project --no-dev \
|
||||
--extra all
|
||||
--no-compile --extra all
|
||||
|
||||
# Install the project itself
|
||||
COPY turnstone/ turnstone/
|
||||
RUN uv sync --frozen --no-dev \
|
||||
--extra all
|
||||
--no-compile --extra all
|
||||
|
||||
# Compile bytecode in a separate step (avoids fd exhaustion during install)
|
||||
RUN python -m compileall -q .venv turnstone/
|
||||
|
||||
# Add venv to PATH so entry points are found
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
@@ -49,6 +55,9 @@ COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
WORKDIR /data
|
||||
RUN chown turnstone:turnstone /data
|
||||
|
||||
# Workspace mount point — bind-mount a host directory here
|
||||
RUN mkdir -p /workspace && chown turnstone:turnstone /workspace
|
||||
|
||||
USER turnstone
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
|
||||
+4
-4
@@ -55,7 +55,7 @@ The wizard supports two deployment modes:
|
||||
```
|
||||
$ turnstone-bootstrap
|
||||
|
||||
Turnstone Bootstrap Wizard v0.5.4
|
||||
Turnstone Bootstrap Wizard v1.5.0
|
||||
────────────────────────────────────────────────
|
||||
|
||||
Which provider for this wizard?
|
||||
@@ -87,6 +87,6 @@ $ turnstone-bootstrap
|
||||
|
||||
## 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
|
||||
- [Docker Deployment](docs/docker.md) — manual compose setup and profiles
|
||||
- [Security](docs/security.md) — auth architecture and token types
|
||||
- [Governance](docs/governance.md) — roles, policies, and templates
|
||||
|
||||
@@ -7,10 +7,21 @@
|
||||
|
||||
Multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers with direct HTTP routing, interactive interfaces, and enterprise governance.
|
||||
|
||||
> **Beta — Use at your own risk.** APIs, configuration formats, and database schemas may change between versions without migration paths.
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero.png" alt="Turnstone console — multi-workstream AI orchestration with mermaid diagrams" width="960"/>
|
||||
</p>
|
||||
|
||||
Named after the [Ruddy Turnstone](https://en.wikipedia.org/wiki/Ruddy_turnstone) (*Arenaria interpres*) — a shorebird that flips stones to discover what's hiding underneath.
|
||||
|
||||
### Release Tracks
|
||||
|
||||
| Track | Install | Docker | Description |
|
||||
|-------|---------|--------|-------------|
|
||||
| **Stable** | `pip install turnstone` | `ghcr.io/turnstonelabs/turnstone:stable` | Production-grade. Bugfixes only. |
|
||||
| **Experimental** | `pip install turnstone --pre` | `ghcr.io/turnstonelabs/turnstone:experimental` | New features. May have rough edges. |
|
||||
|
||||
See [docs/releasing.md](docs/releasing.md) for the full release process.
|
||||
|
||||
## What it does
|
||||
|
||||
Turnstone gives LLMs tools — shell, files, search, web, planning — and orchestrates multi-turn conversations where the model investigates, acts, and reports.
|
||||
@@ -19,7 +30,7 @@ Turnstone gives LLMs tools — shell, files, search, web, planning — and orche
|
||||
- **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
|
||||
- **Multi-provider** — OpenAI-compatible APIs (vLLM, llama.cpp, NIM), Anthropic Messages API, and Google Gemini
|
||||
- **MCP support** — external tool servers with native deferred loading (Anthropic/OpenAI) or BM25 fallback
|
||||
|
||||
<p align="center">
|
||||
@@ -42,6 +53,15 @@ pip install turnstone[console]
|
||||
turnstone-console --port 8090
|
||||
```
|
||||
|
||||
For PostgreSQL (recommended for production):
|
||||
|
||||
```bash
|
||||
pip install turnstone[postgres]
|
||||
export TURNSTONE_DB_BACKEND=postgresql
|
||||
export TURNSTONE_DB_URL="postgresql+psycopg://user:pass@localhost:5432/turnstone"
|
||||
turnstone-server --port 8080 --base-url http://localhost:8000/v1
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
@@ -64,20 +84,20 @@ with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
|
||||
## Tools
|
||||
|
||||
Built-in tools for shell, files, search, web, memory, notifications, and autonomous sub-agents — plus external tools via [MCP](https://modelcontextprotocol.io/) with native deferred loading. See [docs/tools.md](docs/tools.md) for the full reference and [docs/mcp.md](docs/mcp.md) for MCP configuration.
|
||||
Built-in tools for shell, files, search, web, memory, notifications, and autonomous sub-agents — plus external tools via [MCP](https://modelcontextprotocol.io/) with native deferred loading. See [docs/tools.md](docs/tools.md) for the full reference and [docs/mcp-registry.md](docs/mcp-registry.md) for MCP configuration.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Single-node**: Client → Server (direct HTTP + SSE). No external dependencies beyond the database.
|
||||
|
||||
**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.
|
||||
**Multi-node**: Client → Console (rendezvous routing proxy) → Server nodes. The console picks the target node for each workstream via rendezvous (HRW) hashing over the live service registry — pure function of `(ws_id, live_nodes)`, no stored bucket state, deterministic across readers. A node join or drop only re-routes the keys that score highest on the affected node.
|
||||
|
||||
| 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-channel` | Channel gateway (Discord and Slack adapters) |
|
||||
| `turnstone-admin` | User/token management CLI |
|
||||
| `turnstone-eval` | Eval harness for prompt/tool optimization |
|
||||
| `turnstone-bootstrap` | LLM-guided setup wizard |
|
||||
@@ -97,7 +117,7 @@ UML diagrams in [`docs/diagrams/`](docs/diagrams/):
|
||||
| [Console Data Flow](docs/diagrams/png/11-console-data-flow.png) | Dashboard data collection |
|
||||
| [Deployment](docs/diagrams/png/12-deployment.png) | Docker Compose topology |
|
||||
| [Auth](docs/diagrams/png/15-auth-architecture.png) | JWT, scopes, login flows |
|
||||
| [Channels](docs/diagrams/png/16-channel-architecture.png) | Discord adapter + routing |
|
||||
| [Channels](docs/diagrams/png/16-channel-architecture.png) | Discord / Slack adapters + routing |
|
||||
| [Judge](docs/diagrams/png/22-judge-architecture.png) | Intent validation pipeline |
|
||||
| [OIDC](docs/diagrams/png/25-oidc-architecture.png) | SSO authorization code flow |
|
||||
|
||||
@@ -116,12 +136,12 @@ UML diagrams in [`docs/diagrams/`](docs/diagrams/):
|
||||
| Console dashboard | [docs/console.md](docs/console.md) |
|
||||
| Eval harness | [docs/eval.md](docs/eval.md) |
|
||||
| Tools reference | [docs/tools.md](docs/tools.md) |
|
||||
| MCP integration | [docs/mcp.md](docs/mcp.md) |
|
||||
| MCP integration | [docs/mcp-registry.md](docs/mcp-registry.md) |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11+
|
||||
- An OpenAI-compatible API endpoint or Anthropic API key
|
||||
- An OpenAI-compatible API endpoint, Anthropic API key, or Google Gemini API key
|
||||
- Optional: PostgreSQL (`pip install turnstone[postgres]`), Anthropic (`pip install turnstone[anthropic]`)
|
||||
- [Git LFS](https://git-lfs.com/) for cloning (diagram PNGs)
|
||||
|
||||
|
||||
@@ -95,3 +95,22 @@ 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.
|
||||
|
||||
================================================================================
|
||||
|
||||
hls.js 1.6.15
|
||||
https://github.com/video-dev/hls.js
|
||||
|
||||
Copyright 2017 Dailymotion
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
+30
-71
@@ -1,12 +1,16 @@
|
||||
# =============================================================================
|
||||
# Turnstone Docker Compose Stack
|
||||
# Turnstone Docker Compose Stack — Development
|
||||
#
|
||||
# This file is for local development from a git clone. It builds images
|
||||
# locally from the Dockerfile. If you installed via pip/pipx, run
|
||||
# `turnstone-bootstrap` instead — it writes a production compose.yaml
|
||||
# that pulls pre-built images from ghcr.io.
|
||||
#
|
||||
# Usage:
|
||||
# Infra only: docker compose up
|
||||
# Single node: docker compose --profile production up
|
||||
# Production (PG): DB_BACKEND=postgresql docker compose --profile production up
|
||||
# Production (PG): TURNSTONE_DB_BACKEND=postgresql docker compose --profile production up
|
||||
# 10-node cluster: docker compose --profile cluster up
|
||||
# Cluster + DDG: docker compose --profile ddgCluster up
|
||||
# =============================================================================
|
||||
|
||||
name: turnstone
|
||||
@@ -17,6 +21,7 @@ networks:
|
||||
|
||||
volumes:
|
||||
turnstone-data:
|
||||
workspace:
|
||||
postgres-data:
|
||||
|
||||
services:
|
||||
@@ -28,7 +33,6 @@ services:
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
- ddgCluster
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
@@ -61,9 +65,7 @@ services:
|
||||
# turnstone-server — Web UI + chat workstreams + LLM interaction
|
||||
# -------------------------------------------------------------------
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: turnstone:local
|
||||
profiles:
|
||||
- production
|
||||
command:
|
||||
@@ -82,19 +84,18 @@ services:
|
||||
- "${SERVER_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ./docker/mcp-ddg.json:/etc/turnstone/mcp-ddg.json:ro
|
||||
- ${WORKSPACE_MOUNT:-workspace}:/workspace
|
||||
environment:
|
||||
- LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-dummy}
|
||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||
- SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-}
|
||||
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- MODEL=${MODEL:-}
|
||||
- MCP_CONFIG=${MCP_CONFIG:-}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-}
|
||||
- TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-}
|
||||
- TURNSTONE_NODE_ID=${TURNSTONE_NODE_ID:-}
|
||||
- TURNSTONE_ADVERTISE_URL=${TURNSTONE_ADVERTISE_URL:-http://server:8080}
|
||||
extra_hosts:
|
||||
@@ -105,9 +106,6 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
ddg-search:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
@@ -120,6 +118,7 @@ services:
|
||||
# turnstone-console — Cluster dashboard
|
||||
# -------------------------------------------------------------------
|
||||
console:
|
||||
image: turnstone:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -127,15 +126,13 @@ services:
|
||||
- turnstone-console
|
||||
- --host=0.0.0.0
|
||||
- --port=8090
|
||||
- --poll-interval=${CONSOLE_POLL_INTERVAL:-10}
|
||||
ports:
|
||||
- "${CONSOLE_PORT:-8090}:8090"
|
||||
environment:
|
||||
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-}
|
||||
- TURNSTONE_CONSOLE_URL=http://console:8090
|
||||
networks:
|
||||
- turnstone-net
|
||||
@@ -152,13 +149,10 @@ services:
|
||||
# Requires TURNSTONE_DISCORD_TOKEN to enable Discord adapter
|
||||
# -------------------------------------------------------------------
|
||||
channel:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: turnstone:local
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
- ddgCluster
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -169,10 +163,10 @@ services:
|
||||
environment:
|
||||
- TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-}
|
||||
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
|
||||
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-postgresql}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-postgresql}
|
||||
- TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
|
||||
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
|
||||
networks:
|
||||
- turnstone-net
|
||||
@@ -182,39 +176,6 @@ services:
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# ddg-search — DuckDuckGo Search MCP server (HTTP transport)
|
||||
# Provides web search + content fetch tools to turnstone via MCP.
|
||||
# No API key required.
|
||||
#
|
||||
# Start with: MCP_CONFIG=/etc/turnstone/mcp-ddg.json \
|
||||
# docker compose --profile ddgCluster up
|
||||
# -------------------------------------------------------------------
|
||||
ddg-search:
|
||||
image: python:3.14-slim
|
||||
profiles:
|
||||
- ddgCluster
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
pip install --no-cache-dir duckduckgo-mcp-server &&
|
||||
python -c "from mcp.server.transport_security import TransportSecuritySettings; import duckduckgo_mcp_server.server as s; s.safe_search=s.SafeSearchMode.OFF; s.mcp.settings.host='0.0.0.0'; s.mcp.settings.port=3000; s.mcp.settings.transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False); s.mcp.run(transport='streamable-http')"
|
||||
networks:
|
||||
- turnstone-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import socket; s=socket.create_connection(('0.0.0.0',3000),2); s.close()\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
cpus: '0.25'
|
||||
restart: unless-stopped
|
||||
|
||||
# ===================================================================
|
||||
# 10-node cluster (profile: cluster)
|
||||
#
|
||||
@@ -229,7 +190,7 @@ services:
|
||||
server-1: &cluster-server
|
||||
image: turnstone:local
|
||||
build: { context: ., dockerfile: Dockerfile }
|
||||
profiles: [cluster, ddgCluster]
|
||||
profiles: [cluster]
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -244,26 +205,24 @@ services:
|
||||
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ./docker/mcp-ddg.json:/etc/turnstone/mcp-ddg.json:ro
|
||||
- ${WORKSPACE_MOUNT:-workspace}:/workspace
|
||||
environment: &cluster-server-env
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy}
|
||||
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
|
||||
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
|
||||
TURNSTONE_AUTH_ENABLED: ${TURNSTONE_AUTH_ENABLED:-}
|
||||
TURNSTONE_AUTH_TOKEN: ${TURNSTONE_AUTH_TOKEN:-}
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
MODEL: ${MODEL:-}
|
||||
MCP_CONFIG: ${MCP_CONFIG:-}
|
||||
TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql}
|
||||
TURNSTONE_DB_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone}
|
||||
TURNSTONE_DB_BACKEND: ${TURNSTONE_DB_BACKEND:-postgresql}
|
||||
TURNSTONE_DB_URL: ${TURNSTONE_DB_URL:-postgresql+psycopg://${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:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Bare-metal overlay — expose PostgreSQL and let the console reach
|
||||
# a turnstone-server running outside Docker on the host machine.
|
||||
#
|
||||
# Requires TURNSTONE_HOST_IP set to the host's routable IP address.
|
||||
#
|
||||
# Usage:
|
||||
# export TURNSTONE_HOST_IP="$(hostname -I | awk '{print $1}')"
|
||||
# docker compose --profile production \
|
||||
# -f compose.yaml -f deploy/docker-compose.bare-metal.yml up
|
||||
#
|
||||
# Then on the host:
|
||||
# export TURNSTONE_JWT_SECRET="<same as .env>"
|
||||
# export TURNSTONE_DB_BACKEND=postgresql
|
||||
# export TURNSTONE_DB_URL="postgresql://turnstone:<pw>@localhost:5432/turnstone"
|
||||
# export TURNSTONE_NODE_ID="bare-metal-1"
|
||||
# export TURNSTONE_ADVERTISE_URL="http://${TURNSTONE_HOST_IP}:8080"
|
||||
# python -m turnstone.server --host 0.0.0.0 --port 8080 \
|
||||
# --base-url http://localhost:8000/v1 --api-key "$OPENAI_API_KEY"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
|
||||
console:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
# Console needs to reach the bare-metal server on the host
|
||||
TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}"
|
||||
|
||||
channel:
|
||||
ports:
|
||||
- "${CHANNEL_PORT:-8091}:8091"
|
||||
environment:
|
||||
# Channel gateway advertises with host-routable IP so the
|
||||
# bare-metal server can reach it for schedule notifications
|
||||
TURNSTONE_CHANNEL_ADVERTISE_URL: "http://${TURNSTONE_HOST_IP}:${CHANNEL_PORT:-8091}"
|
||||
# Channel needs to reach the bare-metal server on the host
|
||||
TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}"
|
||||
@@ -36,13 +36,13 @@ spec:
|
||||
- secretRef:
|
||||
name: {{ include "turnstone.llm.secretName" . }}
|
||||
optional: true
|
||||
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
|
||||
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
|
||||
env:
|
||||
- name: TURNSTONE_AUTH_TOKEN
|
||||
- name: TURNSTONE_JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.auth.existingSecret }}
|
||||
key: TURNSTONE_AUTH_TOKEN
|
||||
name: {{ include "turnstone.auth.secretName" . }}
|
||||
key: TURNSTONE_JWT_SECRET
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
|
||||
@@ -41,12 +41,12 @@ spec:
|
||||
env:
|
||||
- name: TURNSTONE_DB_URL
|
||||
value: "postgresql+psycopg://$(TURNSTONE_DB_USER):$(POSTGRES_PASSWORD)@$(TURNSTONE_DB_HOST):$(TURNSTONE_DB_PORT)/$(TURNSTONE_DB_NAME)"
|
||||
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
|
||||
- name: TURNSTONE_AUTH_TOKEN
|
||||
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
|
||||
- name: TURNSTONE_JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.auth.existingSecret }}
|
||||
key: TURNSTONE_AUTH_TOKEN
|
||||
name: {{ include "turnstone.auth.secretName" . }}
|
||||
key: TURNSTONE_JWT_SECRET
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
|
||||
@@ -15,7 +15,7 @@ data:
|
||||
{{- else if and (not .Values.postgresql.enabled) .Values.database.external.password }}
|
||||
POSTGRES_PASSWORD: {{ .Values.database.external.password | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if and .Values.auth.enabled .Values.auth.token (not .Values.auth.existingSecret) }}
|
||||
TURNSTONE_AUTH_TOKEN: {{ .Values.auth.token | b64enc | quote }}
|
||||
{{- if and .Values.auth.jwtSecret (not .Values.auth.existingSecret) }}
|
||||
TURNSTONE_JWT_SECRET: {{ .Values.auth.jwtSecret | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -59,10 +59,9 @@ llm:
|
||||
apiKey: ""
|
||||
existingSecret: ""
|
||||
|
||||
# -- Authentication
|
||||
# -- Authentication (always enabled, JWT secret required)
|
||||
auth:
|
||||
enabled: false
|
||||
token: ""
|
||||
jwtSecret: ""
|
||||
existingSecret: ""
|
||||
|
||||
# -- Ingress configuration
|
||||
|
||||
@@ -40,8 +40,8 @@ resource "aws_iam_role_policy" "ecs_execution_secrets" {
|
||||
[
|
||||
aws_secretsmanager_secret.openai_api_key.arn,
|
||||
aws_secretsmanager_secret.db_password.arn,
|
||||
aws_secretsmanager_secret.jwt_secret.arn,
|
||||
],
|
||||
var.auth_token != "" ? [aws_secretsmanager_secret.auth_token[0].arn] : [],
|
||||
)
|
||||
},
|
||||
]
|
||||
|
||||
@@ -41,20 +41,26 @@ locals {
|
||||
},
|
||||
]
|
||||
|
||||
auth_env = var.auth_token != "" ? [
|
||||
{ name = "TURNSTONE_AUTH_ENABLED", value = "true" },
|
||||
] : []
|
||||
|
||||
auth_secrets = var.auth_token != "" ? [
|
||||
auth_secrets = [
|
||||
{
|
||||
name = "TURNSTONE_AUTH_TOKEN"
|
||||
valueFrom = aws_secretsmanager_secret_version.auth_token[0].arn
|
||||
name = "TURNSTONE_JWT_SECRET"
|
||||
valueFrom = aws_secretsmanager_secret_version.jwt_secret.arn
|
||||
},
|
||||
] : []
|
||||
]
|
||||
}
|
||||
|
||||
# ---------- Secrets Manager ----------
|
||||
|
||||
resource "aws_secretsmanager_secret" "jwt_secret" {
|
||||
name = "${var.name_prefix}-${var.environment}-jwt-secret"
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "jwt_secret" {
|
||||
secret_id = aws_secretsmanager_secret.jwt_secret.id
|
||||
secret_string = var.jwt_secret
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "openai_api_key" {
|
||||
name = "${var.name_prefix}-${var.environment}-openai-api-key"
|
||||
tags = local.common_tags
|
||||
@@ -65,17 +71,7 @@ resource "aws_secretsmanager_secret_version" "openai_api_key" {
|
||||
secret_string = var.openai_api_key
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "auth_token" {
|
||||
count = var.auth_token != "" ? 1 : 0
|
||||
name = "${var.name_prefix}-${var.environment}-auth-token"
|
||||
tags = local.common_tags
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "auth_token" {
|
||||
count = var.auth_token != "" ? 1 : 0
|
||||
secret_id = aws_secretsmanager_secret.auth_token[0].id
|
||||
secret_string = var.auth_token
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "db_password" {
|
||||
name = "${var.name_prefix}-${var.environment}-db-password"
|
||||
@@ -140,7 +136,7 @@ resource "aws_ecs_task_definition" "server" {
|
||||
{ containerPort = 8080, protocol = "tcp" },
|
||||
]
|
||||
|
||||
environment = concat(local.common_env, local.auth_env)
|
||||
environment = local.common_env
|
||||
secrets = concat(local.common_secrets, local.auth_secrets)
|
||||
|
||||
logConfiguration = {
|
||||
@@ -209,7 +205,7 @@ resource "aws_ecs_task_definition" "console" {
|
||||
{ containerPort = 8090, protocol = "tcp" },
|
||||
]
|
||||
|
||||
environment = concat(local.common_env, local.auth_env)
|
||||
environment = local.common_env
|
||||
secrets = concat(local.common_secrets, local.auth_secrets)
|
||||
|
||||
logConfiguration = {
|
||||
|
||||
@@ -90,11 +90,10 @@ variable "name_prefix" {
|
||||
default = "turnstone"
|
||||
}
|
||||
|
||||
variable "auth_token" {
|
||||
description = "Optional authentication token for the Turnstone API. Empty string disables auth."
|
||||
variable "jwt_secret" {
|
||||
description = "JWT signing secret for Turnstone auth (required, min 32 characters)."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "certificate_arn" {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ddg": {
|
||||
"url": "http://ddg-search:3000/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
+270
-13
@@ -56,7 +56,7 @@ console.log(result.content);
|
||||
|
||||
## Authentication
|
||||
|
||||
When auth is enabled (`[auth].enabled = true` or `TURNSTONE_AUTH_ENABLED=1`), all API endpoints except public paths require a valid token.
|
||||
Auth is always enabled. All API endpoints except public paths require a valid token.
|
||||
|
||||
### Sending Credentials
|
||||
|
||||
@@ -65,15 +65,14 @@ Include a token in one of two ways:
|
||||
- **Bearer header**: `Authorization: Bearer <token>`
|
||||
- **Cookie**: `turnstone_auth=<token>` (set automatically by the login endpoint)
|
||||
|
||||
The server accepts three token types:
|
||||
The server accepts two token types:
|
||||
|
||||
| Type | Format | Example |
|
||||
|------|--------|---------|
|
||||
| JWT | Base64 segments separated by dots | `eyJhbG...` |
|
||||
| API token | `ts_` prefix + 64 hex chars | `ts_a1b2c3d4...` |
|
||||
| Config token | Arbitrary string from `config.toml` | `my-secret-token` |
|
||||
|
||||
JWTs are the recommended credential for browser sessions. API tokens are suitable for programmatic access and CI/CD. Config tokens are a simple option for single-node deployments.
|
||||
JWTs are the recommended credential for browser sessions. API tokens are suitable for programmatic access and CI/CD.
|
||||
|
||||
### `POST /v1/api/auth/login`
|
||||
|
||||
@@ -843,6 +842,15 @@ button automatically.
|
||||
|
||||
Creates a new workstream. The server supports up to 10 concurrent workstreams.
|
||||
|
||||
The endpoint accepts **either** `application/json` (legacy shape) **or**
|
||||
`multipart/form-data` when you want to upload attachments at creation
|
||||
time. Multipart requests carry one `meta` field containing the JSON body
|
||||
shown below plus zero-or-more `file` parts; each file is validated and
|
||||
reserved onto the new workstream's first turn before the dispatch worker
|
||||
runs, so queued multimodal turns cannot lose files to racing sends. If
|
||||
validation fails the fresh workstream is rolled back so no orphan rows
|
||||
leak.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
@@ -858,6 +866,7 @@ All fields are optional. The body can be empty or an empty JSON object.
|
||||
| `auto_approve` | bool | false | Auto-approve all tool calls for this workstream |
|
||||
| `resume_ws` | string | "" | Workstream ID to resume atomically during creation (empty = fresh)|
|
||||
| `skill` | string | "" | Skill name. Applies content (system prompt), model, temperature, reasoning effort, max tokens, auto-approve policy, token budget, and other session config from the skill. Returns 400 if not found or disabled. Ignored when `resume_ws` is set (resumed sessions restore their own skill). |
|
||||
| `judge_model` | string | "" | Optional model alias for the judge (overrides default judge model for this workstream) |
|
||||
|
||||
> **Skill behavior:** When `skill` is specified, the skill's content is injected as a system message and its session config fields (model, temperature, auto-approve, token budget, etc.) override system defaults for the new workstream.
|
||||
|
||||
@@ -915,6 +924,255 @@ Status code: `400`
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/workstreams/{ws_id}/attachments`
|
||||
|
||||
Upload an image or text document and attach it to the caller's next user
|
||||
turn on this workstream.
|
||||
|
||||
- Images (png/jpeg/gif/webp) are capped at **4 MiB** and validated via
|
||||
magic-byte sniff on upload.
|
||||
- Text documents (any `text/*` MIME, allow-listed application MIMEs, or
|
||||
known text extensions) are capped at **512 KiB** and must be UTF-8.
|
||||
- Per-(workstream, user) pending cap is **10** attachments.
|
||||
|
||||
The attachment moves through three states: `pending → reserved →
|
||||
consumed`. Reservation tokens are threaded through
|
||||
`POST /v1/api/send` so a queued multimodal turn cannot lose its file to
|
||||
an overlapping send.
|
||||
|
||||
Ownership failures are masked as `404` so non-owners cannot enumerate
|
||||
workstream existence.
|
||||
|
||||
**Content-Type:** `multipart/form-data` with a single `file` field.
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"attachment_id": "att_abc123",
|
||||
"kind": "image",
|
||||
"mime_type": "image/png",
|
||||
"size_bytes": 73240,
|
||||
"filename": "screenshot.png",
|
||||
"state": "pending"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------------------------------------------------------|
|
||||
| 400 | Missing/invalid form, unsupported MIME, not UTF-8, etc. |
|
||||
| 403 | Auth/scope failure |
|
||||
| 404 | Workstream not found / not owned by caller |
|
||||
| 409 | Pending-cap reached |
|
||||
| 413 | Payload exceeds size cap |
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/workstreams/{ws_id}/attachments`
|
||||
|
||||
List the caller's **pending** (unconsumed) attachments for this
|
||||
workstream. Ownership failures are masked as `404`.
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"attachments": [
|
||||
{
|
||||
"attachment_id": "att_abc123",
|
||||
"kind": "image",
|
||||
"mime_type": "image/png",
|
||||
"size_bytes": 73240,
|
||||
"filename": "screenshot.png",
|
||||
"state": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/workstreams/{ws_id}/attachments/{attachment_id}/content`
|
||||
|
||||
Returns the raw bytes of an attachment with its stored `Content-Type`.
|
||||
Useful for previewing an image or replaying a document. Ownership
|
||||
failures are masked as `404`.
|
||||
|
||||
**Response:** `200` — binary body, original `Content-Type`.
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /v1/api/workstreams/{ws_id}/attachments/{attachment_id}`
|
||||
|
||||
Remove a pending attachment. Consumed attachments return `404` (they
|
||||
are part of a committed conversation turn). Ownership failures are also
|
||||
masked as `404`.
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{"deleted": "att_abc123"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/workstreams/{ws_id}/delete`
|
||||
|
||||
Permanently delete a saved workstream and all its messages from storage.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------|----------------------|
|
||||
| `ws_id` | string | Workstream ID |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{"deleted": "a1b2c3d4"}
|
||||
```
|
||||
|
||||
**Response (not found):** `404`
|
||||
|
||||
```json
|
||||
{"error": "Workstream not found"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/workstreams/{ws_id}/open`
|
||||
|
||||
Load a saved workstream into memory with its original `ws_id`. If the
|
||||
workstream is already loaded, returns immediately with `already_loaded: true`.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------|----------------------|
|
||||
| `ws_id` | string | Workstream ID |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{"ws_id": "a1b2c3d4", "name": "refactor"}
|
||||
```
|
||||
|
||||
**Response (already loaded):** `200`
|
||||
|
||||
```json
|
||||
{"ws_id": "a1b2c3d4", "name": "refactor", "already_loaded": true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/workstreams/{ws_id}/title`
|
||||
|
||||
Set a workstream title manually. The title is stored as the workstream alias.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------|----------------------|
|
||||
| `ws_id` | string | Workstream ID |
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{"title": "JWT Authentication Refactor"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------|--------|----------|------------------------|
|
||||
| `title` | string | yes | New workstream title |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{"status": "ok", "title": "JWT Authentication Refactor"}
|
||||
```
|
||||
|
||||
**Response (conflict):** `409`
|
||||
|
||||
```json
|
||||
{"error": "That name is already used by another workstream"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/workstreams/{ws_id}/refresh-title`
|
||||
|
||||
Regenerate the workstream title via LLM based on conversation content.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------|----------------------|
|
||||
| `ws_id` | string | Workstream ID |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/admin/settings`
|
||||
|
||||
List `interface.*` settings with their current values and sources. Requires
|
||||
`read` scope on the server.
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": [
|
||||
{
|
||||
"key": "interface.close_tab_action",
|
||||
"value": "last_used",
|
||||
"source": "default",
|
||||
"type": "str",
|
||||
"description": "Determines which workstream to switch to after closing a tab."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST|PUT /v1/api/admin/settings/{key}`
|
||||
|
||||
Update an `interface.*` setting. Only keys in the `interface` section are
|
||||
accepted; other keys return `400`.
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|--------|-------------------------------------|
|
||||
| `key` | string | Setting key (e.g. `interface.theme`) |
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{"value": "light"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------|------|----------|----------------|
|
||||
| `value` | any | yes | New value |
|
||||
|
||||
**Response (success):** `200`
|
||||
|
||||
```json
|
||||
{"status": "ok", "key": "interface.theme", "value": "light"}
|
||||
```
|
||||
|
||||
**Error:** `400` if the key is not in the `interface` section.
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/watches`
|
||||
|
||||
List active watches on this server node. Optionally filter by workstream.
|
||||
@@ -1353,7 +1611,7 @@ version. Requires the `admin.skills` permission.
|
||||
|
||||
```json
|
||||
{
|
||||
"scan_status": "medium",
|
||||
"risk_level": "medium",
|
||||
"scan_report": "{\"composite\": 1.75, \"details\": {...}}",
|
||||
"scan_version": "1"
|
||||
}
|
||||
@@ -1863,15 +2121,15 @@ turnstone_tool_calls_total{tool="read_file"} 3
|
||||
## Console Routing Proxy Endpoints
|
||||
|
||||
These endpoints are served by the console (`turnstone-console`) and proxy
|
||||
requests to the correct server node via the hash ring bucket cache. In
|
||||
multi-node deployments, clients (SDK, channel gateway) talk to the console
|
||||
instead of individual server nodes.
|
||||
requests to the correct server node via rendezvous (HRW) hashing over the
|
||||
live service registry. In multi-node deployments, clients (SDK, channel
|
||||
gateway) talk to the console instead of individual server nodes.
|
||||
|
||||
### `POST /v1/api/route/workstreams/new`
|
||||
|
||||
Create a workstream via hash-ring routing. The console generates the `ws_id`,
|
||||
routes to the assigned node, and includes `node_url` in the response for
|
||||
direct SSE connections.
|
||||
Create a workstream via rendezvous routing. The console generates the `ws_id`,
|
||||
routes to the rendezvous-selected node, and includes `node_url` in the
|
||||
response for direct SSE connections.
|
||||
|
||||
### `POST /v1/api/route/send`
|
||||
|
||||
@@ -1906,5 +2164,4 @@ Used by channel adapters to open direct SSE connections to the correct server no
|
||||
|
||||
Prometheus metrics for the console routing layer. Includes:
|
||||
`turnstone_router_requests_total`, `turnstone_router_request_duration_seconds`,
|
||||
`turnstone_ring_membership_size`, `turnstone_ring_version`,
|
||||
`turnstone_ring_rebalance_total`, `turnstone_ring_migrations_total`.
|
||||
`turnstone_router_membership_size`, `turnstone_router_refresh_total`.
|
||||
|
||||
+132
-64
@@ -21,7 +21,8 @@ plugs in.
|
||||
| `turnstone-console` | `turnstone.console.server` | ClusterCollector | Cluster dashboard (aggregates all nodes) |
|
||||
| `turnstone-eval` | `turnstone.eval` | `NullUI` | Headless evaluation and prompt optimization |
|
||||
| `turnstone-channel` | `turnstone.channels.cli` | ChannelAdapter | Channel gateway (Discord, Slack, etc.) |
|
||||
| `turnstone-admin` | `turnstone.core.admin_cli` | — | Offline user and API token management |
|
||||
| `turnstone-admin` | `turnstone.admin` | — | Offline user and API token management |
|
||||
| `turnstone-bootstrap` | `turnstone.bootstrap` | — | LLM-guided setup wizard |
|
||||
|
||||
---
|
||||
|
||||
@@ -36,8 +37,12 @@ turnstone/
|
||||
session.py ChatSession engine, SessionUI protocol, tool dispatch
|
||||
providers/ LLM provider adapters (pluggable backend layer)
|
||||
_protocol.py LLMProvider protocol, ModelCapabilities, StreamChunk, CompletionResult
|
||||
_openai.py OpenAIProvider — OpenAI, vLLM, llama.cpp, any compatible API
|
||||
_openai.py OpenAIProvider facade (re-exports Chat/Responses providers)
|
||||
_openai_chat.py OpenAIChatCompletionsProvider — vLLM, llama.cpp, local compatible APIs
|
||||
_openai_responses.py OpenAIResponsesProvider — commercial OpenAI Responses API
|
||||
_openai_common.py Shared ModelCapabilities table + helpers
|
||||
_anthropic.py AnthropicProvider — Anthropic Messages API, native streaming, thinking
|
||||
_google.py GoogleProvider — Google Gemini via OpenAI-compat endpoint
|
||||
__init__.py create_provider() + create_client() factory functions
|
||||
workstream.py Parallel workstream manager (WorkstreamState, Workstream, WorkstreamManager)
|
||||
tools.py Tool schema loader (JSON -> OpenAI function-calling format)
|
||||
@@ -74,18 +79,19 @@ turnstone/
|
||||
_sync.py Background event loop for sync wrappers
|
||||
_types.py TurnResult + TurnstoneAPIError
|
||||
console/
|
||||
collector.py ClusterCollector — aggregates state from all nodes via HTTP
|
||||
collector.py ClusterCollector — aggregates state from all nodes via SSE
|
||||
scheduler.py TaskScheduler — background cron/at scheduler, dispatches via HTTP
|
||||
server.py Cluster dashboard HTTP server + SSE + CLI entry point
|
||||
static/ Cluster dashboard web UI (page-specific HTML, CSS, JS)
|
||||
channels/
|
||||
cli.py Unified channel gateway entry point (turnstone-channel)
|
||||
_protocol.py ChannelAdapter protocol, ChannelEvent dataclass
|
||||
_protocol.py ChannelAdapter protocol
|
||||
_routing.py ChannelRouter — channel/thread ↔ workstream mapping via HTTP
|
||||
_config.py Base ChannelConfig dataclass
|
||||
discord/ Discord adapter (bot, cog, views, streaming, config)
|
||||
slack/ Slack adapter (Socket Mode bot, DM routing, approval buttons)
|
||||
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
|
||||
katex-0.16.44/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
katex-0.16.45/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
ui/
|
||||
colors.py ANSI color constants with NO_COLOR support
|
||||
markdown.py Streaming terminal markdown renderer (line-buffered)
|
||||
@@ -96,7 +102,7 @@ turnstone/
|
||||
renderer.js Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
|
||||
app.js Split-pane UI (Pane class, binary layout tree, SSE, tool approval)
|
||||
tools/
|
||||
*.json 15 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||||
*.json 19 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/`.
|
||||
@@ -442,13 +448,15 @@ 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
|
||||
|
||||
### 13 Tools by Category
|
||||
### 19 Tools by Category
|
||||
|
||||
**Read-only (auto-approve)**:
|
||||
- `read_file` -- read file contents with optional offset/limit
|
||||
- `diff_file` -- show diff between two files / versions
|
||||
- `search` -- ripgrep-based codebase search
|
||||
- `man` -- read man pages
|
||||
- `recall` -- search conversation history
|
||||
- `read_resource` -- read an MCP resource by URI
|
||||
|
||||
**Write (requires approval)**:
|
||||
- `bash` -- execute shell commands (with safety checks via `turnstone.core.safety`)
|
||||
@@ -457,13 +465,20 @@ from each schema and builds:
|
||||
- `math` -- execute Python in sandboxed subprocess (via `turnstone.core.sandbox`)
|
||||
- `web_fetch` -- fetch a URL (with SSRF protection via `turnstone.core.web`)
|
||||
- `web_search` -- search the web (provider-native for Anthropic/OpenAI, Tavily fallback for local models)
|
||||
- `notify` -- send a user-facing notification (Discord/Slack, optional reply routing)
|
||||
- `watch` -- schedule a recurring poll with condition DSL
|
||||
|
||||
**Agent (delegated sub-sessions)**:
|
||||
- `task` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
|
||||
- `plan` -- explore codebase and write a structured plan (`AGENT_TOOLS`)
|
||||
- `task_agent` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
|
||||
- `plan_agent` -- explore codebase and write a structured plan (`AGENT_TOOLS`)
|
||||
|
||||
**Memory (structured persistent store)**:
|
||||
**Memory / skills / prompts**:
|
||||
- `memory` -- save, search, delete, or list memories (typed and scoped)
|
||||
- `skill` -- invoke a skill (governed, versioned procedure)
|
||||
- `use_prompt` -- fetch and apply a prompt template
|
||||
|
||||
Tool names are `plan_agent` / `task_agent` (not `plan` / `task`); bare words
|
||||
collide with chat-template channels on some local models.
|
||||
|
||||
### Prepare / Execute Pattern
|
||||
|
||||
@@ -482,14 +497,14 @@ separation allows the UI to show previews before any side effects occur.
|
||||
|
||||
### Agent Tools
|
||||
|
||||
`task` and `plan` invoke `_run_agent()`, which runs a multi-turn loop with
|
||||
a subset of tools and its own system prompt. The sub-agent runs
|
||||
`task_agent` and `plan_agent` invoke `_run_agent()`, which runs a multi-turn
|
||||
loop with a subset of tools and its own system prompt. The sub-agent runs
|
||||
independently, then returns the final content as the tool result.
|
||||
|
||||
- **task**: uses `self._task_tools` (`TASK_AGENT_TOOLS` + MCP tools)
|
||||
- **plan**: uses `self._agent_tools` (`AGENT_TOOLS` + MCP tools). Writes output
|
||||
- **task_agent**: uses `self._task_tools` (`TASK_AGENT_TOOLS` + MCP tools)
|
||||
- **plan_agent**: uses `self._agent_tools` (`AGENT_TOOLS` + MCP tools). Writes output
|
||||
to `.plan-<ws_id>.md` — unique per `ChatSession` so concurrent workstreams
|
||||
don't collide. On repeat invocations the prior `plan` tool call and its result
|
||||
don't collide. On repeat invocations the prior `plan_agent` tool call and its result
|
||||
are forwarded from `self.messages` so the agent refines the existing plan rather
|
||||
than starting over. Planning instructions are injected as a developer message
|
||||
prepended to the agent's conversation.
|
||||
@@ -547,6 +562,21 @@ expanded tools).
|
||||
**Tool naming:** `mcp__{server}__{tool}` — double underscore delimiter, validated
|
||||
at connection time (server names with `__` are rejected).
|
||||
|
||||
**Resilience:** Each MCP server has an independent circuit breaker that opens
|
||||
after 3 consecutive transport failures (timeouts, broken pipes, connection
|
||||
resets). Cooldown uses capped exponential backoff (30 s base, 5 min max) with
|
||||
per-server jitter to avoid thundering herd. Protocol-level errors (`McpError`)
|
||||
from a healthy connection do not trip the breaker. When the cooldown expires
|
||||
(half-open), the next operation attempt triggers automatic reconnection. Manual
|
||||
`/mcp refresh` also clears the circuit on success. All sync bridge methods
|
||||
(`call_tool_sync`, `read_resource_sync`, `get_prompt_sync`, `refresh_sync`)
|
||||
cancel orphaned futures on timeout to prevent coroutine accumulation on the
|
||||
background event loop. Push notification refreshes are debounced (5 s per
|
||||
server) to protect against notification storms. The periodic refresh loop
|
||||
attempts reconnection for disconnected servers with exponential backoff
|
||||
(60 s–1 h). Transport stream references are pre-closed before stack teardown to
|
||||
work around the MCP SDK's anyio cancel-scope CPU busy-loop (SDK #2147).
|
||||
|
||||
**Error isolation:** Per-server connection/refresh failures are caught and logged; other
|
||||
servers are unaffected. Tool execution errors return error strings to the LLM
|
||||
rather than crashing the session.
|
||||
@@ -578,6 +608,7 @@ LLMProvider (protocol)
|
||||
|
|
||||
+--- OpenAIProvider --- OpenAI, vLLM, llama.cpp, any /v1/chat/completions API
|
||||
+--- AnthropicProvider --- Anthropic Messages API (native streaming, thinking)
|
||||
+--- GoogleProvider --- Google Gemini via /v1beta/openai/ (extends OpenAIProvider)
|
||||
```
|
||||
|
||||
**Protocol methods:**
|
||||
@@ -631,6 +662,13 @@ both streaming and non-streaming responses. The `anthropic` SDK is imported
|
||||
lazily so it remains an optional dependency (`pip install
|
||||
turnstone[anthropic]`).
|
||||
|
||||
**GoogleProvider** (`_google.py`): extends `OpenAIChatCompletionsProvider` for
|
||||
the Gemini `/v1beta/openai/` endpoint. Uses a single default
|
||||
`ModelCapabilities` (2M context window, 65K max output tokens,
|
||||
`token_param=max_tokens`) since Google updates models frequently. No static
|
||||
per-model capability table. Google's endpoint is wire-compatible with the
|
||||
OpenAI SDK, so no extra dependency is needed.
|
||||
|
||||
**Factory functions** (`__init__.py`): `create_provider(name)` returns a
|
||||
singleton provider instance (thread-safe). `create_client(name, base_url,
|
||||
api_key)` creates the appropriate SDK client.
|
||||
@@ -659,6 +697,10 @@ api_key = "sk-..."
|
||||
model = "gpt-5"
|
||||
context_window = 400000
|
||||
|
||||
[models.gemini]
|
||||
provider = "google"
|
||||
model = "gemini-2.5-pro"
|
||||
|
||||
[model]
|
||||
default = "local"
|
||||
fallback = ["claude", "openai"]
|
||||
@@ -666,7 +708,28 @@ agent_model = "claude"
|
||||
```
|
||||
|
||||
Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
|
||||
(default: `"openai"`). Supported values: `"openai"` and `"anthropic"`.
|
||||
(default: `"openai"`). Supported values: `"openai"`, `"anthropic"`, `"google"`,
|
||||
and `"openai-compatible"`.
|
||||
|
||||
**Per-model sampling overrides:** Each model can specify `temperature`,
|
||||
`max_tokens`, and `reasoning_effort` to override the global defaults from
|
||||
ConfigStore. When unset (`NULL`), the global default is used.
|
||||
|
||||
```toml
|
||||
[models.local]
|
||||
base_url = "http://localhost:8000/v1"
|
||||
model = "qwen3-32b"
|
||||
temperature = 0.7
|
||||
max_tokens = 8192
|
||||
|
||||
[models.o3]
|
||||
base_url = "https://api.openai.com/v1"
|
||||
api_key = "sk-..."
|
||||
model = "o3"
|
||||
reasoning_effort = "high"
|
||||
# temperature omitted — uses global default
|
||||
```
|
||||
|
||||
An optional `[models.*.capabilities]` sub-table overrides per-model
|
||||
`ModelCapabilities` flags (useful for local models whose capabilities
|
||||
cannot be detected programmatically):
|
||||
@@ -680,9 +743,15 @@ model = "qwen-3.5-vl"
|
||||
supports_vision = true
|
||||
```
|
||||
|
||||
**Database model definitions:** On server entry points, models can also be
|
||||
defined in the `model_definitions` table (admin Models tab). DB models support
|
||||
the same per-model sampling overrides. Config.toml models override DB models
|
||||
with the same alias in-memory (the DB rows are never modified).
|
||||
|
||||
**Lifecycle:**
|
||||
1. `load_model_registry()` reads `[models.*]` sections from config.toml and
|
||||
builds a `"default"` entry from CLI `--base-url`/`--model`/`--api-key` args
|
||||
1. `load_model_registry()` loads DB model definitions (if storage available),
|
||||
then overlays `[models.*]` from config.toml, then builds a `"default"` entry
|
||||
from CLI `--base-url`/`--model`/`--api-key` args
|
||||
2. The registry is passed to the session factory closure in both `cli.py` and
|
||||
`server.py`; each workstream resolves its model on creation
|
||||
3. `ModelRegistry.get_client()` lazily creates SDK client instances via
|
||||
@@ -691,7 +760,8 @@ supports_vision = true
|
||||
4. `ModelRegistry.get_provider()` lazily creates `LLMProvider` instances via
|
||||
`create_provider()` (also cached and thread-safe)
|
||||
5. `/model` command shows available models; `/model <alias>` switches the
|
||||
active workstream's client, model, and context window
|
||||
active workstream's client, model, context window, and per-model sampling
|
||||
parameters
|
||||
6. `_create_stream_with_retry()` tries the primary model, then each fallback
|
||||
alias in order if the primary is unreachable
|
||||
7. `_run_agent()` resolves `registry.agent_model` (if set) for plan/task
|
||||
@@ -814,7 +884,7 @@ and are the single source of truth for both backends and Alembic migrations.
|
||||
| `update_workstream_title(ws_id, title)` | Set/update LLM-generated title |
|
||||
| `update_workstream_state(ws_id, state)` | Update workstream state and bump timestamp |
|
||||
| `update_workstream_name(ws_id, name)` | Update workstream display name |
|
||||
| `list_workstreams(node_id, limit)` | List workstreams, optionally by node |
|
||||
| `list_workstreams(node_id, limit, *, parent_ws_id, kind, user_id)` | List workstreams, optionally filtered by node, parent, kind, or owning user |
|
||||
| `kv_get(key)` / `kv_set(key, value)` / `kv_delete(key)` | Generic key-value store (backs memories table) |
|
||||
| `kv_list()` / `kv_search(query)` | List or search key-value pairs |
|
||||
| `search_history(query, limit)` | Full-text search (FTS5 on SQLite, tsvector on PostgreSQL) |
|
||||
@@ -1016,13 +1086,10 @@ limits using a token-bucket algorithm. Each IP gets a `TokenBucket` with
|
||||
Turnstone supports three authentication mechanisms, unified behind an
|
||||
`AuthResult` dataclass that carries `user_id`, `scopes`, and `token_source`:
|
||||
|
||||
1. **Config-file tokens** — static secrets in `config.toml` `[[auth.tokens]]`
|
||||
or the `TURNSTONE_AUTH_TOKEN` env var. Validated in-memory via
|
||||
`hmac.compare_digest`. Map to scopes through their role (`read` or `full`).
|
||||
2. **API tokens** — database-backed, prefixed `ts_`, stored as SHA-256 hashes
|
||||
1. **API tokens** — database-backed, prefixed `ts_`, stored as SHA-256 hashes
|
||||
in the `api_tokens` table. Can be exchanged for JWTs via
|
||||
`POST /v1/api/auth/login`.
|
||||
3. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
|
||||
2. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
|
||||
successful credential validation. Contain `sub` (user_id), `scopes`, and
|
||||
`src` (origin) in claims.
|
||||
|
||||
@@ -1046,9 +1113,8 @@ Three hierarchical scopes control endpoint access:
|
||||
2. **Token extraction** — `Authorization: Bearer <token>` header first, then
|
||||
`turnstone_auth` cookie as fallback.
|
||||
3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix
|
||||
indicates API token; otherwise config-file token.
|
||||
4. **Validation** — JWT signature check, API token hash lookup in storage, or
|
||||
config-token hmac comparison.
|
||||
indicates API token.
|
||||
4. **Validation** — JWT signature check or API token hash lookup in storage.
|
||||
5. **Scope check** — `required_scope(method, path)` determines the minimum
|
||||
scope; the request is rejected with 403 if the token lacks it.
|
||||
6. **Context propagation** — on success, `ctx_user_id` is set so structured
|
||||
@@ -1059,8 +1125,9 @@ 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** (14 tabs) for managing
|
||||
credentials, governance, MCP servers, and runtime settings through the browser.
|
||||
dashboard includes an **admin panel** (18 tabs) for managing
|
||||
credentials, governance, MCP servers, models, node metadata, 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).
|
||||
@@ -1201,31 +1268,31 @@ bell + status line to stderr to alert the user.
|
||||
### Cluster Console
|
||||
|
||||
```
|
||||
Monitoring (3 daemon threads) Control + Proxy (async Starlette)
|
||||
Monitoring (2 daemon threads) Control + Proxy (async Starlette)
|
||||
+------------------+ +----------------------------+
|
||||
| Event subscriber | | POST /v1/api/cluster/ |
|
||||
| SSE on | | workstreams/new |
|
||||
| /events/glob | | → POST to target server |
|
||||
| Node discovery | | POST /v1/api/cluster/ |
|
||||
| Service registry | | workstreams/new |
|
||||
| every 60 seconds | | → POST to target server |
|
||||
+------------------+ +----------------------------+
|
||||
| Node discovery | | GET /node/{node_id}/ |
|
||||
| Service registry | | → httpx.AsyncClient |
|
||||
| every 15 seconds | | proxy to server_url |
|
||||
+------------------+ | GET /node/{id}/v1/api/events |
|
||||
| Poll loop | | → SSE stream proxy |
|
||||
| GET /v1/api/dash | | POST /node/{id}/v1/api/send |
|
||||
| GET /health | | → forwarded to server |
|
||||
| ThreadPoolExec | +----------------------------+
|
||||
+------------------+
|
||||
| SSE manager | | GET /node/{node_id}/ |
|
||||
| asyncio loop | | → httpx.AsyncClient |
|
||||
| 1 task per node | | proxy to server_url |
|
||||
| /events/global | | GET /node/{id}/v1/api/events |
|
||||
| snapshot+deltas | | → SSE stream proxy |
|
||||
+------------------+ | POST /node/{id}/v1/api/send |
|
||||
| → forwarded to server |
|
||||
+----------------------------+
|
||||
```
|
||||
|
||||
The console HTTP layer is a Starlette/ASGI app served by uvicorn. The SSE
|
||||
endpoint uses `EventSourceResponse` with the same listener queue pattern as
|
||||
the main server. `ClusterCollector`'s background threads (event subscriber,
|
||||
node discovery, poll loop) use `ThreadPoolExecutor`
|
||||
for parallel HTTP polling. The poll loop diffs workstream IDs between poll
|
||||
cycles and fans out synthetic `ws_created`/`ws_closed` SSE events for any
|
||||
changes, ensuring browser clients stay in sync even when real-time cluster
|
||||
events are missed.
|
||||
the main server. `ClusterCollector` runs two daemon threads: a discovery loop
|
||||
that queries the service registry every 60 seconds, and an SSE manager that
|
||||
runs a single asyncio event loop multiplexing persistent SSE connections to
|
||||
all nodes via `GET /v1/api/events/global`. Each node delivers a full snapshot
|
||||
on connect followed by real-time delta events — state changes, health
|
||||
transitions, and aggregate metrics arrive sub-second instead of on a 15-second
|
||||
poll cycle.
|
||||
|
||||
The console has two write-path capabilities:
|
||||
|
||||
@@ -1295,9 +1362,10 @@ setup, auth headers, `_request()` (REST) and `_stream_sse()` (SSE). Sync
|
||||
clients delegate through `_SyncRunner` which maintains a persistent background
|
||||
event loop on a daemon thread.
|
||||
|
||||
**Event types**: 27 standalone dataclasses in `events.py` with a type-registry
|
||||
pattern matching `OutboundEvent.from_json()` from `mq/protocol.py`. Events are
|
||||
decoupled from server internals.
|
||||
**Event types**: 38 standalone dataclasses in `events.py` with a type-registry
|
||||
dispatch (`from_json()` on each event). Events are decoupled from server
|
||||
internals — the SDK parses SSE frames directly from the `/v1/api/events`
|
||||
streams.
|
||||
|
||||
**TypeScript SDK**: `sdk/typescript/` — separate npm package with the same API
|
||||
surface. Zero browser dependencies, SSE via `fetch` + `ReadableStream` parsing.
|
||||
@@ -1319,7 +1387,8 @@ with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
|
||||
> See also: [Channel Integrations guide](channels.md)
|
||||
|
||||
The `turnstone-channel` gateway connects external messaging platforms
|
||||
(Discord, Slack, Teams) to the turnstone cluster via HTTP. Each
|
||||
(Discord and Slack today, with an adapter protocol for future platforms) to
|
||||
the turnstone cluster via HTTP. Each
|
||||
platform adapter implements the `ChannelAdapter` protocol and translates
|
||||
between platform-native events and turnstone server API calls.
|
||||
|
||||
@@ -1332,7 +1401,7 @@ workstream is reactivated, the router uses atomic resume via the
|
||||
the old workstream's conversation during creation in a single HTTP
|
||||
request, eliminating ordering fragility.
|
||||
|
||||
Discord ships as the first adapter. See [channels.md](channels.md) for
|
||||
Discord and Slack adapters ship today. See [channels.md](channels.md) for
|
||||
setup instructions, configuration reference, and the adapter development
|
||||
guide.
|
||||
|
||||
@@ -1353,11 +1422,11 @@ retries up to 3 times with backoff, re-querying the service registry on
|
||||
each attempt. See [Notification Flow diagram](diagrams/png/17-notify-flow.png).
|
||||
|
||||
**Bidirectional replies:** When a user replies to a notification DM, the
|
||||
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
|
||||
channel adapter (Discord or Slack) looks up the originating `ws_id` from the
|
||||
tracked message ID, verifies the replying user matches the notification
|
||||
recipient, and routes the reply to the workstream via `router.send_message()`.
|
||||
The workstream's response is forwarded back to the DM via a temporary entry
|
||||
in `_notify_reply_channels`. On `TurnCompleteEvent`, the response message is
|
||||
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.
|
||||
@@ -1390,11 +1459,10 @@ 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.
|
||||
The console admin panel exposes these capabilities as 18 permission-gated
|
||||
tabs: Users, API Tokens, Channels, Schedules, Watches, Roles, Policies,
|
||||
Prompts, Judge, Skills, MCP Servers, Usage, Audit, Memories, Models, Nodes,
|
||||
Settings, and TLS.
|
||||
Both Python and TypeScript SDKs expose governance methods on the console
|
||||
client.
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:75c1832b6079e8628f4bbf4ce98d37880c4de133636b7555e3869990b046ddc6
|
||||
size 567704
|
||||
@@ -0,0 +1,237 @@
|
||||
# Bulk endpoint shape contract
|
||||
|
||||
Turnstone exposes several endpoints and tool calls that take multiple
|
||||
ids and return a per-id outcome. Over the last few phases two
|
||||
**distinct** response shapes have settled, one per semantic category.
|
||||
This doc codifies both so a future endpoint author can pick the right
|
||||
shape by semantics instead of by coin-flip.
|
||||
|
||||
Existing bulk endpoints at time of writing:
|
||||
|
||||
| Endpoint / tool | Category | Response shape |
|
||||
|---------------------------------------------------------|--------------------------|------------------------------------------|
|
||||
| `GET /v1/api/cluster/ws/live?ids=a,b,c` | bulk read | `{results, denied, truncated}` |
|
||||
| model tool `spawn_batch` | bulk create (per-item) | `{results, denied}` |
|
||||
| `POST /v1/api/coordinator/{ws_id}/stop_cascade` | cascade mutation | `{cancelled, failed, skipped}` |
|
||||
| `POST /v1/api/coordinator/{ws_id}/close_all_children` | cascade mutation | `{closed, failed, skipped}` |
|
||||
|
||||
---
|
||||
|
||||
## Why two shapes
|
||||
|
||||
The ask-to-outcome mapping is fundamentally different between the
|
||||
two categories, and a one-size-fits-all envelope ends up papering
|
||||
over distinctions the caller genuinely needs to branch on.
|
||||
|
||||
**Bulk read / bulk create-with-payload.** Each input id (or batch
|
||||
index) carries a *request-side* concept — "give me the live block
|
||||
for this ws_id" or "spawn a child with this spec" — and each
|
||||
successful output carries a *payload* — the live block, or the new
|
||||
workstream's identifying triple. The interesting distinction on
|
||||
failure is *ownership / validation* (caller can't see that id, spec
|
||||
was malformed) — independent of the storage state.
|
||||
|
||||
**Cascade mutation.** The action is uniform across every id (cancel
|
||||
this subtree, close this child). The interesting distinctions on
|
||||
outcome are *did it reach the terminal state?* (succeeded / already
|
||||
was there / the dispatch itself failed) — driven by the storage
|
||||
state plus transport reliability, not by the caller's input.
|
||||
|
||||
Trying to unify these forces either:
|
||||
|
||||
- a stateless `denied` bucket that has to carry "already gone"
|
||||
*and* "you don't have permission" *and* "transport failed" with a
|
||||
separate reason string — reviewers end up string-matching to branch.
|
||||
- or a per-item-payload map for cascade mutations where every
|
||||
successful value is the same sentinel — carrier with no payload.
|
||||
|
||||
So: two shapes, one per category. The rest of this doc spells out
|
||||
each.
|
||||
|
||||
---
|
||||
|
||||
## Shape A — bulk read / bulk create-with-payload
|
||||
|
||||
```json
|
||||
{
|
||||
"results": { "<key>": <value-or-null>, ... },
|
||||
"denied": [ "<key>", ... ],
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
**`results`** is a key-indexed map of the positive-path payload.
|
||||
The key is the input id for read endpoints (`cluster/ws/live` uses
|
||||
the ws_id), or the input-array index (stringified) for create
|
||||
endpoints that want ordering preserved (`spawn_batch` uses `"0"`,
|
||||
`"1"`, ...). The value is whatever the endpoint produces per
|
||||
success — a live block, a `{ws_id, name, node_id, status}` triple,
|
||||
etc. A `null` value (read endpoints only) means "the id existed and
|
||||
you own it, but the live block wasn't available" — distinct from
|
||||
"denied".
|
||||
|
||||
**`denied`** is the negative-path list. For read endpoints it's a
|
||||
flat list of ids (preserves input order so callers can re-zip
|
||||
against their input). For create endpoints with per-item payloads
|
||||
it's a list of `{idx, reason}` objects (`spawn_batch`'s validation
|
||||
and spawn-error rows; also the operator-reject surface when per-item
|
||||
selective-deny ships). Include every reason that's *not* the
|
||||
positive path — authz, ownership, validation, already-consumed,
|
||||
spawn failure — so callers don't branch on status codes.
|
||||
|
||||
**`truncated`** is a boolean set to `true` when the server's
|
||||
per-endpoint input cap was exceeded and the tail was dropped. The
|
||||
endpoint docs each spell out the cap (50 for `cluster/ws/live`).
|
||||
`spawn_batch` hard-errors on overflow instead of silently
|
||||
truncating — it omits the field entirely rather than carry a
|
||||
permanently-false flag.
|
||||
|
||||
### Example — `cluster/ws/live`
|
||||
|
||||
```http
|
||||
GET /v1/api/cluster/ws/live?ids=a1b2,c3d4,nonexistent,foreign HTTP/1.1
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"results": {
|
||||
"a1b2": {"state": "running", "tokens": 12843, "activity": "..."},
|
||||
"c3d4": null
|
||||
},
|
||||
"denied": ["nonexistent", "foreign"],
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
Callers that need ordered output zip their original id list against
|
||||
this map; ids in `denied` drop out of the zip cleanly. A live-block
|
||||
`null` doesn't route to `denied` — the row exists and the caller
|
||||
owns it; the node is just currently unreachable.
|
||||
|
||||
### Example — `spawn_batch`
|
||||
|
||||
```json
|
||||
{
|
||||
"results": {
|
||||
"0": {"ws_id": "d4e5f6...", "name": "csrf-audit", "node_id": "gpu-3", "status": 200},
|
||||
"2": {"ws_id": "f1a2b3...", "name": "xss-audit", "node_id": "gpu-1", "status": 200}
|
||||
},
|
||||
"denied": [
|
||||
{"idx": 1, "reason": "skill not found: nonexistent-skill"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Indexes are stringified to keep the envelope JSON-safe and
|
||||
consistently-typed across the read and create cases.
|
||||
|
||||
---
|
||||
|
||||
## Shape B — cascade mutation
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"<bucket>": [ "<ws_id>", ... ],
|
||||
"failed": [ "<ws_id>", ... ],
|
||||
"skipped": [ "<ws_id>", ... ]
|
||||
}
|
||||
```
|
||||
|
||||
Where `<bucket>` is the endpoint-specific name for "succeeded" —
|
||||
`cancelled` for `stop_cascade`, `closed` for `close_all_children`.
|
||||
The three buckets partition the input set exactly once:
|
||||
|
||||
| Bucket | Meaning |
|
||||
|---------------|-------------------------------------------------------------------------------|
|
||||
| `<bucket>` | Action dispatch accepted; target reached the intended terminal state. |
|
||||
| `failed` | Dispatch returned a non-404 error (transport issue, upstream 5xx, exception). |
|
||||
| `skipped` | Upstream 404 — stale registry entry, row already deleted, or peer gone. |
|
||||
|
||||
The split between `failed` and `skipped` is load-bearing. `failed`
|
||||
is actionable — the operator may want to retry, or the cascade may
|
||||
be partial. `skipped` is pre-resolved — the target is already in
|
||||
the terminal state the cascade was aiming at, so it's neither a
|
||||
win to report nor a fault to fix.
|
||||
|
||||
### Example — `stop_cascade`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"cancelled": ["child-1", "child-3"],
|
||||
"failed": [],
|
||||
"skipped": ["child-2"]
|
||||
}
|
||||
```
|
||||
|
||||
A subsequent retry would target only `failed` ids, not `skipped`
|
||||
ones — the latter are already done.
|
||||
|
||||
### Example — `close_all_children`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"closed": ["child-1", "child-3"],
|
||||
"failed": ["child-2"],
|
||||
"skipped": []
|
||||
}
|
||||
```
|
||||
|
||||
Same partition, different success-bucket name. When `coord_client`
|
||||
is unavailable (session loaded but no HTTP client attached — a
|
||||
construction bug) every id goes to `failed` so the operator notices
|
||||
rather than getting a silent all-skipped response.
|
||||
|
||||
---
|
||||
|
||||
## Guidance for future bulk endpoints
|
||||
|
||||
1. **Pick by semantics, not by "what shape is nearby."**
|
||||
- Mutation that's uniform across ids + terminal-state outcome? →
|
||||
**Shape B** (cascade mutation).
|
||||
- Read or create where the input id carries payload, or where the
|
||||
denial axis is independent of storage state? → **Shape A**
|
||||
(bulk read / bulk create-with-payload).
|
||||
|
||||
2. **Cap the input.** Both shapes assume a bounded input — the
|
||||
server rejects or silently truncates past the cap. Document the
|
||||
cap in the endpoint's OpenAPI description. Shape A uses
|
||||
`truncated: true` on quiet truncation; Shape B hard-errors on
|
||||
overflow.
|
||||
|
||||
3. **Match existing bucket names for the same semantic.** Use
|
||||
`failed` and `skipped` verbatim in Shape B — the per-endpoint
|
||||
success bucket is the only slot that varies. Use `results` and
|
||||
`denied` verbatim in Shape A; the per-endpoint `<key>` /
|
||||
`<value>` types vary.
|
||||
|
||||
4. **Audit the verbose shape.** Both endpoints emit a corresponding
|
||||
audit event with the full before/after bucket lists — the SSE
|
||||
stream and the in-process response give live feedback, but a
|
||||
postmortem operator will read the audit row. Use
|
||||
`_emit_coord_audit` (coordinator-scoped) or `record_audit`
|
||||
directly; don't inline.
|
||||
|
||||
5. **Don't mix shapes within one endpoint.** If a bulk endpoint
|
||||
wants both partial-success creation AND per-item failure reasons
|
||||
(like `spawn_batch` with its `{idx, reason}` denial rows), that's
|
||||
Shape A with a richer denial element — not a blend with Shape B.
|
||||
|
||||
---
|
||||
|
||||
## History
|
||||
|
||||
- **Phase 6** shipped `cluster/ws/live` as the first Shape A endpoint
|
||||
(`{results, denied, truncated}`).
|
||||
- **Phase 7** shipped `stop_cascade` as the first Shape B endpoint
|
||||
(`{cancelled, failed, skipped}`).
|
||||
- **Phase 8 PR A** shipped `spawn_batch` (Shape A, keyed by idx) and
|
||||
`close_all_children` (Shape B, twin of `stop_cascade`), which
|
||||
crystallised the two-shape-per-semantic-category policy codified
|
||||
here.
|
||||
|
||||
Before adding a third shape, read this doc and argue for why the
|
||||
new surface doesn't fit either A or B. Two idioms in the cluster
|
||||
API is a finite operator tax; three is one too many.
|
||||
+100
-27
@@ -7,31 +7,35 @@ platform-native events (messages, button clicks, slash commands) into
|
||||
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
|
||||
future Slack and Teams integrations.
|
||||
Discord and Slack adapters ship today. The adapter protocol is designed
|
||||
so new platforms can be added with only a new package under
|
||||
`turnstone/channels/<platform>/`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Discord Gateway
|
||||
|
|
||||
v
|
||||
turnstone-channel (Discord adapter)
|
||||
|
|
||||
v
|
||||
turnstone-server (direct HTTP)
|
||||
or
|
||||
turnstone-console (routing proxy, multi-node)
|
||||
Discord Gateway Slack (Socket Mode WebSocket)
|
||||
\ /
|
||||
v v
|
||||
turnstone-channel (one or more adapters)
|
||||
|
|
||||
v
|
||||
turnstone-server (direct HTTP)
|
||||
or
|
||||
turnstone-console (routing proxy, multi-node)
|
||||
```
|
||||
|
||||
A single `turnstone-channel` process can run multiple adapters
|
||||
simultaneously (e.g. Discord + Slack) — pass the tokens for each
|
||||
platform you want to enable.
|
||||
|
||||
Key components:
|
||||
|
||||
- **ChannelAdapter protocol** (`turnstone/channels/_protocol.py`) — generic
|
||||
interface for any messaging platform. Defines `start()`, `stop()`,
|
||||
`send()`, `send_notification()`, `edit_message()`,
|
||||
`send_approval_request()`, `send_plan_review()`, and `create_thread()`.
|
||||
`send()`, and `send_notification()`.
|
||||
- **ChannelRouter** (`turnstone/channels/_routing.py`) — maps
|
||||
channel/thread IDs to turnstone workstream IDs. Handles workstream
|
||||
creation via HTTP, stale route detection, and user identity resolution.
|
||||
@@ -120,6 +124,68 @@ An admin can also force-link or unlink users via the console admin panel
|
||||
|
||||
---
|
||||
|
||||
## Slack Setup
|
||||
|
||||
Slack uses **Socket Mode**, so no public URL or API Gateway is required — Slack
|
||||
connects outbound to the bot via a WebSocket. Install with:
|
||||
|
||||
```bash
|
||||
pip install 'turnstone[slack]'
|
||||
```
|
||||
|
||||
### 1. Create a Slack App
|
||||
|
||||
1. Go to https://api.slack.com/apps and click **Create New App**
|
||||
2. Under **Settings > Socket Mode**, enable Socket Mode. This generates an
|
||||
**App-Level Token** (prefix `xapp-`) — copy it.
|
||||
3. Under **OAuth & Permissions**, add these **Bot Token Scopes**:
|
||||
`chat:write`, `chat:write.public`, `channels:history`, `im:history`,
|
||||
`groups:history`, `mpim:history`, `reactions:write`, `commands`
|
||||
4. Under **Event Subscriptions** (Socket Mode delivers events), subscribe
|
||||
to bot events: `message.channels`, `message.im`, `message.groups`
|
||||
5. Under **Slash Commands**, create a command (default `/turnstone`)
|
||||
6. Install the app to your workspace to generate the **Bot User OAuth
|
||||
Token** (prefix `xoxb-`).
|
||||
|
||||
### 2. Configure Turnstone
|
||||
|
||||
**Environment variables** (recommended for Docker):
|
||||
|
||||
```bash
|
||||
TURNSTONE_SLACK_TOKEN=xoxb-... # Bot User OAuth Token
|
||||
TURNSTONE_SLACK_APP_TOKEN=xapp-... # App-Level Token (Socket Mode)
|
||||
TURNSTONE_SLACK_CHANNELS= # optional, comma-separated channel IDs
|
||||
TURNSTONE_SLACK_SLASH_COMMAND=/turnstone
|
||||
```
|
||||
|
||||
**CLI flags** (bare-metal):
|
||||
|
||||
```bash
|
||||
turnstone-channel \
|
||||
--slack-token "xoxb-..." \
|
||||
--slack-app-token "xapp-..." \
|
||||
--slack-slash-command /turnstone \
|
||||
--server-url http://localhost:8080
|
||||
```
|
||||
|
||||
The Slack and Discord adapters can be enabled together — pass tokens for
|
||||
both and the gateway hosts both adapters in one process.
|
||||
|
||||
### 3. Usage
|
||||
|
||||
- **DM the bot**: messages sent directly to the bot create a workstream
|
||||
scoped to that DM; the slash command is not required.
|
||||
- **Slash command**: `/turnstone <message>` in any channel the bot can
|
||||
see starts a per-user channel session.
|
||||
- Tool approvals render as Slack **Block Kit** buttons; only the user
|
||||
who owns the workstream can approve/reject.
|
||||
- Plan reviews render as a modal with approve / request-changes actions.
|
||||
- Notifications and reply routing work identically to Discord.
|
||||
- Session recovery: persisted channel routes are re-subscribed when the
|
||||
bot restarts, so existing Slack conversations keep flowing.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Conversations
|
||||
@@ -184,19 +250,25 @@ Plan review requests are displayed as a blue embed with:
|
||||
|
||||
| CLI Flag | Env Var | Default | Description |
|
||||
|----------|---------|---------|-------------|
|
||||
| `--discord-token` | `TURNSTONE_DISCORD_TOKEN` | — | Bot token (required to enable Discord) |
|
||||
| `--discord-token` | `TURNSTONE_DISCORD_TOKEN` | — | Discord 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 |
|
||||
| `--discord-channels` | — | empty (all) | Comma-separated Discord channel IDs to allow |
|
||||
| `--slack-token` | `TURNSTONE_SLACK_TOKEN` | — | Slack Bot User OAuth token (`xoxb-…`, required to enable Slack) |
|
||||
| `--slack-app-token` | `TURNSTONE_SLACK_APP_TOKEN` | — | Slack App-Level token (`xapp-…`, required with `--slack-token`) |
|
||||
| `--slack-channels` | `TURNSTONE_SLACK_CHANNELS` | empty (all) | Comma-separated Slack channel IDs to allow |
|
||||
| `--slack-slash-command` | `TURNSTONE_SLACK_SLASH_COMMAND` | `/turnstone` | Slash command name registered in the Slack app |
|
||||
| `--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 |
|
||||
| `--http-port` | `TURNSTONE_CHANNEL_PORT` | `8091` | HTTP server port |
|
||||
| `--auth-token` | `TURNSTONE_CHANNEL_AUTH_TOKEN` | — | Static auth token for `/v1/api/notify` (alternative to JWT) |
|
||||
| `--log-level` | `TURNSTONE_LOG_LEVEL` | `INFO` | Log level |
|
||||
| `--log-format` | `TURNSTONE_LOG_FORMAT` | `auto` | Log format (`auto`/`json`/`text`) |
|
||||
|
||||
At least one of `--discord-token` or `--slack-token` must be supplied.
|
||||
Passing both starts both adapters in the same process.
|
||||
|
||||
---
|
||||
|
||||
## User Identity
|
||||
@@ -250,8 +322,8 @@ waiting for them to check in.
|
||||
Two modes:
|
||||
|
||||
- **Username** — provide a turnstone `username`. The gateway resolves
|
||||
it via the `channel_users` table and sends to all linked channels
|
||||
(e.g. Discord + future Slack).
|
||||
it via the `channel_users` table and sends to every linked platform
|
||||
the user has (e.g. Discord + Slack).
|
||||
- **Direct** — provide `channel_type` + `channel_id` to target a
|
||||
specific platform channel or user DM.
|
||||
|
||||
@@ -321,11 +393,11 @@ The `services` table schema:
|
||||
### Security
|
||||
|
||||
- **Authentication** — the gateway's `POST /v1/api/notify` endpoint
|
||||
requires authentication. Configure either `TURNSTONE_JWT_SECRET`
|
||||
(the server mints JWTs with `aud: turnstone-channel` automatically)
|
||||
or a static token via `--auth-token`. If neither is set, the
|
||||
gateway fails closed and rejects all requests with 401. Server JWTs
|
||||
(`aud: turnstone-server`) are rejected.
|
||||
requires authentication. Configure `TURNSTONE_JWT_SECRET` so the
|
||||
server can mint JWTs with `aud: turnstone-channel` automatically.
|
||||
If the secret is not set, the gateway fails closed and rejects all
|
||||
requests with 401. Server JWTs (`aud: turnstone-server`) are
|
||||
rejected.
|
||||
- **Rate limit** — maximum 5 notifications per turn. The counter only
|
||||
increments on successful delivery, so failures don't consume the
|
||||
budget.
|
||||
@@ -352,10 +424,6 @@ class ChannelAdapter(Protocol):
|
||||
async def stop(self) -> None: ...
|
||||
async def send(self, channel_id: str, content: str) -> str: ...
|
||||
async def send_notification(self, channel_id: str, content: str, ws_id: str) -> str: ...
|
||||
async def edit_message(self, channel_id: str, message_id: str, content: str) -> None: ...
|
||||
async def send_approval_request(self, channel_id: str, ws_id: str, correlation_id: str, items: list[dict]) -> None: ...
|
||||
async def send_plan_review(self, channel_id: str, ws_id: str, correlation_id: str, content: str) -> None: ...
|
||||
async def create_thread(self, parent_channel_id: str, name: str, message_id: str = "") -> str: ...
|
||||
```
|
||||
|
||||
`send_notification()` is like `send()` but associates the outgoing
|
||||
@@ -363,6 +431,11 @@ message with a `ws_id` so that user replies can be routed back to the
|
||||
originating workstream. Adapters must track the mapping from outgoing
|
||||
message ID to `(ws_id, target_user_id)` and handle DM replies.
|
||||
|
||||
Platform-specific concerns — approval prompts, plan reviews, message
|
||||
edits, thread creation — live inside the adapter implementation and are
|
||||
not part of the protocol surface. Each adapter drives those via its
|
||||
own `_on_ws_event` dispatcher using SDK-native APIs.
|
||||
|
||||
To add a new platform:
|
||||
|
||||
1. Create `turnstone/channels/<platform>/` package
|
||||
|
||||
+23
-15
@@ -1,6 +1,6 @@
|
||||
# Cluster Dashboard (turnstone-console)
|
||||
|
||||
`turnstone-console` is a cluster management service that provides cluster-wide visibility and control across all turnstone nodes. It discovers nodes via the `services` database table, polls each node's HTTP API for workstream data, and receives real-time state changes via HTTP polling.
|
||||
`turnstone-console` is a cluster management service that provides cluster-wide visibility and control across all turnstone nodes. It discovers nodes via the `services` database table and subscribes to each node's SSE event stream for real-time workstream, health, and metric updates.
|
||||
|
||||
The console also supports **workstream creation** (dispatched via HTTP proxy to target nodes) and a **reverse proxy** that serves each node's server UI through the console port — so users only need network access to the console, not to individual server nodes.
|
||||
|
||||
@@ -21,7 +21,7 @@ turnstone-console ──────┤
|
||||
|
||||
Data flows in two directions:
|
||||
|
||||
- **Inbound (monitoring):** The console discovers nodes via the `services` database table (nodes register on startup and send periodic heartbeats). It periodically polls each node's `GET /v1/api/dashboard` for full workstream snapshots and `GET /health` for node health.
|
||||
- **Inbound (monitoring):** The console discovers nodes via the `services` database table (nodes register on startup and send periodic heartbeats). It opens a persistent SSE connection to each node's `GET /v1/api/events/global` endpoint, receiving a full snapshot on connect followed by real-time delta events (state changes, health transitions, aggregate metrics).
|
||||
- **Outbound (control):** The console proxies workstream creation requests to target nodes via HTTP.
|
||||
- **Proxy (pass-through):** The console reverse-proxies each node's server UI at `/node/{node_id}/`, forwarding HTTP and SSE traffic so the browser never contacts server nodes directly.
|
||||
|
||||
@@ -30,8 +30,7 @@ Data flows in two directions:
|
||||
| Source | Method | Direction | Data |
|
||||
|--------|--------|-----------|------|
|
||||
| `services` table | Database query | Read | Node discovery (node_id, server_url, started) |
|
||||
| Node HTTP API | `GET {server_url}/v1/api/dashboard` | Read | Full workstream list with tokens, context, activity |
|
||||
| Node HTTP API | `GET {server_url}/health` | Read | Node health status |
|
||||
| Node SSE | `GET {server_url}/v1/api/events/global` | Stream | Snapshot on connect, then real-time delta events (state, health, aggregate) |
|
||||
| Node HTTP API | `POST {server_url}/v1/api/workstreams/new` | Write | Workstream creation |
|
||||
| Node HTTP API | `GET/POST {server_url}/*` | Proxy | Server UI, API requests, SSE streams |
|
||||
|
||||
@@ -41,9 +40,9 @@ Data flows in two directions:
|
||||
|
||||
The collector (`turnstone/console/collector.py`) maintains an in-memory snapshot of all nodes and workstreams. Two daemon threads handle data acquisition:
|
||||
|
||||
1. **Node discovery** — queries the `services` database table every 15 seconds. Adds newly discovered nodes, removes expired ones (stale heartbeats), emits `node_joined` / `node_lost` events to SSE listeners.
|
||||
1. **Node discovery** — queries the `services` database table every 60 seconds. Adds newly discovered nodes, removes expired ones (stale heartbeats), emits `node_joined` / `node_lost` events to SSE listeners, and spawns/cancels SSE tasks for new/lost nodes.
|
||||
|
||||
2. **Poll loop** — fetches `GET /v1/api/dashboard` and `GET /health` from each known node every 10 seconds. Uses `ThreadPoolExecutor(max_workers=50)` for parallelism. Each poll replaces the node's workstream list with the authoritative server data.
|
||||
2. **SSE manager** — a single asyncio event loop on one thread multiplexes persistent SSE connections to all discovered nodes via `GET /v1/api/events/global`. Each connection receives a `node_snapshot` on connect (workstreams, health, aggregate) followed by real-time delta events (`ws_state`, `ws_created`, `ws_closed`, `ws_rename`, `health_changed`, `aggregate`). On disconnect, the node is marked unreachable and the connection is retried with exponential backoff (1s–30s). An `?expected_node_id=` query parameter provides identity verification against IP reuse (server returns 409 on mismatch).
|
||||
|
||||
A `get_snapshot()` method builds the full cluster state under a single lock acquisition — overview aggregates and per-node workstream lists in one atomic read. This is served both as a REST endpoint and as the initial SSE event on client connect.
|
||||
|
||||
@@ -54,7 +53,7 @@ All reads and writes to the node/workstream map are protected by a single `threa
|
||||
### Scale Considerations
|
||||
|
||||
- **50,000 workstreams** (1,000 nodes × 50 per node) at ~500 bytes each = ~25 MB in memory
|
||||
- **1,000 nodes** polled in parallel — fan-out concurrency is configurable via `cluster.node_fan_out_limit` (default 200), yielding 5 batches at ~100ms each = ~0.5 second poll cycle
|
||||
- **1,000 nodes** connected via persistent SSE — a single asyncio event loop multiplexes all connections with negligible overhead. Ensure `ulimit -n` >= 4096 for fd headroom
|
||||
- **Filtering and pagination** run in-memory on the full workstream list — sub-millisecond at this scale
|
||||
- **SSE fan-out** uses per-client queues (2,000 events) — backed-up clients get events dropped, not blocking
|
||||
- **Database** — for clusters sharing PostgreSQL, use [PgBouncer](pgbouncer.md) in transaction pooling mode
|
||||
@@ -383,6 +382,9 @@ Triggered by the "+ new" header button. A modal dialog with:
|
||||
- **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.
|
||||
- **Judge Model** — optional text input for the judge model alias (overrides the default judge model for this workstream).
|
||||
|
||||
Keyboard shortcuts: Ctrl+Shift+R (refresh title), Ctrl+Shift+E (edit title), Ctrl+Shift+F (fork), Ctrl+Shift+X (delete). Press ? for full shortcut help.
|
||||
|
||||
On submit, `POST /v1/api/cluster/workstreams/new` dispatches the creation request. A toast confirms success; the SSE stream delivers the `ws_created` event to update the dashboard.
|
||||
|
||||
@@ -394,10 +396,19 @@ The browser maintains a local `clusterState` object that mirrors the cluster sna
|
||||
|
||||
Accessed via the "admin" button in the header (visible when authenticated
|
||||
with `approve` scope). Provides user, API token, channel link, MCP server,
|
||||
and skill management with 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):
|
||||
and skill management with 18 tabs (Users, API Tokens, Channels, Schedules,
|
||||
Watches, Roles, Policies, Prompts, Judge, Skills, MCP Servers, Usage,
|
||||
Audit, Memories, Models, Nodes, Settings, TLS). See also
|
||||
[Governance](governance.md) for the Roles, Policies, Skills, Usage, and
|
||||
Audit tabs, and [Settings](settings.md) for the database-backed
|
||||
configuration editor.
|
||||
|
||||
The **Channels** tab links users to either a Discord or Slack account
|
||||
via a per-row channel-type selector. The **Models** tab is a CRUD
|
||||
editor for `model_definitions`, the **Nodes** tab edits per-node
|
||||
metadata, and the **TLS** tab manages CA and leaf certificates for the
|
||||
internal mTLS fabric. The **Settings** tab edits ConfigStore values
|
||||
live; edits apply without restart.
|
||||
|
||||
**Users tab:**
|
||||
|
||||
@@ -629,8 +640,6 @@ CLI flags for `turnstone-console`:
|
||||
|------|---------|-------------|
|
||||
| `--host` | `0.0.0.0` | Bind host |
|
||||
| `--port` | `8090` | HTTP port |
|
||||
| `--poll-interval` | `10` | Node polling interval (seconds) |
|
||||
| `--auth-token` | `$TURNSTONE_AUTH_TOKEN` | Bearer token for server node communication and proxy |
|
||||
| `--log-level` | `INFO` | Log level |
|
||||
|
||||
Config file (`~/.config/turnstone/config.toml`):
|
||||
@@ -640,7 +649,6 @@ Config file (`~/.config/turnstone/config.toml`):
|
||||
host = "0.0.0.0"
|
||||
port = 8090
|
||||
url = "http://localhost:8090" # used by CLI /cluster commands
|
||||
poll_interval = 10
|
||||
```
|
||||
|
||||
---
|
||||
@@ -652,7 +660,7 @@ poll_interval = 10
|
||||
turnstone-server --port 8080
|
||||
|
||||
# Start cluster console (one instance)
|
||||
turnstone-console --port 8090 --auth-token "$TURNSTONE_AUTH_TOKEN"
|
||||
turnstone-console --port 8090
|
||||
```
|
||||
|
||||
Open `http://localhost:8090` for the cluster dashboard. Create workstreams via the "+ new" button. Click any workstream to open the proxied server UI — no direct access to server ports required.
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
# Coordinator API tour
|
||||
|
||||
Turnstone's **coordinator workstream** is a session hosted on the
|
||||
console whose job is to orchestrate other workstreams. It runs an LLM
|
||||
that can spawn child workstreams on any node, watch their progress,
|
||||
wait for them to finish, steer them mid-flight, and tear them down.
|
||||
This doc walks the full lifecycle — one request, one response, and the
|
||||
relevant SSE events at each step.
|
||||
|
||||
Aimed at integrators driving a coordinator from a custom UI or SDK
|
||||
without reverse-engineering the built-in console page. The shapes
|
||||
here match the live OpenAPI spec served at `/openapi.json` and
|
||||
rendered at `/docs` on every `turnstone-console` process. Every
|
||||
step references the operation id from that spec so doc updates track
|
||||
schema changes.
|
||||
|
||||
> **Auth throughout.** Every endpoint below sits behind bearer-token
|
||||
> auth and the `admin.coordinator` permission. A session-scoped JWT
|
||||
> is minted per login (see [docs/oidc.md](oidc.md) / [docs/security.md](security.md));
|
||||
> a service token may call the read paths but destructive governance
|
||||
> paths (`/restrict`, `/stop_cascade`, `/close_all_children`) require
|
||||
> the explicit `admin.coordinator` grant — a service-token owner
|
||||
> match isn't enough.
|
||||
|
||||
---
|
||||
|
||||
## The 9 steps
|
||||
|
||||
| # | Action | Operation | Operation id |
|
||||
|---|------------------------------|-------------------------------------------------------------|-------------------------------------------------------------|
|
||||
| 1 | Create | `POST /v1/api/coordinator/new` | `v1_api_coordinator_new_post` |
|
||||
| 2 | Subscribe to events | `GET /v1/api/coordinator/{ws_id}/events` (SSE) | `v1_api_coordinator_{ws_id}_events_get` |
|
||||
| 3 | Send a user message | `POST /v1/api/coordinator/{ws_id}/send` | `v1_api_coordinator_{ws_id}_send_post` |
|
||||
| 4 | Inspect children | `GET /v1/api/coordinator/{ws_id}/children` | `v1_api_coordinator_{ws_id}_children_get` |
|
||||
| 5 | Inspect one workstream | `GET /v1/api/cluster/ws/{ws_id}/detail` | `v1_api_cluster_ws_{ws_id}_detail_get` |
|
||||
| 6 | Wait for fan-out | model-side tool `wait_for_workstream` | — (tool call, not HTTP) |
|
||||
| 7 | Govern | `POST /v1/api/coordinator/{ws_id}/trust` | `v1_api_coordinator_{ws_id}_trust_post` |
|
||||
| | | `POST /v1/api/coordinator/{ws_id}/restrict` | `v1_api_coordinator_{ws_id}_restrict_post` |
|
||||
| | | `POST /v1/api/coordinator/{ws_id}/stop_cascade` | `v1_api_coordinator_{ws_id}_stop_cascade_post` |
|
||||
| | | `POST /v1/api/coordinator/{ws_id}/close_all_children` | `v1_api_coordinator_{ws_id}_close_all_children_post` |
|
||||
| 8 | Approve / cancel | `POST /v1/api/coordinator/{ws_id}/approve` | `v1_api_coordinator_{ws_id}_approve_post` |
|
||||
| | | `POST /v1/api/coordinator/{ws_id}/cancel` | `v1_api_coordinator_{ws_id}_cancel_post` |
|
||||
| 9 | Close | `POST /v1/api/coordinator/{ws_id}/close` | `v1_api_coordinator_{ws_id}_close_post` |
|
||||
|
||||
---
|
||||
|
||||
## 1. Create a coordinator
|
||||
|
||||
```http
|
||||
POST /v1/api/coordinator/new
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <token>
|
||||
|
||||
{
|
||||
"name": "release-coord",
|
||||
"skill": "engineer-orchestrator",
|
||||
"initial_message": "audit /auth for CSRF handling across all active routes"
|
||||
}
|
||||
```
|
||||
|
||||
```http
|
||||
HTTP/1.1 201 Created
|
||||
Content-Type: application/json
|
||||
|
||||
{"ws_id": "a1b2c3d4e5f6...", "name": "release-coord"}
|
||||
```
|
||||
|
||||
All three body fields are optional — an empty body still creates a
|
||||
coordinator with an auto-generated name and no initial message.
|
||||
Returns **503** with a remediation message when the cluster isn't
|
||||
configured with a coordinator model; see
|
||||
[`coordinator.model_alias`](settings.md) to set one.
|
||||
|
||||
**SSE implication:** the `ws_created` event fires on the cluster-wide
|
||||
stream (`/v1/api/cluster/events`) once the row is committed. Per-ws
|
||||
subscribers (step 2) see the session warm up as token traffic starts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Subscribe to the per-coordinator event stream
|
||||
|
||||
```http
|
||||
GET /v1/api/coordinator/{ws_id}/events HTTP/1.1
|
||||
Accept: text/event-stream
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
One persistent SSE connection per browser tab / SDK caller — the
|
||||
console fans each event out to every listener queue (cap 500 events
|
||||
per queue, put_nowait drop on overflow). Events come in flat JSON
|
||||
with a `type` field. The recurring shapes a UI has to handle:
|
||||
|
||||
| `type` | Emitted when | Payload highlights |
|
||||
|---------------------|--------------------------------------------------------------------------------------------|--------------------|
|
||||
| `thinking_start` / `thinking_stop` | Model has entered / exited a reasoning block | — |
|
||||
| `reasoning` | Reasoning-token stream chunk (when the model exposes it) | `text` |
|
||||
| `content` | Assistant-content stream chunk | `text` |
|
||||
| `stream_end` | End of a single provider stream | — |
|
||||
| `tool_result` | A tool call completed (success or error) | `call_id`, `name`, `output`, `is_error?` |
|
||||
| `tool_output_chunk` | Streaming tool output (e.g. long bash command) | `call_id`, `chunk` |
|
||||
| `approve_request` | One or more tool calls need operator approval | `items: [{call_id, header, preview, func_name, approval_label, needs_approval}]` |
|
||||
| `approval_resolved` | Operator answered the approval prompt | `approved`, `feedback` |
|
||||
| `state_change` | Worker-thread state transition | `state` ∈ `running`, `thinking`, `attention`, `idle`, `error` |
|
||||
| `status` | Token usage + context-window snapshot (fires on every streaming tick) | `prompt_tokens`, `completion_tokens`, `total_tokens`, `context_window`, `pct`, `effort`, `cache_creation_tokens`, `cache_read_tokens` |
|
||||
| `rename` | Session's display name changed | `name` |
|
||||
| `intent_verdict` | Intent judge produced a verdict on a pending tool call | `risk_level`, `recommendation`, `reasons` |
|
||||
| `output_warning` | Output guard flagged a tool result | `call_id`, `risk_level`, `flags` |
|
||||
| `child_ws_created` | A direct child of this coord was just created (fan-out from the cluster bus) | `child_ws_id`, `node_id`, `name`, `parent_ws_id` (`ws_id` in the envelope is always the coord's own id) |
|
||||
| `child_ws_state` | A direct child transitioned state | `child_ws_id`, `state` |
|
||||
| `child_ws_closed` | A direct child closed | `child_ws_id` |
|
||||
| `child_ws_rename` | A direct child's name changed | `child_ws_id`, `name` |
|
||||
| `wait_started` / `wait_progress` / `wait_ended` | `wait_for_workstream` tool lifecycle (see §6) | `call_id`, `ws_ids`, `elapsed`, `results`, `complete` |
|
||||
| `batch_started` / `batch_ended` | `spawn_batch` / `close_all_children` tool lifecycle | `call_id`, `op`, `total`/`succeeded`/`denied`/`closed`/`failed`/`skipped` |
|
||||
| `info` / `error` | Operational messages | `message` |
|
||||
|
||||
**Reconnection contract:** a freshly-opened SSE connection receives
|
||||
the current snapshot of any pending tool approval (`approve_request`
|
||||
is re-sent if unresolved) and any in-flight `wait_*` / `batch_*`
|
||||
indicator — so a tab refresh mid-approval doesn't strand the
|
||||
operator.
|
||||
|
||||
---
|
||||
|
||||
## 3. Send the first user message
|
||||
|
||||
```http
|
||||
POST /v1/api/coordinator/{ws_id}/send
|
||||
Content-Type: application/json
|
||||
|
||||
{"message": "audit /auth for CSRF handling across all active routes"}
|
||||
```
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
The message is queued for the worker thread at its next tool-result
|
||||
seam (so you can send follow-ups mid-conversation without corrupting
|
||||
the in-progress turn). On the SSE stream you'll see `state_change`
|
||||
→ `thinking_start` → streaming `reasoning` / `content` / `tool_result`
|
||||
events, finishing with `state_change → idle` or an
|
||||
`approve_request` when the model invokes a gated tool.
|
||||
|
||||
---
|
||||
|
||||
## 4. Inspect direct children
|
||||
|
||||
```http
|
||||
GET /v1/api/coordinator/{ws_id}/children HTTP/1.1
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"ws_id": "d4e5f6...", "name": "csrf-audit", "state": "running", "node_id": "gpu-3"},
|
||||
{"ws_id": "e1f2a3...", "name": "xss-audit", "state": "idle", "node_id": "gpu-1"}
|
||||
],
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
The response key is `items`, not `children` — the endpoint shape
|
||||
follows the cluster-wide workstream-list idiom rather than the
|
||||
coordinator `list_workstreams` tool's (which uses `children`).
|
||||
Rows include every state stored for the parent (`running`, `idle`,
|
||||
`closed`, ...); the endpoint does not accept a state query param,
|
||||
so clients should inspect each row's `state` field and filter
|
||||
locally if they want to hide closed/deleted children. Nested
|
||||
coordinator rows are dropped server-side so only interactive
|
||||
descendants appear.
|
||||
|
||||
---
|
||||
|
||||
## 5. Inspect one workstream (storage + live block + tail)
|
||||
|
||||
```http
|
||||
GET /v1/api/cluster/ws/{ws_id}/detail?message_limit=20 HTTP/1.1
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"persisted": { "ws_id": "...", "state": "running", "parent_ws_id": "...", "kind": "interactive", ... },
|
||||
"live": { "state": "thinking", "tokens": 12843, "activity": "...", "pending_approval": null },
|
||||
"tail": [ {"role": "assistant", "content": "...", "tokens": 128}, ... ]
|
||||
}
|
||||
```
|
||||
|
||||
Works for any workstream the caller has `admin.cluster.inspect` on,
|
||||
not just children of a single coordinator — useful for a cluster
|
||||
admin panel watching multiple coordinators at once. `live` is
|
||||
`null` when the owning node is unreachable or has dropped the row
|
||||
from its dashboard cache; callers should degrade gracefully, not
|
||||
treat it as an error.
|
||||
|
||||
For fan-out views, prefer
|
||||
[`GET /v1/api/cluster/ws/live?ids=a,b,c`](bulk-endpoints.md) — it
|
||||
collapses N per-row round-trips into one, returning the live block
|
||||
for every id in a `{results, denied, truncated}` envelope.
|
||||
|
||||
---
|
||||
|
||||
## 6. Wait for fan-out (`wait_for_workstream`)
|
||||
|
||||
`wait_for_workstream` is a **model-side tool**, not an HTTP endpoint
|
||||
— the coordinator's LLM invokes it with a list of child ws_ids, the
|
||||
session's worker thread blocks inside the tool, and a sequence of
|
||||
`wait_started` / `wait_progress` / `wait_ended` SSE events is emitted
|
||||
for the UI to drive a "waiting on N children" indicator.
|
||||
|
||||

|
||||
|
||||
Key properties:
|
||||
|
||||
- **Caps** — up to 32 ws_ids per call, up to 600 seconds per call.
|
||||
A coordinator that needs to wait on more children re-invokes the
|
||||
tool with a fresh timeout.
|
||||
- **Modes** — `mode="any"` returns as soon as one child reaches a
|
||||
real terminal state (`idle` / `error` / `closed` / `deleted`);
|
||||
`mode="all"` waits for every polled child.
|
||||
- **Progress throttling** — the poll loop runs every 500 ms but the
|
||||
SSE emission is diff-on-state-change plus a 5-second heartbeat. A
|
||||
600 s wait generates O(dozens) of progress events, not 1200.
|
||||
- **Denied rows** — an id the caller doesn't own (cross-tenant) or a
|
||||
missing row is reported as a `denied` state in the results dict;
|
||||
`mode="any"` won't satisfy on a pure-denied list (the LLM should
|
||||
treat it as a config error, not a completion).
|
||||
|
||||
Prefer `wait_for_workstream` over polling `inspect_workstream` in a
|
||||
loop — a wait consumes one assistant turn regardless of how long the
|
||||
children take, whereas each `inspect_workstream` poll costs a full
|
||||
turn (plus judge, plus tokens). On a fan-out of 3+ children this
|
||||
rounds to a 10× token-efficiency win.
|
||||
|
||||
---
|
||||
|
||||
## 7. Governance — trust, restrict, stop_cascade, close_all_children
|
||||
|
||||
These four endpoints let an operator steer a live coordinator session
|
||||
mid-flight. All four emit an audit event tagged
|
||||
`coordinator.<action>` via the dedicated audit executor so a cascade
|
||||
burst can't starve audit writes.
|
||||
|
||||
### `POST /trust` — auto-approve own-subtree sends
|
||||
|
||||
```json
|
||||
POST /v1/api/coordinator/{ws_id}/trust
|
||||
{"send": true}
|
||||
```
|
||||
|
||||
Flips `trust_send=true` on the live session. Subsequent
|
||||
`send_to_workstream` calls that target a ws_id in the coordinator's
|
||||
own subtree skip the approval prompt; foreign ws_ids and other tool
|
||||
calls still go through the normal flow. Requires both
|
||||
`admin.coordinator` AND `coordinator.trust.send` permissions (the
|
||||
second grants a service token the opt-in it otherwise wouldn't get).
|
||||
|
||||
### `POST /restrict` — revoke tool access mid-session
|
||||
|
||||
```json
|
||||
POST /v1/api/coordinator/{ws_id}/restrict
|
||||
{"revoke": ["spawn_workstream", "delete_workstream"]}
|
||||
```
|
||||
|
||||
Unions the names into the session's revoked-tools set. Additive and
|
||||
idempotent — calling twice with overlapping lists converges to the
|
||||
union. Revocations don't survive a session close/reopen; operators
|
||||
opt in per session. Cap 256 tool names per request, 128 chars each.
|
||||
|
||||
### `POST /stop_cascade` — cancel the subtree
|
||||
|
||||
```json
|
||||
POST /v1/api/coordinator/{ws_id}/stop_cascade
|
||||
{}
|
||||
```
|
||||
|
||||
Cancels the coordinator's in-flight generation AND dispatches
|
||||
`cancel_workstream` through the routing proxy for every direct
|
||||
child in the in-memory registry. Returns:
|
||||
|
||||
```json
|
||||
{"status": "ok", "cancelled": ["child-1", "child-3"], "failed": [], "skipped": ["child-2"]}
|
||||
```
|
||||
|
||||
Response uses the [cascade-mutation bulk shape](bulk-endpoints.md):
|
||||
`cancelled` = accepted, `failed` = dispatch error worth retrying,
|
||||
`skipped` = upstream 404 (already gone — stale registry entry or
|
||||
the row was deleted between snapshot and dispatch). Grandchildren
|
||||
aren't touched directly; they sit behind their parent's cancel and
|
||||
propagate via the child's SSE stream.
|
||||
|
||||
### `POST /close_all_children` — soft-close the direct fan-out
|
||||
|
||||
```json
|
||||
POST /v1/api/coordinator/{ws_id}/close_all_children
|
||||
{"reason": "audit round complete"}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{"status": "ok", "closed": ["c-1", "c-2"], "failed": [], "skipped": []}
|
||||
```
|
||||
|
||||
Soft-close cascade bounded by the same semaphore as `stop_cascade`.
|
||||
The `reason` (up to 512 chars) propagates into each closed child's
|
||||
audit + `workstream_config` for postmortem. Unlike `stop_cascade`
|
||||
this does NOT recurse into grandchildren — the model-facing tool
|
||||
that pairs with this endpoint asks for a bounded teardown of the
|
||||
coordinator's own fan-out. For a full-subtree teardown, use
|
||||
`stop_cascade`.
|
||||
|
||||
See [bulk-endpoints.md](bulk-endpoints.md) for why both endpoints
|
||||
share the cascade-mutation shape and how it differs from the
|
||||
`spawn_batch` / `cluster/ws/live` shape.
|
||||
|
||||
---
|
||||
|
||||
## 8. Approve / cancel
|
||||
|
||||
The `approve` endpoint is what resolves an `approve_request` SSE
|
||||
event. The coordinator's worker thread is blocked inside
|
||||
`ui.approve_tools` waiting for this POST.
|
||||
|
||||
```json
|
||||
POST /v1/api/coordinator/{ws_id}/approve
|
||||
{"approved": true, "feedback": null, "always": false}
|
||||
{"approved": false, "feedback": "spawn count looks too high — try 3 not 10"}
|
||||
{"approved": true, "feedback": null, "always": true} // always-approve this tool name
|
||||
```
|
||||
|
||||
`cancel` drops the in-flight generation but leaves the coordinator
|
||||
idle and open for a fresh `send`:
|
||||
|
||||
```json
|
||||
POST /v1/api/coordinator/{ws_id}/cancel
|
||||
{}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Close
|
||||
|
||||
```json
|
||||
POST /v1/api/coordinator/{ws_id}/close
|
||||
{}
|
||||
```
|
||||
|
||||
Soft-closes the session — state persists, children keep running (use
|
||||
`close_all_children` or `stop_cascade` first to wind them down), the
|
||||
worker thread exits, SSE streams send a final `stream_end` and
|
||||
disconnect. The row is reopenable via
|
||||
`POST /v1/api/coordinator/{ws_id}/open` so long as it hasn't been
|
||||
deleted.
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [coordinator-skills.md](coordinator-skills.md) — writing a skill
|
||||
that runs on a coordinator session (orchestrator persona,
|
||||
workflow patterns, `SkillKind` classifier).
|
||||
- [bulk-endpoints.md](bulk-endpoints.md) — the two bulk-shape
|
||||
idioms (`{results, denied, truncated}` vs
|
||||
`{<bucket>, failed, skipped}`) used by `cluster/ws/live`,
|
||||
`spawn_batch`, `stop_cascade`, and `close_all_children`.
|
||||
- [architecture.md](architecture.md) — cluster-wide architecture
|
||||
including how coordinator sessions fit next to node-hosted
|
||||
interactive workstreams.
|
||||
- The live OpenAPI spec (`/openapi.json` on any console process)
|
||||
and Swagger UI (`/docs`) — authoritative schemas for every
|
||||
endpoint above.
|
||||
@@ -0,0 +1,321 @@
|
||||
# Writing a coordinator-specific skill
|
||||
|
||||
Skills are prompt-level personas that steer a Turnstone session
|
||||
toward a narrow task. Most skills target **interactive** sessions —
|
||||
the single-workstream "do this thing" surface where the model wields
|
||||
`bash`, `edit_file`, `web_fetch`, and the rest of the maker toolset.
|
||||
|
||||
A **coordinator skill** is different. It runs on a session whose job
|
||||
is to orchestrate other sessions. The toolset is smaller and
|
||||
narrower, the persona is an orchestrator instead of a maker, and the
|
||||
success metric is "did the plan resolve" instead of "did the code
|
||||
compile". This doc covers the differences a skill author has to
|
||||
care about.
|
||||
|
||||
---
|
||||
|
||||
## The two-surface model
|
||||
|
||||
A row in `prompt_templates` carries a `kind` column (see
|
||||
[`turnstone/core/skill_kind.py`](../turnstone/core/skill_kind.py);
|
||||
migration 044 added the column). Three values:
|
||||
|
||||
| `SkillKind` enum | Stored as | Visible in |
|
||||
|----------------------|-----------------------------|---------------------------------------------------------------------------|
|
||||
| `SkillKind.INTERACTIVE` | `"interactive"` | Only the interactive-session activation path. `list_skills` on a coord won't show it. |
|
||||
| `SkillKind.COORDINATOR` | `"coordinator"` | Only the coordinator's `list_skills` tool. Hidden from interactive activation pickers. |
|
||||
| `SkillKind.ANY` | `"any"` | Both surfaces. Default for legacy rows predating the classifier. |
|
||||
|
||||
The `kind` field is a `StrEnum` — drop-in ``str`` compatible — so
|
||||
DB rows, JSON payloads, and `==` comparisons all work without
|
||||
translation at the edge.
|
||||
|
||||
When a coordinator calls `list_skills`, the SQL filter narrows to
|
||||
`kind IN ('coordinator', 'any')`. When an interactive session picks
|
||||
a skill at activation, the filter narrows to
|
||||
`kind IN ('interactive', 'any')`. A skill author tags once at
|
||||
creation; the two surfaces stay partitioned without any
|
||||
per-call filtering on the LLM side.
|
||||
|
||||
**Tagging a new skill as coordinator-only** — set `kind` to
|
||||
`SkillKind.COORDINATOR` (or the literal string `"coordinator"`) when
|
||||
you POST to `/v1/api/admin/skills`. Existing rows default to
|
||||
`SkillKind.ANY`; bump them to `COORDINATOR` if you've rewritten the
|
||||
prompt around the orchestrator toolset.
|
||||
|
||||
---
|
||||
|
||||
## Tool surface differences
|
||||
|
||||
Coordinator sessions receive a **fixed** tool set, defined in
|
||||
`turnstone/core/tools.py` as `COORDINATOR_TOOLS`. Nothing a skill
|
||||
or MCP config can do adds to it. Current members:
|
||||
|
||||
| Tool | Category | Notes |
|
||||
|---------------------------|-----------------|---------------------------------------------------------------------|
|
||||
| `spawn_workstream` | delegate | Create one child. Requires approval. |
|
||||
| `spawn_batch` | delegate | Create up to 10 children in one approval. Partial-success shape. |
|
||||
| `inspect_workstream` | observe | Read state + tail of one child. Auto-approved (no mutation). |
|
||||
| `list_workstreams` | observe | List the direct children (same shape as `/children` endpoint). |
|
||||
| `wait_for_workstream` | block | Block until one/all listed children hit a terminal state. |
|
||||
| `send_to_workstream` | steer | Queue a follow-up message to a running child. |
|
||||
| `close_workstream` | wind-down | Soft-close one child. Requires approval. |
|
||||
| `close_all_children` | wind-down | Soft-close every direct child in one approval. Partial-success shape. |
|
||||
| `cancel_workstream` | wind-down | Drop the in-flight generation; leaves child idle for a fresh send. |
|
||||
| `delete_workstream` | wind-down | Hard-delete one child. Requires approval. |
|
||||
| `list_nodes` | discover | Enumerate live cluster nodes + capabilities. |
|
||||
| `list_skills` | discover | Coordinator-visible skills only (SkillKind filter above). |
|
||||
| `task_list` | plan | Orchestrator-only scratchpad. Children don't see it. |
|
||||
|
||||
Explicitly **not** in the coordinator set:
|
||||
|
||||
- `bash` / `edit_file` / `write_file` / `append_file` / `diff_file` — no local FS.
|
||||
- `read_file` / `search` — no local FS reads.
|
||||
- `web_fetch` / `web_search` — no direct web access.
|
||||
- `task_agent` / `plan_agent` — sub-agent tools are zeroed on coord sessions.
|
||||
- `memory` / `recall` / `notify` / `watch` / `read_resource` / `use_prompt` / `skill` — the orchestrator's "memory" is its children's outputs; these UX / persistence tools belong to interactive sessions.
|
||||
|
||||
If your skill needs a coordinator to "run a command" or "read a
|
||||
file", write the delegate pattern instead: spawn a child with an
|
||||
appropriate skill, `wait_for_workstream`, then `inspect_workstream`
|
||||
for the output. The coordinator stays the orchestrator.
|
||||
|
||||
---
|
||||
|
||||
## Persona differences
|
||||
|
||||
Interactive skills compose on top of `base_interactive.md` — a
|
||||
"maker" persona: get the work done, use the tools, edit the code,
|
||||
close the loop.
|
||||
|
||||
Coordinator skills compose on top of
|
||||
[`base_coordinator.md`](../turnstone/prompts/base_coordinator.md) —
|
||||
an "orchestrator" persona: decompose, delegate, monitor, synthesise.
|
||||
The base text is short but sets the tone every coordinator skill
|
||||
inherits:
|
||||
|
||||
> You are a coordinator on a small, focused infrastructure team.
|
||||
> Your role is to orchestrate work across the cluster... You do
|
||||
> not edit files, run shell commands, browse the web, or manipulate
|
||||
> the codebase directly. Children do that.
|
||||
|
||||
Write your skill's system prompt to *add* task-specific orchestration
|
||||
hints on top — don't re-explain the role, don't paste tool JSON,
|
||||
don't try to override the "no direct action" contract. Keep the
|
||||
additions to: (a) the specific kind of work this skill delegates;
|
||||
(b) the preferred skill tags for children; (c) the synthesis shape
|
||||
the skill should end on.
|
||||
|
||||
---
|
||||
|
||||
## `task_list` integration
|
||||
|
||||
`task_list` is the coordinator's scratchpad — a persisted, ordered
|
||||
list of rows with fields `{id, title, status, child_ws_id, created,
|
||||
updated}` that only this coordinator sees. Children don't see it;
|
||||
the user does via the sidebar. Five actions: `add`, `update`,
|
||||
`remove`, `reorder`, `list` (only `list` is auto-approved; the
|
||||
mutators go through the approval flow).
|
||||
|
||||
The input schema refers to rows by `task_id`; the persisted row
|
||||
object exposes the same id as `id`. The `child_ws_id` field is a
|
||||
free-form label the skill sets to link a task to a spawned
|
||||
workstream — it is NOT validated against the workstreams table, so
|
||||
a skill can set it to a placeholder before `spawn_workstream`
|
||||
returns or keep it pointing at a closed child for later audit.
|
||||
|
||||
A skill's initial prompt can seed the task list by calling
|
||||
`task_list(action="add", title=...)` as its very first tool calls —
|
||||
the user gets a visible plan before any child is spawned, and the
|
||||
coordinator's future self has something concrete to iterate on.
|
||||
Status transitions (`pending` → `in_progress` → `done` / `blocked`)
|
||||
are the skill's main feedback loop: mutate the task when the child
|
||||
covering it finishes, not when the child starts. Use
|
||||
`task_list(action="update", task_id=..., child_ws_id=<ws_id>)` to
|
||||
link a task to the child that owns it once spawn returns.
|
||||
|
||||
A final gotcha: parallel tool dispatch does NOT serialise reads
|
||||
after writes in the same batch. If a skill issues an `update` and
|
||||
a `list` in one parallel tool batch, the `list` response may reflect
|
||||
the pre-update state. Dispatch mutate and list serially (one
|
||||
tool_use turn each) when the list must observe the mutation.
|
||||
|
||||
Keep the tasks coarse-grained — one per child, roughly. A 20-task
|
||||
list for a 3-child fan-out is noise; a 1-task list for a 5-child
|
||||
fan-out loses the plan. The sidebar renders tasks as the operator's
|
||||
mental model of "what the coord thinks it's doing".
|
||||
|
||||
---
|
||||
|
||||
## Referencing children by `ws_id`
|
||||
|
||||
Every ws_id returned by `spawn_workstream` / `spawn_batch` is a
|
||||
**full 32-char hex string**. The skill's system prompt must not
|
||||
invent ws_ids — a model that hallucinates `"child-1"` or `"ws-abc"`
|
||||
hits the tenant guard in `CoordinatorClient._is_own_subtree`, which
|
||||
validates ws_id against `parent_ws_id=coord_ws_id` AND
|
||||
`user_id=owner` in storage. The rejection shape varies by tool:
|
||||
|
||||
- **Mutating ops** (`send_to_workstream`, `close_workstream`,
|
||||
`cancel_workstream`, `delete_workstream`) return
|
||||
`{"error": "workstream not in coordinator subtree: <ws_id>", "status": 404}`
|
||||
— the skill should treat this as a tool error, not an empty result.
|
||||
- **`inspect_workstream`** returns `{"error": "workstream not found: <ws_id>"}`
|
||||
(same shape as a genuinely missing row, so the guard can't be
|
||||
used as an existence oracle).
|
||||
- **`wait_for_workstream`** reports the offending id with
|
||||
`state="denied"` in its `results` dict; `mode="any"` won't
|
||||
satisfy on a pure-denied list, so a hallucinated id won't trick
|
||||
the wait into reporting "complete".
|
||||
|
||||
Pattern: capture each spawn result in the next tool call's input.
|
||||
The JSON tool-result carries `{"ws_id": "...", "name": "...",
|
||||
"node_id": "...", "status": 200}`; the model should extract the
|
||||
ws_id and pass it to `inspect_workstream` / `wait_for_workstream` /
|
||||
`send_to_workstream` / `close_workstream` verbatim.
|
||||
|
||||
A UI that wants human-readable identifiers should render the `name`
|
||||
field and keep the ws_id as the click-through key.
|
||||
|
||||
---
|
||||
|
||||
## `wait_for_workstream` vs `inspect_workstream`
|
||||
|
||||
Two distinct semantics, different cost profiles:
|
||||
|
||||
- **`wait_for_workstream(ws_ids=[...], timeout=60, mode="any")`** —
|
||||
blocks inside a single tool call until one (or all, for `mode="all"`)
|
||||
of the listed children reaches a terminal state (`idle`, `error`,
|
||||
`closed`, `deleted`). The worker thread blocks up to `timeout`
|
||||
seconds; the assistant turn remains a single round-trip regardless
|
||||
of how long the wait actually takes. Prefer this for "the plan
|
||||
needs child X to finish before the next step."
|
||||
- **`inspect_workstream(ws_id=...)`** — single read of the child's
|
||||
state + tail. Costs a full assistant turn (judge, tokens, stream).
|
||||
Prefer this for "what does the final message say?" after the child
|
||||
has already resolved (via `wait_for_workstream` or a known
|
||||
transition).
|
||||
|
||||
Rule of thumb: wait once for a fan-out, then inspect once per
|
||||
child for the content. A loop of inspect-every-few-seconds is a
|
||||
token-burning antipattern — on 3+ children it rounds to a 10×
|
||||
efficiency hit over a wait+inspect pair.
|
||||
|
||||
---
|
||||
|
||||
## Common coordinator patterns
|
||||
|
||||
Three patterns cover most coordinator skills. Pick the one that
|
||||
matches the task, or combine them deliberately.
|
||||
|
||||
### Pattern 1 — delegate-and-summarise
|
||||
|
||||
One specialist child, one focused brief, one synthesis message back
|
||||
to the user. Appropriate when the user's request is "run the thing
|
||||
and tell me what happened" and the work fits in one workstream.
|
||||
|
||||
```
|
||||
task_list(action='add', title='audit /auth for CSRF')
|
||||
spawn_workstream(skill='engineer', initial_message='audit /auth ...')
|
||||
wait_for_workstream(ws_ids=[<child>], timeout=300)
|
||||
inspect_workstream(ws_id=<child>)
|
||||
→ synthesise the final message into a user-facing response
|
||||
task_list(action='update', task_id='t_01', status='done')
|
||||
close_workstream(ws_id=<child>, reason='audit complete')
|
||||
```
|
||||
|
||||
### Pattern 2 — fan-out-and-synthesise
|
||||
|
||||
N children running in parallel, each with a distinct brief, all
|
||||
waited-on together, then synthesised. Appropriate when the user's
|
||||
request naturally decomposes into independent subtasks.
|
||||
|
||||
```
|
||||
task_list seeds:
|
||||
t_01 benchmark Anthropic 4.7 latency on summarisation
|
||||
t_02 benchmark OpenAI GPT-5.2 latency on summarisation
|
||||
t_03 benchmark Gemini 2.5 latency on summarisation
|
||||
spawn_batch(children=[...3 briefs...])
|
||||
wait_for_workstream(ws_ids=[c1, c2, c3], mode='all', timeout=600)
|
||||
inspect_workstream(ws_id=c1); ...(c2); ...(c3)
|
||||
→ synthesise head-to-head comparison
|
||||
task_list → all done
|
||||
close_all_children(reason='benchmark complete')
|
||||
```
|
||||
|
||||
Prefer `spawn_batch` over 3 individual `spawn_workstream` calls —
|
||||
one approval instead of three, one audit trail, deterministic
|
||||
sibling ordering. Pair with `wait_for_workstream(mode='all')` and
|
||||
`close_all_children(reason=...)` to wind the fan-out down in one
|
||||
approval each.
|
||||
|
||||
### Pattern 3 — plan-then-delegate
|
||||
|
||||
The coordinator first uses its own reasoning to carve the plan,
|
||||
records it in `task_list`, then spawns children that each own one
|
||||
task. Appropriate when the user's request is "figure out how to X"
|
||||
and the coordinator's planning step is itself valuable.
|
||||
|
||||
```
|
||||
→ coord reasons about the shape of the work
|
||||
task_list(action='add', title='...') × N # the plan, visible in the sidebar
|
||||
for task in tasks:
|
||||
spawn_workstream(skill=..., initial_message=task.brief)
|
||||
task_list(action='update', task_id=task.id, notes='ws=<child_ws_id>')
|
||||
wait_for_workstream(ws_ids=[...], mode='all', timeout=...)
|
||||
for child in children:
|
||||
inspect_workstream(ws_id=child)
|
||||
task_list(action='update', task_id=..., status='done', notes='result summary')
|
||||
→ synthesise
|
||||
```
|
||||
|
||||
The key distinction from Pattern 2: the plan is an artifact the user
|
||||
can see and interact with (via the sidebar). If the coordinator's
|
||||
reasoning-pass was wrong about the decomposition, the user can
|
||||
course-correct before any child runs.
|
||||
|
||||
---
|
||||
|
||||
## Testing a coordinator skill
|
||||
|
||||
Coordinator sessions are hosted on the console, not on a node.
|
||||
Integration tests that drive a real coord session live under
|
||||
`tests/test_coordinator_end_to_end.py` — they spin a console with
|
||||
an in-memory SQLite backend and a fake upstream node, then drive
|
||||
the session through its HTTP surface.
|
||||
|
||||
For a new coordinator skill:
|
||||
|
||||
1. Write the skill prompt as a string and pass it to the
|
||||
`coord_session` fixture's `skill=` kwarg (see
|
||||
`tests/test_coordinator_tools.py` for the pattern).
|
||||
2. Build a small fake cluster: one node + two children via
|
||||
`mgr.register_children(coord.id, ["child-1", "child-2"])`.
|
||||
3. Drive the session with seeded tool_call dicts matching the
|
||||
provider layer's shape. The unit-level tests in
|
||||
`tests/test_coordinator_tools.py` show the helper (`_tc(name,
|
||||
args, call_id)`).
|
||||
4. Assert the skill's decision shape — which tools fire in what
|
||||
order, what the task_list looks like at the end, which
|
||||
`_error` reasons appear on the denied-path.
|
||||
|
||||
A full end-to-end test isn't required for every skill; a
|
||||
prepare-step unit test that asserts "given this initial message, the
|
||||
first tool call is X with Y args" is usually sufficient to catch
|
||||
persona drift without a real LLM in the loop.
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [coordinator-api-tour.md](coordinator-api-tour.md) — the HTTP
|
||||
surface every coordinator skill indirectly drives.
|
||||
- [bulk-endpoints.md](bulk-endpoints.md) — the response shape
|
||||
`spawn_batch` and `close_all_children` use, so your skill can
|
||||
parse results / denied arrays correctly.
|
||||
- [governance.md](governance.md) — the broader governance surface
|
||||
(`/trust`, `/restrict`, `/stop_cascade`, role-based permissions)
|
||||
that wraps every coord session.
|
||||
- [settings.md](settings.md) — `coordinator.model_alias` and
|
||||
`coordinator.reasoning_effort` settings that gate which LLM runs
|
||||
the coordinator session at all.
|
||||
@@ -1,34 +1,27 @@
|
||||
# Consistent Hash Ring — Reference Design
|
||||
|
||||
**Status**: Reference (not currently in the hot path)
|
||||
**Date**: 2026-03-30
|
||||
**Status**: Reference — alternative routing strategy
|
||||
|
||||
## Overview
|
||||
Live routing uses **rendezvous (HRW) hashing** in
|
||||
`turnstone/core/rendezvous.py` and `turnstone/console/router.py`. This
|
||||
document captures a vnode-ring approach as a reference for future
|
||||
evaluation if the cluster outgrows rendezvous's O(N)-per-route
|
||||
characteristic.
|
||||
|
||||
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.
|
||||
The FNV-1a-32 hash function specified below is bit-identical to the
|
||||
hash used by the live rendezvous implementation; cross-language clients
|
||||
can rely on these test vectors.
|
||||
|
||||
## When to consider the ring approach
|
||||
## When the ring approach becomes interesting
|
||||
|
||||
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 vnode ring becomes preferable to rendezvous hashing when:
|
||||
|
||||
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)
|
||||
- Cluster size grows large (50+ nodes) and the per-route O(N) hash
|
||||
computation becomes visible against downstream HTTP cost.
|
||||
- Decentralised routing is needed (each node computes the ring locally,
|
||||
no central console required).
|
||||
- A precomputed flat-array lookup is desired so the routing hot path
|
||||
avoids hashing entirely.
|
||||
|
||||
## Algorithm
|
||||
|
||||
@@ -133,16 +126,17 @@ class HashRing:
|
||||
# Precompute all 65536 bucket assignments
|
||||
```
|
||||
|
||||
## Comparison with current approach
|
||||
## Comparison with rendezvous (HRW) hashing
|
||||
|
||||
| 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 |
|
||||
| Aspect | Rendezvous (live) | Consistent hash ring (this doc) |
|
||||
|--------|-------------------|---------------------------------|
|
||||
| Per-route cost | O(N) hash computes | O(log V) bisect against precomputed array |
|
||||
| Seeding | None — pure function | Build vnode array on every membership change |
|
||||
| Node addition | Pure function moves ~1/N keys | Ring moves ~1/N buckets |
|
||||
| Node removal | Surviving nodes' keys unchanged | Surviving nodes' buckets unchanged |
|
||||
| Decentralised | Yes — pure function over services | Yes — each node computes locally |
|
||||
| Persistent state | None | None on the hot path; precomputed array in memory |
|
||||
| Complexity | ~20 LOC | Virtual-node construction + bisect |
|
||||
|
||||
## Test vectors
|
||||
|
||||
|
||||
@@ -19,13 +19,14 @@ package "Entry Points" <<Rectangle>> {
|
||||
component [cli.py\nturnstone] as cli <<entry>>
|
||||
component [server.py\nturnstone-server] as server <<entry>>
|
||||
component [eval.py\nturnstone-eval] as eval <<entry>>
|
||||
component [chat.py\n(re-exports)] as chat <<entry>>
|
||||
component [admin.py\nturnstone-admin] as admin <<entry>>
|
||||
component [bootstrap.py\nturnstone-bootstrap] as bootstrap <<entry>>
|
||||
}
|
||||
|
||||
' Core engine
|
||||
package "turnstone/core/" <<Rectangle>> {
|
||||
component [session.py\nChatSession, SessionUI] as session <<core>>
|
||||
component [providers/\nLLMProvider, OpenAI, Anthropic] as providers <<core>>
|
||||
component [providers/\nLLMProvider, OpenAI, Anthropic, Google] as providers <<core>>
|
||||
component [workstream.py\nWorkstreamManager] as workstream <<core>>
|
||||
component [tools.py\nTool loader] as tools <<core>>
|
||||
component [memory.py\nPersistence facade] as memory <<core>>
|
||||
@@ -48,7 +49,8 @@ package "turnstone/core/" <<Rectangle>> {
|
||||
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>>
|
||||
component [slack/bot.py\nSlackBot (Socket Mode)] as slackbot <<channel>>
|
||||
component [cli.py\nturnstone-channel] as gateway <<channel>>
|
||||
}
|
||||
|
||||
' Console
|
||||
@@ -112,7 +114,8 @@ eval --> memory
|
||||
eval --> config
|
||||
eval --> tools
|
||||
|
||||
chat --> session
|
||||
admin --> auth
|
||||
bootstrap --> providers
|
||||
|
||||
' Core internal deps
|
||||
session --> providers
|
||||
@@ -135,8 +138,10 @@ tools --> schemas
|
||||
|
||||
' Channel dependencies
|
||||
gateway --> discordbot
|
||||
gateway --> slackbot
|
||||
gateway --> router
|
||||
discordbot --> sdkserver : HTTP + SSE
|
||||
slackbot --> sdkserver : HTTP + SSE
|
||||
router --> storage : channel_routes
|
||||
|
||||
' Console dependencies
|
||||
|
||||
@@ -103,6 +103,18 @@ class "AnthropicProvider" as AnthropicProv {
|
||||
core/providers/_anthropic.py
|
||||
}
|
||||
|
||||
class "GoogleProvider" as GoogleProv {
|
||||
+ provider_name: str
|
||||
+ get_capabilities(model) -> ModelCapabilities
|
||||
--
|
||||
Extends OpenAIChatCompletionsProvider
|
||||
for Gemini /v1beta/openai/ endpoint.
|
||||
Single default ModelCapabilities
|
||||
(2M context, 65K output).
|
||||
--
|
||||
core/providers/_google.py
|
||||
}
|
||||
|
||||
' ModelCapabilities
|
||||
class "ModelCapabilities" as ModelCaps <<frozen>> {
|
||||
+ context_window: int
|
||||
@@ -283,7 +295,7 @@ class "ModelRegistry" as ModelReg {
|
||||
--
|
||||
Thread-safe lazy client + provider
|
||||
creation. Loaded by load_model_registry()
|
||||
from CLI args + [models.*] config.
|
||||
from DB + [models.*] config + CLI args.
|
||||
--
|
||||
core/model_registry.py
|
||||
}
|
||||
@@ -294,6 +306,9 @@ class "ModelConfig" as ModelCfg <<frozen>> {
|
||||
+ base_url: str
|
||||
+ model: str
|
||||
+ context_window: int
|
||||
+ temperature: float | None
|
||||
+ max_tokens: int | None
|
||||
+ reasoning_effort: str | None
|
||||
}
|
||||
|
||||
' Circuit breaker state
|
||||
@@ -360,6 +375,7 @@ SessionUI <|.. NullUI
|
||||
|
||||
LLMProvider <|.. OpenAIProv
|
||||
LLMProvider <|.. AnthropicProv
|
||||
OpenAIProv <|-- GoogleProv
|
||||
|
||||
ChatSession --> SessionUI : uses
|
||||
ChatSession --> LLMProvider : delegates LLM calls
|
||||
|
||||
@@ -10,55 +10,76 @@ participant "ClusterCollector" as CC
|
||||
participant "Node-A\n(server)" as NodeA
|
||||
participant "Node-B\n(server)" as NodeB
|
||||
|
||||
== Thread 1: HTTP Polling (every 10s) ==
|
||||
== Thread 1: Node Discovery (every 60s) ==
|
||||
|
||||
CC -> CC : Iterate registered nodes
|
||||
CC -> CC : list_services("server",\nmax_age_seconds=120)
|
||||
activate CC #C8E6C9
|
||||
|
||||
CC -> NodeA : GET /v1/api/dashboard
|
||||
activate NodeA
|
||||
NodeA --> CC : {workstreams: [...],\naggregate: {total_tokens, ...}}
|
||||
deactivate NodeA
|
||||
|
||||
CC -> NodeA : GET /health
|
||||
activate NodeA
|
||||
NodeA --> CC : {status:"ok", version:"0.9.2",\nmodel:"...", workstreams:{...}}
|
||||
deactivate NodeA
|
||||
|
||||
CC -> NodeB : GET /v1/api/dashboard
|
||||
activate NodeB
|
||||
NodeB --> CC : {workstreams: [...],\naggregate: {total_tokens, ...}}
|
||||
deactivate NodeB
|
||||
|
||||
CC -> CC : Diff old vs new workstream IDs
|
||||
CC -> CC : Replace NodeSnapshot["nodeA"]\n.workstreams, .health, .aggregate
|
||||
CC -> CC : _fanout(ws_created) for\nnewly appeared workstreams
|
||||
CC -> CC : _fanout(ws_closed) for\nremoved workstreams
|
||||
|
||||
note right of CC
|
||||
Poll-diff fanout ensures
|
||||
browser SSE clients learn
|
||||
about workstreams that
|
||||
appeared between polls.
|
||||
end note
|
||||
CC -> CC : New node? → spawn SSE task\nLost node? → cancel SSE task
|
||||
CC -> CC : _fanout(node_joined)\n_fanout(node_lost)
|
||||
|
||||
deactivate CC
|
||||
|
||||
== Thread 2: SSE Manager (asyncio event loop) ==
|
||||
|
||||
note over CC
|
||||
Single asyncio event loop multiplexes
|
||||
one persistent SSE connection per node.
|
||||
Scales to 1000+ nodes.
|
||||
end note
|
||||
|
||||
CC -> NodeA : GET /v1/api/events/global\n?expected_node_id=nodeA
|
||||
activate NodeA
|
||||
activate CC #BBDEFB
|
||||
|
||||
NodeA --> CC : data: {"type":"node_snapshot",\n"node_id":"nodeA",\n"workstreams":[...],\n"health":{...},\n"aggregate":{...}}
|
||||
|
||||
note right of CC
|
||||
Snapshot populates NodeSnapshot
|
||||
in-memory state. Reconciles
|
||||
against stale data (emits
|
||||
ws_created/ws_closed diffs).
|
||||
end note
|
||||
|
||||
loop real-time delta events
|
||||
NodeA --> CC : data: {"type":"ws_state",\n"ws_id":"ws1","state":"running"}
|
||||
CC -> CC : Update NodeSnapshot\n_fanout(cluster_state)
|
||||
end
|
||||
|
||||
alt health transition
|
||||
NodeA --> CC : data: {"type":"health_changed",\n"circuit_state":"open"}
|
||||
CC -> CC : Update node.health
|
||||
end
|
||||
|
||||
alt periodic aggregate (every 10s)
|
||||
NodeA --> CC : data: {"type":"aggregate",\n"total_tokens":50000}
|
||||
CC -> CC : Update node.aggregate
|
||||
end
|
||||
|
||||
deactivate CC
|
||||
deactivate NodeA
|
||||
|
||||
alt SSE disconnect
|
||||
CC -> CC : Mark node unreachable\nReconnect with backoff\n(1s → 30s cap)
|
||||
end
|
||||
|
||||
alt identity mismatch (409 or snapshot node_id differs)
|
||||
CC -> CC : Mark node unreachable\nStop reconnecting to this URL
|
||||
end
|
||||
|
||||
== Browser SSE Stream ==
|
||||
|
||||
Browser -> Server : GET /v1/api/cluster/events
|
||||
activate Server
|
||||
|
||||
Server -> CC : get_snapshot()
|
||||
Server -> CC : get_snapshot_and_register(queue)
|
||||
note right : Atomic: snapshot + listener\nregistration under both locks\n→ no event gap
|
||||
CC --> Server : ClusterSnapshot\n(full current state)
|
||||
|
||||
Server -> CC : register_listener(queue)
|
||||
note right : Per-client queue.Queue(maxsize=2000)\nSSE via EventSourceResponse + run_in_executor()
|
||||
|
||||
Server -> Browser : data: {"type":"snapshot",...}\n(full state as first SSE event)
|
||||
|
||||
loop continuous (incremental updates)
|
||||
CC -> Server : event via listener queue\n(from polling thread)
|
||||
CC -> Server : event via listener queue\n(from SSE manager thread)
|
||||
Server -> Browser : data: {"type":"cluster_state",...}\n\n
|
||||
end
|
||||
|
||||
@@ -81,7 +102,7 @@ Server --> Browser : JSON response
|
||||
|
||||
Browser -> Server : GET /v1/api/cluster/overview
|
||||
Server -> CC : get_overview()
|
||||
CC --> Server : {nodes: 2, workstreams: 12,\nstates: {running:5, ...},\naggregate: {total_tokens: 50000},\nversion_drift: false, versions: ["0.9.2"]}
|
||||
CC --> Server : {nodes: 2, workstreams: 12,\nstates: {running:5, ...},\naggregate: {total_tokens: 50000},\nversion_drift: false, versions: ["0.9.7"]}
|
||||
Server --> Browser : JSON response
|
||||
|
||||
Browser -> Server : GET /v1/api/cluster/nodes?sort=activity
|
||||
|
||||
@@ -23,7 +23,7 @@ interface "StorageBackend" as SB <<protocol>> {
|
||||
+resolve_workstream(alias_or_id) → str | None
|
||||
+delete_workstream(ws_id) → bool
|
||||
+prune_workstreams(retention_days) → (int, int)
|
||||
+list_workstreams(node_id, limit) → list
|
||||
+list_workstreams(node_id, limit, *, parent_ws_id, kind, user_id) → list
|
||||
+save_workstream_config(ws_id, config)
|
||||
+load_workstream_config(ws_id) → dict
|
||||
+kv_get(key) → str | None
|
||||
|
||||
@@ -20,11 +20,14 @@ class "Discord" as Discord <<platform>> {
|
||||
asyncio event loop
|
||||
}
|
||||
|
||||
class "Slack (future)" as Slack <<platform>> {
|
||||
Socket Mode / Events API
|
||||
class "Slack" as Slack <<platform>> {
|
||||
Socket Mode WebSocket
|
||||
Block Kit messages
|
||||
Slash command (default /turnstone)
|
||||
DM + channel events
|
||||
--
|
||||
Planned integration
|
||||
slack-bolt (Python)
|
||||
asyncio event loop
|
||||
}
|
||||
|
||||
class "Teams (future)" as Teams <<platform>> {
|
||||
@@ -38,7 +41,7 @@ class "Teams (future)" as Teams <<platform>> {
|
||||
class "turnstone-channel" as ChannelService <<service>> {
|
||||
entry point: turnstone-channel
|
||||
--
|
||||
One process per platform
|
||||
One process — hosts one or more adapters
|
||||
asyncio event loop
|
||||
Structured logging (structlog)
|
||||
--log-level, --log-format
|
||||
@@ -47,6 +50,19 @@ class "turnstone-channel" as ChannelService <<service>> {
|
||||
GET /health
|
||||
}
|
||||
|
||||
class "SlackBot" as SlackBot <<service>> {
|
||||
+on_message(event)
|
||||
+on_action(action) (Block Kit buttons)
|
||||
+send(channel_id, content)
|
||||
+send_notification(channel_id, content, ws_id)
|
||||
+run(bot_token, app_token)
|
||||
--
|
||||
slack-bolt AsyncApp
|
||||
Socket Mode client
|
||||
Per-user channel sessions via slash command
|
||||
DM routing without slash command
|
||||
}
|
||||
|
||||
class "DiscordBot" as Bot <<service>> {
|
||||
+on_message(msg)
|
||||
+on_interaction(interaction)
|
||||
@@ -138,10 +154,15 @@ Server --> Bot : SSE event stream
|
||||
|
||||
Bot --> Discord : reply / embed\nbutton callback
|
||||
|
||||
Slack .[hidden]. Discord
|
||||
Slack --> SlackBot : socket-mode\nevents
|
||||
SlackBot --> Router : on_message / on_action
|
||||
SlackBot --> Server : POST /v1/api/send\nGET /v1/api/events?ws_id=
|
||||
SlackBot --> Slack : post / update\nBlock Kit button callbacks
|
||||
|
||||
Teams .[hidden]. Slack
|
||||
|
||||
ChannelService --> Bot : creates + runs
|
||||
ChannelService --> SlackBot : creates + runs
|
||||
ChannelService --> Router : creates
|
||||
ChannelService --> SVC : register / heartbeat /\nderegister
|
||||
|
||||
|
||||
@@ -152,10 +152,34 @@ MCPMgr -> MCPSrv : prompts/get
|
||||
MCPSrv --> MCPMgr : GetPromptResult
|
||||
MCPMgr --> Session : messages [{role, content}]
|
||||
|
||||
== Resilience: Circuit Breaker & Stream Safety ==
|
||||
|
||||
note over MCPMgr
|
||||
**Per-server circuit breaker**
|
||||
CLOSED --(3 failures)--> OPEN
|
||||
OPEN --(cooldown expires)--> half-open probe
|
||||
Probe success --> CLOSED (trip_count decays by 1)
|
||||
Probe failure --> OPEN (cooldown doubles, max 5 min)
|
||||
|
||||
McpError (protocol) does NOT trip breaker.
|
||||
BrokenPipeError / EOFError evicts dead session.
|
||||
All sync methods cancel orphaned futures on timeout.
|
||||
Transport streams pre-closed before stack teardown
|
||||
to avoid anyio cancel-scope CPU busy-loop (SDK #2147).
|
||||
end note
|
||||
|
||||
Session -> MCPMgr : call_tool_sync()
|
||||
MCPMgr -> MCPMgr : _cb_gate(server)\n[reject if circuit open]
|
||||
MCPMgr -> MCPMgr : _cb_auto_reconnect()\n[if session gone + cooldown expired]
|
||||
MCPMgr -> MCPSrv : tools/call
|
||||
MCPSrv --> MCPMgr : result or error
|
||||
MCPMgr -> MCPMgr : _cb_record_success()\nor _cb_record_failure()
|
||||
|
||||
== Three-Tier Refresh ==
|
||||
|
||||
group Push Notifications
|
||||
group Push Notifications (debounced 5s per server)
|
||||
MCPSrv -> MCPMgr : ToolListChangedNotification
|
||||
MCPMgr -> MCPMgr : debounce check\n(skip if < 5s since last)
|
||||
MCPMgr -> MCPMgr : _refresh_server_tools()
|
||||
|
||||
MCPSrv -> MCPMgr : ResourceListChangedNotification
|
||||
@@ -172,6 +196,9 @@ group Periodic Polling (default 4h)
|
||||
Only polls capabilities
|
||||
without push support.
|
||||
Staggered per-server.
|
||||
Disconnected servers get
|
||||
reconnect attempts with
|
||||
exponential backoff (60s-1h).
|
||||
end note
|
||||
end
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ note over Session, Judge
|
||||
**Storage:**
|
||||
intent_verdicts table (migration 012), output_assessments table
|
||||
(migration 022). Both queryable via admin API endpoints
|
||||
(requires admin.judge permission). Skills store scan_status,
|
||||
(requires admin.judge permission). Skills store risk_level,
|
||||
scan_report, scan_version for install-time risk assessment.
|
||||
end note
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
@startuml
|
||||
title Turnstone - coordinator wait_for_workstream lifecycle
|
||||
|
||||
skinparam sequenceArrowThickness 1.5
|
||||
skinparam noteBackgroundColor #FDF6E3
|
||||
|
||||
participant "Coordinator\nLLM" as LLM
|
||||
participant "ChatSession\n(worker thread)" as CS
|
||||
participant "CoordinatorClient" as CC
|
||||
participant "SessionUI\n(SSE fanout)" as UI
|
||||
participant "Console routing\nproxy" as RP
|
||||
database "Storage\n(workstreams row)" as DB
|
||||
participant "Child\nnode" as NODE
|
||||
|
||||
== Spawn ==
|
||||
|
||||
LLM -> CS : tool_call spawn_workstream(...)
|
||||
activate CS
|
||||
CS -> CC : spawn(initial_message=...,\nparent_ws_id=coord, user_id=...)
|
||||
CC -> RP : POST /v1/api/route/workstreams/new
|
||||
RP -> NODE : dispatch (rendezvous)
|
||||
NODE -> DB : insert workstreams row\nstate='running'
|
||||
RP --> CC : {ws_id, node_id, name, status: 200}
|
||||
CC --> CS : {ws_id, ...}
|
||||
CS -> UI : on_tool_result\n("spawn_workstream", ws_id)
|
||||
deactivate CS
|
||||
|
||||
note right of LLM
|
||||
Model now knows the child ws_id.
|
||||
It can inspect / send / wait, and
|
||||
the parent registry tracks it.
|
||||
end note
|
||||
|
||||
== Wait (blocking) ==
|
||||
|
||||
LLM -> CS : tool_call wait_for_workstream\n(ws_ids=[child], mode="any", timeout=60)
|
||||
activate CS
|
||||
CS -> CS : _prepare_wait_for_workstream\n(validate ws_ids, timeout, mode)
|
||||
CS -> UI : emit wait_started\n{call_id, ws_ids, mode, timeout}
|
||||
|
||||
CS -> CC : wait_for_workstream(ws_ids, timeout,\nmode, progress_callback)
|
||||
activate CC
|
||||
|
||||
loop every 500ms up to timeout
|
||||
CC -> DB : read workstreams row(s)
|
||||
DB --> CC : {state, updated, tokens, ...}
|
||||
alt state in {idle, error, closed, deleted}
|
||||
note over CC
|
||||
real-terminal state ->
|
||||
completion condition met
|
||||
end note
|
||||
else still running / thinking / attention
|
||||
CC -> CS : progress_callback(snap)\n(diff-on-change or 5s heartbeat)
|
||||
CS -> UI : emit wait_progress\n{call_id, elapsed, results?}
|
||||
end
|
||||
end
|
||||
|
||||
CC --> CS : {complete, elapsed,\nresults: {ws_id: snap}}
|
||||
deactivate CC
|
||||
|
||||
CS -> UI : emit wait_ended\n{call_id, complete, elapsed, results}
|
||||
CS -> UI : on_tool_result\n("wait_for_workstream",\n"complete after Ns (R/N resolved)")
|
||||
CS --> LLM : tool_result (full results dict)
|
||||
deactivate CS
|
||||
|
||||
note left of UI
|
||||
Sidebar "waiting on N children" indicator
|
||||
keys on call_id - started / progress / ended
|
||||
scope to a single wait invocation so
|
||||
nested waits render independent badges.
|
||||
end note
|
||||
|
||||
== After wait: inspect + close ==
|
||||
|
||||
LLM -> CS : tool_call inspect_workstream(ws_id=child)
|
||||
CS -> CC : inspect(ws_id)
|
||||
CC -> DB : read row + tail
|
||||
CC --> CS : {state, messages, tokens, ...}
|
||||
CS --> LLM : tool_result (serialised)
|
||||
|
||||
LLM -> CS : tool_call close_workstream\n(ws_id=child, reason="...")
|
||||
CS -> CC : close_workstream(ws_id, reason)
|
||||
CC -> RP : POST /v1/api/route/workstreams/close
|
||||
RP -> NODE : dispatch
|
||||
NODE -> DB : state='closed',\nclose_reason='...'
|
||||
RP --> CC : {status: 200}
|
||||
CC --> CS : {closed: true, status: 200, reason: ...}
|
||||
CS --> LLM : tool_result
|
||||
@enduml
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:75da80e6bd205e45b9fe48aa2f87197110c908f44e1d98c52eee69f956274711
|
||||
size 400402
|
||||
oid sha256:a3b5c59403a6febd81667fc8fd2a7d22bc59da6130eba0dea5449c42668d0ede
|
||||
size 387044
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6471e611beebf647f3a191eb16588571a404cc52a43067883a2b6f06dd936376
|
||||
size 594676
|
||||
oid sha256:474b900448ec04d1117b48a2b55614524721b2f04ac4bda66170bd0a06aae0f2
|
||||
size 624573
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:14824b81fa87f9a29e9b182b54132d3e438dd83f880b20b111b4bf51bc4b39d1
|
||||
size 317947
|
||||
oid sha256:040f7d9ec7d676da40b9487e0825caf2c1574cbdd9f16d0998d90e0c2e4f8861
|
||||
size 360309
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8a5957b71822656098cbe779ca619b7e56e9b11b0f1c4eac4fc880ed62b71a2a
|
||||
size 358670
|
||||
oid sha256:ae4f79fb22600106f8cb0af4ba5586bb26ea5d57e27ef382fdc59b6549fdbd21
|
||||
size 415473
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a6b7769aa7e732ffbeb1eb7f5b65273a135fb3a78d9802ec36d3b92801c34f6b
|
||||
size 427745
|
||||
oid sha256:7623df33be9baf7647ca1c2450640df57e1cd73e8be1f8168aae16e546ad683c
|
||||
size 459941
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:aa12d81dc578f7e65bf4df3152b3de1736289c422f83d0b0cd32107726722357
|
||||
size 172028
|
||||
+21
-8
@@ -22,7 +22,7 @@ Console dashboard: http://localhost:8090
|
||||
|---------|------|---------|-------------|
|
||||
| `server` | 8080 | default | Web UI + chat workstreams + LLM |
|
||||
| `console` | 8090 | default | Cluster dashboard |
|
||||
| `channel` | — | production | Channel gateway (Discord, Slack, etc.) |
|
||||
| `channel` | — | production | Channel gateway (Discord and/or Slack adapters) |
|
||||
| `server-1`…`server-10` | — | cluster | 10-node server fleet (PostgreSQL required) |
|
||||
|
||||
## Profiles
|
||||
@@ -69,26 +69,29 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CONSOLE_PORT` | `8090` | Host port mapping |
|
||||
| `CONSOLE_POLL_INTERVAL` | `10` | Node polling interval (seconds) |
|
||||
|
||||
### Auth
|
||||
|
||||
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_AUTH_ENABLED` | — | Set to `1` to require authentication |
|
||||
| `TURNSTONE_AUTH_TOKEN` | — | Config-file token for server/console (backward compat, works alongside JWT) |
|
||||
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required when using user identity / JWT auth) |
|
||||
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required) |
|
||||
|
||||
### Database
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_DB_BACKEND` | `sqlite` | Storage backend: `sqlite` or `postgresql` |
|
||||
| `TURNSTONE_DB_URL` | — | Database URL (e.g. `postgresql://user:pass@db:5432/turnstone`). For SQLite, defaults to `/data/.turnstone.db` |
|
||||
| `TURNSTONE_DB_URL` | — | Database URL (e.g. `postgresql+psycopg://user:pass@postgres: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) |
|
||||
| `POSTGRES_USER` | `turnstone` | PostgreSQL container username (used in default `TURNSTONE_DB_URL` for cluster/channel) |
|
||||
| `POSTGRES_PASSWORD` | — | PostgreSQL container password (required for production and cluster profiles) |
|
||||
|
||||
The database stores workstream history, user accounts, and API tokens. When using JWT auth, a database backend is required for user storage.
|
||||
|
||||
> **Upgrading from <1.3.0a4:** Earlier versions used `DB_BACKEND` and `DATABASE_URL` in `.env`, which `compose.yaml` mapped to the `TURNSTONE_`-prefixed names internally. These short aliases have been removed. Rename `DB_BACKEND` → `TURNSTONE_DB_BACKEND` and `DATABASE_URL` → `TURNSTONE_DB_URL` in your `.env` file.
|
||||
|
||||
> **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:
|
||||
@@ -105,8 +108,16 @@ 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) |
|
||||
| `TURNSTONE_SLACK_TOKEN` | — | Slack Bot User OAuth token `xoxb-…` (required to enable Slack adapter) |
|
||||
| `TURNSTONE_SLACK_APP_TOKEN` | — | Slack App-Level token `xapp-…` (required with `TURNSTONE_SLACK_TOKEN`) |
|
||||
| `TURNSTONE_SLACK_CHANNELS` | — | Comma-separated Slack channel IDs to allow (empty = all) |
|
||||
| `TURNSTONE_SLACK_SLASH_COMMAND` | `/turnstone` | Slash command registered in the Slack app |
|
||||
|
||||
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.
|
||||
The channel service runs in the `production` profile. When
|
||||
`TURNSTONE_DISCORD_TOKEN` or the Slack pair is set the gateway starts the
|
||||
corresponding adapter; both can run in one process. See
|
||||
[Channel Integrations](channels.md) for platform app setup and user
|
||||
account linking.
|
||||
|
||||
## Scaling
|
||||
|
||||
@@ -138,7 +149,9 @@ docker compose build
|
||||
docker compose build --no-cache
|
||||
```
|
||||
|
||||
All entry points are installed in a single image: `turnstone-server`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`, `turnstone-eval`.
|
||||
All entry points are installed in a single image: `turnstone`,
|
||||
`turnstone-server`, `turnstone-console`, `turnstone-channel`,
|
||||
`turnstone-admin`, `turnstone-eval`, and `turnstone-bootstrap`.
|
||||
|
||||
## Cleanup
|
||||
|
||||
|
||||
+12
-6
@@ -62,14 +62,14 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
|
||||
- **Default skills**: All `is_default=true` skills auto-apply to new
|
||||
workstreams, concatenated in alphabetical order by name. Use name prefixes
|
||||
(e.g. `01-safety`, `02-style`) to control ordering.
|
||||
- **Explicit selection**: `--template <name>` CLI flag, `template` field on
|
||||
- **Explicit selection**: `--skill <name>` CLI flag, `skill` 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.
|
||||
- **Runtime switching**: `/skill <name>` to switch, `/skill clear` to revert
|
||||
to defaults, `/skill` to show current. Persisted across resume.
|
||||
- **Model-driven loading**: The `skill` built-in tool lets the model
|
||||
discover and activate skills mid-conversation. `search` action finds skills
|
||||
by query (auto-approved); `load` action activates by name (requires user
|
||||
@@ -91,7 +91,7 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
|
||||
time. The scanner evaluates four risk axes: content risk (command execution,
|
||||
data exfiltration), supply chain risk (pipe-to-shell, transitive installs),
|
||||
vulnerability risk (prompt injection, insecure credentials), and declared
|
||||
capability risk (from `allowed-tools` in SKILL.md). Results populate the `scan_status`
|
||||
capability risk (from `allowed-tools` in SKILL.md). Results populate the `risk_level`
|
||||
(safe/low/medium/high/critical) and `scan_report` (JSON breakdown) columns.
|
||||
These fields are system-managed and cannot be overwritten via the admin API.
|
||||
- **Discovery**: External skills can be discovered and installed from registries:
|
||||
@@ -186,15 +186,21 @@ Full OpenAPI spec at `/openapi.json` and Swagger UI at `/docs`.
|
||||
|
||||
## Admin Console UI
|
||||
|
||||
6 new tabs added to the admin panel (11 total):
|
||||
Governance-related tabs within the 18-tab admin panel:
|
||||
|
||||
- **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
|
||||
- **Prompts** — Prompt-policy editor (heuristics for admin guardrails)
|
||||
- **Skills** — CRUD skills with wide modal, textarea editor; Discover pill for
|
||||
installing from skills.sh / GitHub; per-row scan badges (safe/low/med/high/critical)
|
||||
- **Judge** — Intent validation configuration and verdict history
|
||||
- **Usage** — Summary readouts + CSS bar chart, time range + group-by selectors
|
||||
- **Audit** — Filterable log with relative timestamps, load-more pagination
|
||||
|
||||
Tabs are permission-gated: hidden if the user lacks the required permission.
|
||||
See [docs/console.md](console.md) for the full tab list and
|
||||
[docs/settings.md](settings.md) for the Settings tab that edits live
|
||||
ConfigStore values.
|
||||
|
||||
## SDK
|
||||
|
||||
|
||||
+16
-4
@@ -41,6 +41,7 @@ confidence_threshold = 0.7 # reserved for v2 smart approvals (not used in v1)
|
||||
max_context_ratio = 0.5 # max % of judge context window for history
|
||||
timeout = 60.0 # seconds (generous for local models)
|
||||
read_only_tools = true # judge can use read_file/list_directory
|
||||
cancel_on_approval = false # stop judging remaining tool calls once user decides
|
||||
```
|
||||
|
||||
All fields are optional. The judge is enabled by default; use `enabled = false`
|
||||
@@ -72,6 +73,17 @@ CLI flags override `config.toml` values.
|
||||
- **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.
|
||||
- **Google models**: The judge supports `google` as a provider. Note that
|
||||
read-only tools are disabled for Google models (the Gemini API requires
|
||||
`thought_signature` in tool call round-trips which the judge's normalized
|
||||
format does not preserve).
|
||||
|
||||
The judge creates a fresh HTTP client for each evaluation run and closes it
|
||||
when done, avoiding stale connection issues across runs.
|
||||
|
||||
If the LLM judge fails or returns no verdict, a fallback verdict with tier
|
||||
`llm_fallback` is delivered via the callback, ensuring the UI always receives
|
||||
a result.
|
||||
|
||||
---
|
||||
|
||||
@@ -303,7 +315,7 @@ four independent risk axes:
|
||||
`Bash(*)` (unrestricted shell) is high risk. `Bash(git:*)` is low.
|
||||
Read-only tools are safe.
|
||||
|
||||
Results are stored in `scan_status` (tier: safe/low/medium/high/critical) and
|
||||
Results are stored in `risk_level` (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.
|
||||
|
||||
@@ -388,11 +400,11 @@ 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
|
||||
When a skill with `risk_level` of `high` or `critical` is loaded into a
|
||||
session, a warning is emitted via `on_info`:
|
||||
|
||||
```
|
||||
⚠ Skill 'my-skill' has scan status: high.
|
||||
⚠ Skill 'my-skill' has risk level: high.
|
||||
Review scan report in admin panel before enabling in production.
|
||||
```
|
||||
|
||||
@@ -409,7 +421,7 @@ All three evaluation systems persist their assessments for future calibration:
|
||||
|-------|--------|-------------|
|
||||
| `intent_verdicts` | Intent judge (heuristic + LLM) | `func_name`, `risk_level`, `confidence`, `user_decision` |
|
||||
| `output_assessments` | Output guard | `func_name`, `risk_level`, `flags`, `redacted` |
|
||||
| `prompt_templates` | Skill scanner | `scan_status`, `scan_report`, `scan_version` |
|
||||
| `prompt_templates` | Skill scanner | `risk_level`, `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:
|
||||
|
||||
@@ -146,7 +146,7 @@ with TurnstoneConsole("http://localhost:8081", token="...") as client:
|
||||
### TypeScript
|
||||
|
||||
```typescript
|
||||
import { TurnstoneConsole } from "@anthropic/turnstone-sdk";
|
||||
import { TurnstoneConsole } from "@turnstone/sdk";
|
||||
|
||||
const client = new TurnstoneConsole({
|
||||
baseUrl: "http://localhost:8081",
|
||||
|
||||
+5
-5
@@ -38,7 +38,7 @@ are set.
|
||||
| `TURNSTONE_OIDC_PROVIDER_NAME` | No | `SSO` | Display name for the login button (e.g. "Google", "Okta") |
|
||||
| `TURNSTONE_OIDC_ROLE_CLAIM` | No | — | ID token claim containing role/group values (see [Role Mapping](#role-mapping)) |
|
||||
| `TURNSTONE_OIDC_ROLE_MAP` | No | — | Mapping from claim values to Turnstone role IDs (see [Role Mapping](#role-mapping)) |
|
||||
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens and config-file tokens still work. |
|
||||
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens continue to work. |
|
||||
| `TURNSTONE_OIDC_REDIRECT_BASE` | No | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Recommended when running behind a reverse proxy. When unset, derived from the request Host header. |
|
||||
|
||||
OIDC is enabled when all three required fields (issuer, client ID, client
|
||||
@@ -246,10 +246,10 @@ password) before OIDC is enabled. The setup wizard always works
|
||||
regardless of this setting because it is only available when zero users
|
||||
exist in the database.
|
||||
|
||||
API token login (`POST /v1/api/auth/login` with a `ts_` token) and
|
||||
config-file tokens (`Authorization: Bearer tok_xxx`) continue to work
|
||||
regardless of this setting. OIDC-only mode affects password-based
|
||||
authentication only.
|
||||
API token login (`POST /v1/api/auth/login` with a `ts_` token)
|
||||
continues to work regardless of this setting. JWTs and API tokens are
|
||||
the supported authentication methods. OIDC-only mode affects
|
||||
password-based authentication only.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+16
-14
@@ -40,18 +40,20 @@ Add PgBouncer between turnstone services and PostgreSQL:
|
||||
```yaml
|
||||
services:
|
||||
pgbouncer:
|
||||
image: bitnami/pgbouncer:latest
|
||||
image: edoburu/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"
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: ${POSTGRES_DB:-turnstone}
|
||||
DB_USER: ${POSTGRES_USER:-turnstone}
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD:?}
|
||||
LISTEN_PORT: "6432"
|
||||
AUTH_TYPE: ${POSTGRES_AUTH_TYPE:-scram-sha-256}
|
||||
POOL_MODE: transaction
|
||||
DEFAULT_POOL_SIZE: "40"
|
||||
MAX_CLIENT_CONN: "5000"
|
||||
MAX_DB_CONNECTIONS: "80"
|
||||
SERVER_IDLE_TIMEOUT: "300"
|
||||
ports:
|
||||
- "6432:6432"
|
||||
networks:
|
||||
@@ -67,7 +69,7 @@ services:
|
||||
```
|
||||
|
||||
Then point turnstone services at PgBouncer instead of PostgreSQL
|
||||
directly by changing the `DATABASE_URL` (or `TURNSTONE_DB_URL`):
|
||||
directly by changing `TURNSTONE_DB_URL`:
|
||||
|
||||
```bash
|
||||
# Before (direct)
|
||||
@@ -82,7 +84,7 @@ 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).
|
||||
[edoburu/pgbouncer](https://github.com/edoburu/docker-pgbouncer/tree/master/examples/kubernetes).
|
||||
|
||||
In `values.yaml`, point the database at PgBouncer:
|
||||
|
||||
@@ -106,7 +108,7 @@ pgbouncer:
|
||||
maxClientConn: 5000
|
||||
maxDbConnections: 80
|
||||
```
|
||||
|
||||
:
|
||||
---
|
||||
|
||||
## Configuration reference
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Release Process
|
||||
|
||||
Turnstone ships several parallel release tracks from a single PyPI package.
|
||||
|
||||
## Release Tracks
|
||||
|
||||
| Track | Versions | Branch | Docker tags | PyPI install |
|
||||
|-------|----------|--------|-------------|--------------|
|
||||
| **Legacy 1.0** | `1.0.x` | `stable/1.0` | `:1.0.x`, `:1.0` | `pip install 'turnstone==1.0.*'` |
|
||||
| **Stable 1.3** | `1.3.x` | `stable/1.3` | `:1.3.x`, `:1.3` | `pip install 'turnstone==1.3.*'` |
|
||||
| **Stable 1.4** | `1.4.x` | `stable/1.4` | `:1.4.x`, `:1.4`, `:stable`, `:latest` | `pip install turnstone` |
|
||||
| **Experimental** | `1.5.0aN` | `main` | `:1.5.0aN`, `:experimental` | `pip install turnstone --pre` |
|
||||
|
||||
- **Stable** tracks receive bugfixes only. The most-recent stable minor
|
||||
owns the `:stable` / `:latest` Docker tags and the default PyPI
|
||||
install.
|
||||
- **Experimental** (always on `main`) receives new features. May be
|
||||
rough around the edges.
|
||||
- When experimental matures, it is promoted to a new stable minor via
|
||||
a `stable/X.Y` branch; older stable branches continue to receive
|
||||
security fixes until explicitly retired.
|
||||
|
||||
## Version Scheme
|
||||
|
||||
[PEP 440](https://peps.python.org/pep-0440/) pre-release suffixes on a single package:
|
||||
|
||||
- `1.0.0` — stable release
|
||||
- `1.1.0a1` — alpha (experimental)
|
||||
- `1.1.0b1` — beta (experimental, more stable)
|
||||
- `1.1.0rc1` — release candidate (experimental, nearly stable)
|
||||
- `1.1.0` — promoted to stable
|
||||
|
||||
## Releasing an Experimental Version (from main)
|
||||
|
||||
```bash
|
||||
scripts/release.sh 1.5.0a2 --push
|
||||
```
|
||||
|
||||
This bumps `pyproject.toml` + `turnstone/__init__.py`, regenerates `uv.lock`, commits, tags `v1.5.0a2`, and pushes. CI runs, then publish + Docker workflows fire automatically.
|
||||
|
||||
## Releasing a Stable Patch (from stable/X.Y)
|
||||
|
||||
```bash
|
||||
git checkout stable/1.4
|
||||
git cherry-pick <commit-hash> # bugfix from main
|
||||
scripts/release.sh 1.4.1 --push
|
||||
```
|
||||
|
||||
## Promoting Experimental to Stable
|
||||
|
||||
When `main` is ready for a stable release:
|
||||
|
||||
```bash
|
||||
# 1. Tag the stable release on main
|
||||
scripts/release.sh 1.5.0 --push
|
||||
|
||||
# 2. Create the stable maintenance branch from that tag
|
||||
git branch stable/1.5 v1.5.0
|
||||
git push origin stable/1.5
|
||||
|
||||
# 3. Start the next experimental cycle on main
|
||||
scripts/release.sh 1.6.0a1 --push
|
||||
```
|
||||
|
||||
The previous stable branch (`stable/1.4`) continues to receive
|
||||
security-only patches; older tracks (`stable/1.0`, `stable/1.3`) are
|
||||
retired when they fall out of support.
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
All releases are gated on CI success:
|
||||
|
||||
1. `git push` with `v*` tag triggers **CI** (lint, typecheck, test, test-postgres, lock-check, security audit)
|
||||
2. On CI success, **Publish to PyPI** fires via `workflow_run`
|
||||
3. On CI success, **Publish Docker Image** fires via `workflow_run`
|
||||
|
||||
Pre-release tags (`a`, `b`, `rc` suffixes) produce:
|
||||
- PyPI: pre-release version (not installed by default)
|
||||
- GitHub Release: marked as pre-release
|
||||
- Docker: `:experimental` alias + exact version tag
|
||||
|
||||
Stable tags produce:
|
||||
- PyPI: stable version (default `pip install`)
|
||||
- GitHub Release: full release
|
||||
- Docker: `:stable`, `:latest`, `:X.Y`, `:X.Y.Z` tags
|
||||
|
||||
## Dependency Updates
|
||||
|
||||
Renovate targets `main` (experimental) only. Stable branches receive manual dependency updates via cherry-pick when security-relevant.
|
||||
+38
-4
@@ -69,8 +69,12 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|
||||
|----------|--------|---------|
|
||||
| **Workstreams** | `list_workstreams()` | `ListWorkstreamsResponse` |
|
||||
| | `dashboard()` | `DashboardResponse` |
|
||||
| | `create_workstream(*, name, model, auto_approve, skill)` | `CreateWorkstreamResponse` |
|
||||
| | `create_workstream(*, name, model, auto_approve, skill, initial_message, attachments)` | `CreateWorkstreamResponse` |
|
||||
| | `close_workstream(ws_id)` | `StatusResponse` |
|
||||
| **Attachments** | `upload_attachment(ws_id, filename, data, *, mime_type=...)` | `UploadAttachmentResponse` |
|
||||
| | `list_attachments(ws_id)` | `ListAttachmentsResponse` |
|
||||
| | `get_attachment_content(ws_id, attachment_id)` | `bytes` |
|
||||
| | `delete_attachment(ws_id, attachment_id)` | `StatusResponse` |
|
||||
| **Chat** | `send(message, ws_id)` | `SendResponse` |
|
||||
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
|
||||
| | `plan_feedback(*, ws_id, feedback)` | `StatusResponse` |
|
||||
@@ -171,6 +175,36 @@ result.ok # True if no errors and not timed out
|
||||
result.timed_out # True if timeout expired
|
||||
```
|
||||
|
||||
### Attachments
|
||||
|
||||
Upload files to a workstream and attach them to the next user turn:
|
||||
|
||||
```python
|
||||
# Upload separately, then send a message — attachments auto-attach
|
||||
with open("screenshot.png", "rb") as f:
|
||||
att = client.upload_attachment(ws.ws_id, "screenshot.png",
|
||||
f.read(),
|
||||
mime_type="image/png")
|
||||
client.send("What's wrong in this screenshot?", ws.ws_id)
|
||||
|
||||
# Or attach at workstream-creation time (multipart upload)
|
||||
from turnstone.sdk import AttachmentUpload
|
||||
|
||||
with open("notes.txt", "rb") as f:
|
||||
ws = client.create_workstream(
|
||||
name="triage",
|
||||
initial_message="Summarize the notes",
|
||||
attachments=[AttachmentUpload(data=f.read(),
|
||||
filename="notes.txt",
|
||||
mime_type="text/plain")],
|
||||
)
|
||||
```
|
||||
|
||||
Limits: images ≤ 4 MiB (png/jpeg/gif/webp), text ≤ 512 KiB (UTF-8),
|
||||
10 pending per (workstream, user). The SDK auto-generates `ws_id` on the
|
||||
client so cluster-routed callers bind attachments to the owning node
|
||||
before the request lands.
|
||||
|
||||
### Error Handling
|
||||
|
||||
Non-2xx responses raise `TurnstoneAPIError`:
|
||||
@@ -284,7 +318,7 @@ turnstone/sdk/ Python SDK (sub-package)
|
||||
_base.py Shared httpx async client, auth, error handling
|
||||
_sync.py Background event loop for sync wrappers
|
||||
_types.py TurnResult + TurnstoneAPIError
|
||||
events.py 27 SSE event dataclasses with type registry
|
||||
events.py 38 SSE event dataclasses with type registry
|
||||
server.py AsyncTurnstoneServer + TurnstoneServer
|
||||
console.py AsyncTurnstoneConsole + TurnstoneConsole
|
||||
|
||||
@@ -332,6 +366,6 @@ client.login(token="ts_abc123...")
|
||||
- `client.logout()` clears the stored JWT from the client.
|
||||
- If a request returns 401, the SDK raises `TurnstoneAPIError` — the caller is responsible for re-authenticating.
|
||||
|
||||
### Backward Compatibility
|
||||
### Token Types
|
||||
|
||||
The config-file token (`TURNSTONE_AUTH_TOKEN`) still works as a simple Bearer token for environments that do not use the user/JWT system. When the server receives a non-JWT Bearer token, it falls back to the legacy token check.
|
||||
The SDK accepts any Bearer token — JWTs (from `ServiceTokenManager` or login) and API tokens (`ts_` prefix) are both supported. Use `token_factory` for auto-rotating JWTs or a static `token` for API tokens.
|
||||
|
||||
+17
-58
@@ -1,30 +1,15 @@
|
||||
# Security and Authentication
|
||||
|
||||
Turnstone uses a layered authentication system with three token types,
|
||||
hierarchical scopes, and a split architecture where the console manages
|
||||
credentials while individual server nodes validate JWTs locally.
|
||||
Turnstone uses a layered authentication system with two token types
|
||||
(database-backed API tokens + HMAC-SHA256 JWTs), hierarchical scopes,
|
||||
and a split architecture where the console manages credentials while
|
||||
individual server nodes validate JWTs locally. Inter-service traffic
|
||||
uses short-lived service JWTs minted by `ServiceTokenManager`.
|
||||
|
||||
---
|
||||
|
||||
## Token Types
|
||||
|
||||
### Config-file tokens
|
||||
|
||||
Static tokens defined in `config.toml` or the `TURNSTONE_AUTH_TOKEN`
|
||||
environment variable. Validated in-memory using `hmac.compare_digest`
|
||||
(timing-safe). Each token maps to a role that determines its scopes.
|
||||
|
||||
```toml
|
||||
[[auth.tokens]]
|
||||
value = "tok_legacy"
|
||||
role = "full" # full → {read, write, approve}
|
||||
```
|
||||
|
||||
Role mappings: `"read"` → `{read}`, `"full"` → `{read, write, approve}`.
|
||||
|
||||
Config tokens are sent directly as `Authorization: Bearer tok_legacy`
|
||||
on every request. No JWT exchange is needed.
|
||||
|
||||
### API tokens
|
||||
|
||||
Database-backed tokens prefixed with `ts_`. Created via the admin CLI
|
||||
@@ -54,7 +39,7 @@ Claims:
|
||||
|-------|-------------|
|
||||
| `sub` | User ID |
|
||||
| `scopes` | Comma-separated scope list (`read,write,approve`) |
|
||||
| `src` | Token source (`password`, `api_token`, `config`, `oidc`) |
|
||||
| `src` | Token source (`password`, `database`, `oidc`, or a service origin like `console`, `cli`, or `channel`) |
|
||||
| `iss` | Issuer — always `turnstone` |
|
||||
| `aud` | Audience — `turnstone-server` or `turnstone-console` |
|
||||
| `iat` | Issued-at timestamp |
|
||||
@@ -149,15 +134,6 @@ The API token is hashed, looked up in the database, and exchanged for a
|
||||
JWT with the token's scopes. This is the recommended flow for SDKs and
|
||||
automated clients that need cookie-based sessions.
|
||||
|
||||
### Config-file tokens (direct)
|
||||
|
||||
Config tokens are validated per-request via `hmac.compare_digest`. No
|
||||
login exchange is needed — include the token as a `Bearer` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer tok_legacy
|
||||
```
|
||||
|
||||
### First-time setup
|
||||
|
||||
When no users exist in the database:
|
||||
@@ -276,7 +252,7 @@ Setting `TURNSTONE_OIDC_PASSWORD_ENABLED=false` hides the password
|
||||
form on the login page and blocks password-based login at the API
|
||||
level. The setup wizard always works regardless of this setting — the
|
||||
first admin user is created with a password before OIDC is relevant.
|
||||
API tokens and config-file tokens are unaffected by this setting.
|
||||
API tokens are unaffected by this setting.
|
||||
|
||||
#### Known limitations
|
||||
|
||||
@@ -297,8 +273,6 @@ and classifies the token:
|
||||
|
||||
1. **Contains `.`** → JWT → validate HS256 signature and expiry
|
||||
2. **Starts with `ts_`** → API token → SHA-256 hash, database lookup
|
||||
3. **Otherwise** → config-file token → `hmac.compare_digest` against
|
||||
each configured token
|
||||
|
||||
If a session cookie is present and no `Authorization` header is sent,
|
||||
the cookie value is treated as a JWT (step 1).
|
||||
@@ -332,16 +306,10 @@ deployments.
|
||||
| Signing secret | `[auth] jwt_secret` | `TURNSTONE_JWT_SECRET` | Auto-generated ephemeral (warning logged) |
|
||||
| Expiry | `[auth] jwt_expiry_hours` | — | 24 hours |
|
||||
| Algorithm | — | — | HS256 (not configurable) |
|
||||
| Minimum secret length | — | — | 32 characters (warning if shorter) |
|
||||
| Minimum secret length | — | — | 32 characters (exits if shorter) |
|
||||
|
||||
All service nodes that need to validate JWTs must share the same signing
|
||||
secret. If no secret is configured, an ephemeral key is generated at
|
||||
startup and a warning is logged — JWTs will not survive restarts or work
|
||||
across nodes.
|
||||
|
||||
The console **requires** `TURNSTONE_JWT_SECRET` when no `--auth-token`
|
||||
is provided. It exits with an error if the secret is missing, since
|
||||
ephemeral secrets would silently break inter-service communication.
|
||||
All services require `TURNSTONE_JWT_SECRET` and exit at startup if it is
|
||||
missing or shorter than 32 characters.
|
||||
|
||||
---
|
||||
|
||||
@@ -442,16 +410,15 @@ Console (cluster-wide) Server (per-node)
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ User/Token CRUD (DB) │ │ JWT validation only │
|
||||
│ Login: creds → JWT │ │ (shared signing key) │
|
||||
│ Admin API endpoints │ │ Config tokens: hmac │
|
||||
│ Storage: users, │ │ No auth DB needed │
|
||||
│ Admin API endpoints │ │ No auth DB needed │
|
||||
│ Storage: users, │ │ │
|
||||
│ api_tokens tables │ │ │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
The console owns the credential database and handles all user/token
|
||||
CRUD. Individual server nodes only need the JWT signing secret to
|
||||
validate session tokens. Config-file tokens are validated locally
|
||||
without any database.
|
||||
validate session tokens.
|
||||
|
||||
### Proxy auth forwarding
|
||||
|
||||
@@ -478,8 +445,7 @@ distinguish proxied requests from direct logins in audit logs.
|
||||
|
||||
When no user context is available (auth disabled, or internal requests),
|
||||
the proxy falls back to a `ServiceTokenManager` with service identity
|
||||
`console-proxy` and full scopes. If `--auth-token` is provided, that
|
||||
static token is used as a final fallback.
|
||||
`console-proxy` and full scopes.
|
||||
|
||||
### Service-to-service authentication
|
||||
|
||||
@@ -518,22 +484,17 @@ channel gateway endpoint, and vice versa.
|
||||
|
||||
```toml
|
||||
[auth]
|
||||
enabled = true
|
||||
jwt_secret = "your-secret-key-here"
|
||||
jwt_expiry_hours = 24
|
||||
|
||||
[[auth.tokens]]
|
||||
value = "tok_legacy"
|
||||
role = "full"
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TURNSTONE_AUTH_ENABLED=1` | Enable authentication |
|
||||
| `TURNSTONE_AUTH_TOKEN=tok_xxx` | Register a config-file token with `full` access |
|
||||
| `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (must match across nodes) |
|
||||
| `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (required, must match across nodes) |
|
||||
| `TURNSTONE_CORS_ORIGINS=` | CORS allowed origins (comma-separated; empty = same-origin only) |
|
||||
|
||||
---
|
||||
@@ -571,8 +532,6 @@ and browsers enforce same-origin policy.
|
||||
|
||||
## Security Properties
|
||||
|
||||
- **Timing-safe comparison** for config-file tokens via
|
||||
`hmac.compare_digest` — no timing side-channel.
|
||||
- **Hash-based lookup** for API tokens — the database stores only
|
||||
SHA-256 hashes, eliminating timing attacks on token comparison.
|
||||
- **Local JWT validation** — no network call or database query needed
|
||||
|
||||
+46
-4
@@ -36,6 +36,47 @@ users to the admin Settings API.
|
||||
|
||||
---
|
||||
|
||||
## Per-Model Sampling Overrides
|
||||
|
||||
The global `model.temperature`, `model.max_tokens`, and `model.reasoning_effort`
|
||||
settings serve as cluster-wide defaults. Individual models can override these
|
||||
via per-model settings in the `model_definitions` table (admin Models tab).
|
||||
|
||||
Resolution order for sampling parameters:
|
||||
|
||||
| Priority | Source |
|
||||
|----------|--------|
|
||||
| 1 (highest) | Per-model override (set in Models tab) |
|
||||
| 2 | Global default (set in Settings tab) |
|
||||
| 3 | Registry default (code) |
|
||||
|
||||
When a per-model override is `NULL` (empty in the UI), the global default is
|
||||
used. Switching models via `/model <alias>` re-resolves sampling parameters
|
||||
from the new model's overrides or global defaults.
|
||||
|
||||
**Removed settings:** `model.name` and `model.context_window` have been removed
|
||||
from ConfigStore. Model names and context windows are now configured per-model
|
||||
in the Models tab. A startup warning is logged if these keys appear in
|
||||
`config.toml`.
|
||||
|
||||
### Plan / task agent overrides
|
||||
|
||||
`plan_agent` and `task_agent` sub-sessions resolve independently from the
|
||||
conversation model so operators can pick a cheaper/faster model for
|
||||
autonomous loops:
|
||||
|
||||
| Setting | Purpose |
|
||||
|---------|---------|
|
||||
| `model.plan_alias` | Alias used for `plan_agent` sub-sessions. Falls back to `[model].plan_model` in config.toml, then `[model].agent_model`, then the session's active model. |
|
||||
| `model.task_alias` | Alias used for `task_agent` sub-sessions. Same fallback chain as `plan_alias`. |
|
||||
| `model.plan_effort` | Reasoning effort for `plan_agent` (`none` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max`). Defaults to `high`. |
|
||||
| `model.task_effort` | Reasoning effort for `task_agent`. Empty string means "inherit from the session". |
|
||||
|
||||
All four are live-editable from the Settings tab and take effect on the
|
||||
next sub-agent invocation — no restart required.
|
||||
|
||||
---
|
||||
|
||||
## Bootstrap vs ConfigStore
|
||||
|
||||
**Bootstrap settings** are required before storage is available (database
|
||||
@@ -49,12 +90,12 @@ connection, Redis, auth secrets, server bind address). These stay in
|
||||
| Auth | `[auth]` | config.toml / env |
|
||||
| Console bind | `[console]` | config.toml / env |
|
||||
|
||||
**ConfigStore settings** (48 settings) are loaded from the database after
|
||||
storage initialization:
|
||||
**ConfigStore settings** are loaded from the database after storage
|
||||
initialization:
|
||||
|
||||
| Section | Settings |
|
||||
|---------|----------|
|
||||
| `model` | name, temperature, max_tokens, reasoning_effort, context_window |
|
||||
| `model` | default_alias, temperature, max_tokens, reasoning_effort, plan_alias, task_alias, plan_effort, task_effort |
|
||||
| `session` | instructions, retention_days, compact_max_tokens, auto_compact_pct |
|
||||
| `tools` | timeout, truncation, agent_max_turns, skip_permissions, search, search_threshold, search_max_results |
|
||||
| `server` | workstream_idle_timeout, max_workstreams |
|
||||
@@ -62,7 +103,8 @@ storage initialization:
|
||||
| `mcp` | config_path, refresh_interval, registry_url |
|
||||
| `ratelimit` | enabled, requests_per_second, burst, trusted_proxies |
|
||||
| `health` | backend_probe_interval, backend_probe_timeout, circuit_breaker_threshold, circuit_breaker_cooldown |
|
||||
| `judge` | enabled, model, provider, base_url, api_key, confidence_threshold, max_context_ratio, timeout, read_only_tools, output_guard, redact_secrets |
|
||||
| `judge` | enabled, model, provider, base_url, api_key, confidence_threshold, max_context_ratio, timeout, read_only_tools, output_guard, redact_secrets, cancel_on_approval |
|
||||
| `interface` | close_tab_action, theme |
|
||||
| `skills` | discovery_url |
|
||||
| `memory` | relevance_k, fetch_limit, max_content, nudge_cooldown, nudges |
|
||||
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ turnstone-admin tls-ca-cert --out ca.pem --console-url http://console:8080
|
||||
turnstone-admin tls-issue worker-1.internal --out /certs --console-url http://console:8080
|
||||
|
||||
# List issued certs
|
||||
turnstone-admin tls-list --console-url http://console:8080 --auth-token $TOKEN
|
||||
turnstone-admin tls-list --console-url http://console:8080
|
||||
```
|
||||
|
||||
### Console URL Discovery
|
||||
|
||||
+12
-9
@@ -169,8 +169,8 @@ Every tool defines a `primary_key`. The mapping is:
|
||||
| `man` | `page` |
|
||||
| `web_fetch` | `url` |
|
||||
| `web_search` | `query` |
|
||||
| `task` | `prompt` |
|
||||
| `plan` | `prompt` |
|
||||
| `task_agent` | `prompt` |
|
||||
| `plan_agent` | `goal` |
|
||||
| `memory` | `name` |
|
||||
| `recall` | `query` |
|
||||
| `notify` | `message` |
|
||||
@@ -357,7 +357,10 @@ Search the web using a text query.
|
||||
|
||||
## Agent
|
||||
|
||||
### task
|
||||
Tool names use the `_agent` suffix — bare `plan` / `task` collide with
|
||||
chat-template channel names on some local models.
|
||||
|
||||
### task_agent
|
||||
|
||||
Delegate a general-purpose task to an autonomous sub-agent.
|
||||
|
||||
@@ -371,7 +374,7 @@ Delegate a general-purpose task to an autonomous sub-agent.
|
||||
|
||||
---
|
||||
|
||||
### plan
|
||||
### plan_agent
|
||||
|
||||
Plan before implementing -- an autonomous agent explores the codebase and writes a structured plan.
|
||||
|
||||
@@ -543,11 +546,11 @@ pre-configure skills at workstream creation.
|
||||
- `load` — Activate a skill by name. Calls `set_skill()` which handles content
|
||||
rendering with `{{model}}`/`{{ws_id}}`/`{{node_id}}` variables, system message
|
||||
reinitialization, and config persistence. Returns the skill name, description,
|
||||
and security scan tier. Warns on high/critical scan status.
|
||||
and security risk level. Warns on high/critical risk level.
|
||||
- `search` — Find available skills by query. Uses BM25 relevance ranking over
|
||||
name, description, tags, and category (same `BM25Index` used by memory
|
||||
relevance and tool search). Returns up to 10 results with name, description,
|
||||
category, scan status, and activation type.
|
||||
category, risk level, and activation type.
|
||||
|
||||
- **Auto-approve**: `load` requires approval (changes session behavior); `search`
|
||||
is auto-approved (read-only).
|
||||
@@ -568,8 +571,8 @@ pre-configure skills at workstream creation.
|
||||
| `man` | Info | Yes | Yes | Yes | `page` |
|
||||
| `web_fetch` | Info | No | Yes | Yes | `url` |
|
||||
| `web_search` | Info | No | Yes | Yes | `query` |
|
||||
| `task` | Agent | No | No | No | `prompt` |
|
||||
| `plan` | Agent | No | No | No | `prompt` |
|
||||
| `task_agent` | Agent | No | No | No | `prompt` |
|
||||
| `plan_agent` | Agent | No | No | No | `goal` |
|
||||
| `memory` | Memory | Yes | No | No | `name` |
|
||||
| `recall` | Memory | Yes | No | No | `query` |
|
||||
| `notify` | Notify | Yes | Yes | Yes | `message` |
|
||||
@@ -593,7 +596,7 @@ current turn and letting it search for them on demand.
|
||||
Tool search uses the best available mechanism for each provider:
|
||||
|
||||
1. **Anthropic (native)** -- Models that support it receive `defer_loading: true`
|
||||
on deferred tool definitions plus the `tool_search_tool_bm25_20251119` server-side
|
||||
on deferred tool definitions plus the `tool_search_tool_bm25` server-side
|
||||
search tool. Anthropic's API handles search and expansion transparently.
|
||||
|
||||
2. **OpenAI GPT-5.4+ (native)** -- Models with hosted tool search receive
|
||||
|
||||
@@ -1,10 +1,51 @@
|
||||
# MCP Cluster Ops
|
||||
|
||||
An MCP server that exposes tools for executing commands across a [Turnstone](https://github.com/turnstonelabs/turnstone) cluster. Serves as a reference implementation for both MCP server patterns and Turnstone SDK usage.
|
||||
An MCP server that exposes tools for executing commands across a Turnstone cluster. Serves as a reference implementation for both MCP server patterns and Turnstone SDK usage.
|
||||
|
||||
> [!NOTE]
|
||||
> **Superseded by the built-in coordinator workstream in Turnstone 1.5.**
|
||||
>
|
||||
> This MCP side-car is the pre-1.5 pattern for cluster-wide orchestration.
|
||||
> Turnstone 1.5 promotes coordinator behaviour to a first-class workstream
|
||||
> kind hosted inside `turnstone-console` — no external MCP server to
|
||||
> install or operate, proper per-user audit attribution, and a dedicated
|
||||
> UI at `/coordinator/{ws_id}`.
|
||||
>
|
||||
> The extension continues to work for 1.4-and-earlier clusters. On 1.5+:
|
||||
> grant the `admin.coordinator` permission, set `coordinator.model_alias`
|
||||
> in the admin Settings tab, and create sessions via the dashboard's
|
||||
> "new coordinator" button or `POST /v1/api/coordinator/new`. Full
|
||||
> removal of this example (including docker / compose references) is
|
||||
> planned once 1.5 is confirmed in production.
|
||||
>
|
||||
> | Concern | Built-in coordinator (1.5+) | This MCP extension (1.4-and-earlier) |
|
||||
> |---|---|---|
|
||||
> | Install | None — shipped in-tree | `pip install -e examples/mcp-cluster-ops` + MCP client config |
|
||||
> | Auth | Real creator's `user_id` + `admin.coordinator` permission | Shared service token |
|
||||
> | Audit | `coordinator.create` / `close` / `cancel` events on the console; `src="coordinator"` preserved on upstream hops | Service identity only |
|
||||
> | UI | `/coordinator/{ws_id}` one-pane HTML | No UI — model-only |
|
||||
> | Tool approvals | Inline approval bar in the coordinator pane | MCP approval flow |
|
||||
> | Configuration | `coordinator.model_alias`, `coordinator.max_active`, `coordinator.reasoning_effort`, `coordinator.session_jwt_ttl_seconds` | MCP server config file |
|
||||
>
|
||||
> Minimal 1.5 migration:
|
||||
>
|
||||
> ```bash
|
||||
> curl -X POST https://console.example/v1/api/coordinator/new \
|
||||
> -H "Authorization: Bearer $TOKEN" \
|
||||
> -H "Content-Type: application/json" \
|
||||
> -d '{"name":"planner","initial_message":"Spawn a worker to check the build"}'
|
||||
> ```
|
||||
>
|
||||
> The response carries `ws_id`; open
|
||||
> `https://console.example/coordinator/{ws_id}` to watch the session.
|
||||
|
||||
## How it works
|
||||
|
||||
This server uses Turnstone's SDK client (`TurnstoneServer`) to dispatch shell commands to specific nodes via HTTP. Remote agents execute the command and the raw bash output is captured directly from the `ToolResultEvent` stream — bypassing the costly "agent reads output → re-generates output as completion tokens" round-trip.
|
||||
This server uses the Turnstone console SDK (`TurnstoneConsole`) for node discovery and routing, and `TurnstoneServer` for per-node SSE streaming. The dispatch flow for each command is:
|
||||
|
||||
1. **Route** — `TurnstoneConsole.route_create_workstream(target_node=..., auto_approve=True)` creates a workstream pinned to the target node via the console's rendezvous routing proxy, returning `ws_id` and `node_url`.
|
||||
2. **Execute** — `TurnstoneServer(node_url, token=...)` connects directly to the node's SSE stream using the same `TURNSTONE_API_TOKEN`. `send_and_wait(prompt, ws_id)` runs the command and the raw bash output is captured from the `ToolResultEvent` — bypassing the costly "agent reads output then re-generates output as completion tokens" round-trip.
|
||||
3. **Cleanup** — `TurnstoneConsole.route_close(ws_id)` closes the workstream.
|
||||
|
||||
Multi-node dispatches run in parallel via `asyncio.gather`, so total wall time is bounded by the slowest node rather than the sum.
|
||||
|
||||
@@ -19,7 +60,7 @@ Multi-node dispatches run in parallel via `asyncio.gather`, so total wall time i
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running Turnstone cluster (at least one `turnstone-server`)
|
||||
- A running Turnstone cluster with at least one `turnstone-server` and a `turnstone-console`
|
||||
- Python 3.11+
|
||||
|
||||
## Installation
|
||||
@@ -35,8 +76,8 @@ pip install -e ./examples/mcp-cluster-ops
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_SERVER_URL` | `http://localhost:8080` | Server URL |
|
||||
| `TURNSTONE_API_TOKEN` | _(none)_ | API token for authentication |
|
||||
| `TURNSTONE_CONSOLE_URL` | `http://localhost:8090` | Console URL for node discovery and routing |
|
||||
| `TURNSTONE_API_TOKEN` | _(none)_ | API token / JWT for authentication |
|
||||
| `MCP_CLUSTER_OPS_TIMEOUT` | `120` | Default command timeout (seconds, clamped 5-3600) |
|
||||
| `MCP_CLUSTER_OPS_MAX_OUTPUT` | `8192` | Max output bytes per node (0 = unlimited) |
|
||||
| `MCP_CLUSTER_OPS_MAX_NODES` | `32` | Max concurrent node dispatches |
|
||||
@@ -51,7 +92,7 @@ pip install -e ./examples/mcp-cluster-ops
|
||||
command = "mcp-cluster-ops"
|
||||
|
||||
[mcp.servers.cluster-ops.env]
|
||||
TURNSTONE_SERVER_URL = "http://turnstone.example.com:8080"
|
||||
TURNSTONE_CONSOLE_URL = "http://console.example.com:8090"
|
||||
```
|
||||
|
||||
**JSON** (via `--mcp-config`):
|
||||
@@ -62,7 +103,7 @@ TURNSTONE_SERVER_URL = "http://turnstone.example.com:8080"
|
||||
"cluster-ops": {
|
||||
"command": "mcp-cluster-ops",
|
||||
"env": {
|
||||
"TURNSTONE_SERVER_URL": "http://turnstone.example.com:8080"
|
||||
"TURNSTONE_CONSOLE_URL": "http://console.example.com:8090"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""MCP server for Turnstone cluster operations.
|
||||
|
||||
Exposes tools to execute commands on specific nodes in a Turnstone cluster.
|
||||
Uses the SDK client (``TurnstoneServer``) for direct node targeting via HTTP.
|
||||
Uses the SDK console client (``TurnstoneConsole``) for node discovery and
|
||||
routing, and ``TurnstoneServer`` for per-node SSE streaming.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -14,12 +15,12 @@ Configure in ``~/.config/turnstone/config.toml``::
|
||||
command = "mcp-cluster-ops"
|
||||
|
||||
[mcp.servers.cluster-ops.env]
|
||||
TURNSTONE_SERVER_URL = "http://localhost:8080"
|
||||
TURNSTONE_CONSOLE_URL = "http://localhost:8090"
|
||||
|
||||
Environment variables
|
||||
---------------------
|
||||
TURNSTONE_SERVER_URL Server URL (default: http://localhost:8080)
|
||||
TURNSTONE_API_TOKEN API token for authentication (default: none)
|
||||
TURNSTONE_CONSOLE_URL Console URL (default: http://localhost:8090)
|
||||
TURNSTONE_API_TOKEN API token / JWT for authentication (default: none)
|
||||
MCP_CLUSTER_OPS_TIMEOUT Default command timeout in seconds (default: 120)
|
||||
MCP_CLUSTER_OPS_MAX_OUTPUT Max output bytes per node (default: 8192, 0=unlimited)
|
||||
|
||||
@@ -43,7 +44,7 @@ from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from turnstone.sdk import TurnResult, TurnstoneServer
|
||||
from turnstone.sdk import TurnResult, TurnstoneConsole, TurnstoneServer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -66,15 +67,12 @@ _MAX_TIMEOUT = 3600
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _server_kwargs() -> dict[str, Any]:
|
||||
"""Build TurnstoneServer connection kwargs from environment variables."""
|
||||
kwargs: dict[str, Any] = {
|
||||
"base_url": os.environ.get("TURNSTONE_SERVER_URL", "http://localhost:8080"),
|
||||
def _console_kwargs() -> dict[str, Any]:
|
||||
"""Build TurnstoneConsole connection kwargs from environment variables."""
|
||||
return {
|
||||
"base_url": os.environ.get("TURNSTONE_CONSOLE_URL", "http://localhost:8090"),
|
||||
"token": os.environ.get("TURNSTONE_API_TOKEN", ""),
|
||||
}
|
||||
token = os.environ.get("TURNSTONE_API_TOKEN")
|
||||
if token:
|
||||
kwargs["token"] = token
|
||||
return kwargs
|
||||
|
||||
|
||||
def _exec_prompt(command: str) -> str:
|
||||
@@ -141,6 +139,11 @@ def _validate_command(command: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_node_ids(nodes: list[dict[str, Any]]) -> list[str]:
|
||||
"""Extract unique, non-empty node IDs from a list of node dicts."""
|
||||
return list(dict.fromkeys(n["node_id"].strip() for n in nodes if n.get("node_id", "").strip()))
|
||||
|
||||
|
||||
def _format_node_result(
|
||||
node_id: str,
|
||||
result: TurnResult,
|
||||
@@ -165,12 +168,12 @@ def _format_node_result(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core dispatch functions (testable with mocked TurnstoneServer)
|
||||
# Core dispatch functions (testable with mocked SDK clients)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _exec_on_node_sync(
|
||||
server_kw: dict[str, Any],
|
||||
console_kw: dict[str, Any],
|
||||
node_id: str,
|
||||
command: str,
|
||||
timeout: float,
|
||||
@@ -178,22 +181,35 @@ def _exec_on_node_sync(
|
||||
"""Dispatch *command* to *node_id* and block until complete.
|
||||
|
||||
Runs inside ``asyncio.to_thread`` so it does not block the event loop.
|
||||
Each call creates its own ``TurnstoneServer`` client to avoid state
|
||||
conflicts between concurrent dispatches.
|
||||
|
||||
Flow:
|
||||
1. Create a workstream on the target node via the console routing proxy
|
||||
2. Connect directly to the node's SSE stream to send + collect output
|
||||
3. Close the workstream via the routing proxy
|
||||
"""
|
||||
prompt = _exec_prompt(command)
|
||||
with TurnstoneServer(**server_kw) as client:
|
||||
result = client.send_and_wait(
|
||||
message=prompt,
|
||||
target_node=node_id,
|
||||
auto_approve=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
ws_id = ""
|
||||
with TurnstoneConsole(**console_kw) as console:
|
||||
try:
|
||||
route_resp = console.route_create_workstream(
|
||||
target_node=node_id,
|
||||
auto_approve=True,
|
||||
)
|
||||
ws_id = route_resp["ws_id"]
|
||||
node_url: str = route_resp["node_url"]
|
||||
with TurnstoneServer(
|
||||
base_url=node_url,
|
||||
token=console_kw["token"],
|
||||
) as server:
|
||||
result = server.send_and_wait(prompt, ws_id, timeout=timeout)
|
||||
finally:
|
||||
if ws_id:
|
||||
console.route_close(ws_id)
|
||||
return node_id, result
|
||||
|
||||
|
||||
async def _dispatch_parallel(
|
||||
server_kw: dict[str, Any],
|
||||
console_kw: dict[str, Any],
|
||||
node_ids: list[str],
|
||||
command: str,
|
||||
timeout: float,
|
||||
@@ -204,7 +220,7 @@ async def _dispatch_parallel(
|
||||
Total wall time is bounded by the slowest node.
|
||||
"""
|
||||
tasks = [
|
||||
asyncio.to_thread(_exec_on_node_sync, server_kw, nid, command, timeout) for nid in node_ids
|
||||
asyncio.to_thread(_exec_on_node_sync, console_kw, nid, command, timeout) for nid in node_ids
|
||||
]
|
||||
outcomes = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
@@ -220,16 +236,22 @@ async def _dispatch_parallel(
|
||||
return results
|
||||
|
||||
|
||||
def _list_nodes_sync(server_kw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""List active cluster nodes (blocking)."""
|
||||
with TurnstoneServer(**server_kw) as client:
|
||||
nodes: list[dict[str, Any]] = client.list_nodes()
|
||||
return nodes
|
||||
def _list_nodes_sync(console_kw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""List active cluster nodes (blocking), paginating if needed."""
|
||||
page_size = 100
|
||||
nodes: list[dict[str, Any]] = []
|
||||
with TurnstoneConsole(**console_kw) as console:
|
||||
while True:
|
||||
resp = console.nodes(limit=page_size, offset=len(nodes))
|
||||
nodes.extend(n.model_dump() for n in resp.nodes)
|
||||
if len(nodes) >= resp.total or not resp.nodes:
|
||||
break
|
||||
return nodes
|
||||
|
||||
|
||||
async def _list_nodes_impl(server_kw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
async def _list_nodes_impl(console_kw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""List active cluster nodes."""
|
||||
return await asyncio.to_thread(_list_nodes_sync, server_kw)
|
||||
return await asyncio.to_thread(_list_nodes_sync, console_kw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -239,9 +261,9 @@ async def _list_nodes_impl(server_kw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(server: FastMCP[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Lifespan context — stores server connection kwargs for tool handlers."""
|
||||
kw = _server_kwargs()
|
||||
yield {"server_kwargs": kw}
|
||||
"""Lifespan context — stores console connection kwargs for tool handlers."""
|
||||
kw = _console_kwargs()
|
||||
yield {"console_kwargs": kw}
|
||||
|
||||
|
||||
mcp = FastMCP(
|
||||
@@ -263,8 +285,8 @@ async def list_nodes(ctx: Context[Any, Any, Any]) -> str:
|
||||
Call this before dispatching work to discover available node IDs.
|
||||
Returns a JSON array of node metadata objects.
|
||||
"""
|
||||
server_kw: dict[str, Any] = ctx.request_context.lifespan_context["server_kwargs"]
|
||||
nodes = await _list_nodes_impl(server_kw)
|
||||
console_kw: dict[str, Any] = ctx.request_context.lifespan_context["console_kwargs"]
|
||||
nodes = await _list_nodes_impl(console_kw)
|
||||
return json.dumps(nodes, indent=2)
|
||||
|
||||
|
||||
@@ -291,13 +313,16 @@ async def run_on_node(
|
||||
if cmd_err:
|
||||
return json.dumps({"error": cmd_err})
|
||||
|
||||
server_kw: dict[str, Any] = ctx.request_context.lifespan_context["server_kwargs"]
|
||||
console_kw: dict[str, Any] = ctx.request_context.lifespan_context["console_kwargs"]
|
||||
max_output = _DEFAULT_MAX_OUTPUT
|
||||
|
||||
log.info("run_on_node node=%s cmd=%r", node_id, command)
|
||||
_, result = await asyncio.to_thread(
|
||||
_exec_on_node_sync, server_kw, node_id, command, _clamp_timeout(timeout)
|
||||
)
|
||||
try:
|
||||
_, result = await asyncio.to_thread(
|
||||
_exec_on_node_sync, console_kw, node_id, command, _clamp_timeout(timeout)
|
||||
)
|
||||
except Exception as exc:
|
||||
return json.dumps({"node": node_id, "ok": False, "error": str(exc)}, indent=2)
|
||||
formatted = _format_node_result(node_id, result, max_output)
|
||||
return json.dumps(formatted, indent=2)
|
||||
|
||||
@@ -323,7 +348,7 @@ async def run_on_nodes(
|
||||
if cmd_err:
|
||||
return json.dumps({"error": cmd_err})
|
||||
|
||||
server_kw: dict[str, Any] = ctx.request_context.lifespan_context["server_kwargs"]
|
||||
console_kw: dict[str, Any] = ctx.request_context.lifespan_context["console_kwargs"]
|
||||
max_output = _DEFAULT_MAX_OUTPUT
|
||||
|
||||
clean_ids = list(dict.fromkeys(nid.strip() for nid in node_ids if nid.strip()))
|
||||
@@ -336,7 +361,7 @@ async def run_on_nodes(
|
||||
|
||||
log.info("run_on_nodes nodes=%s cmd=%r", clean_ids, command)
|
||||
results = await _dispatch_parallel(
|
||||
server_kw, clean_ids, command, _clamp_timeout(timeout), max_output
|
||||
console_kw, clean_ids, command, _clamp_timeout(timeout), max_output
|
||||
)
|
||||
return json.dumps(results, indent=2)
|
||||
|
||||
@@ -361,18 +386,14 @@ async def run_on_all_nodes(
|
||||
if cmd_err:
|
||||
return json.dumps({"error": cmd_err})
|
||||
|
||||
server_kw: dict[str, Any] = ctx.request_context.lifespan_context["server_kwargs"]
|
||||
console_kw: dict[str, Any] = ctx.request_context.lifespan_context["console_kwargs"]
|
||||
max_output = _DEFAULT_MAX_OUTPUT
|
||||
|
||||
nodes = await _list_nodes_impl(server_kw)
|
||||
nodes = await _list_nodes_impl(console_kw)
|
||||
if not nodes:
|
||||
return json.dumps({"error": "No active nodes found in cluster"})
|
||||
|
||||
node_ids = list(
|
||||
dict.fromkeys(
|
||||
nid.strip() for n in nodes if (nid := n.get("node_id") or n.get("id")) and nid.strip()
|
||||
)
|
||||
)
|
||||
node_ids = _extract_node_ids(nodes)
|
||||
if not node_ids:
|
||||
return json.dumps({"error": "No nodes with identifiable IDs found"})
|
||||
if len(node_ids) > _MAX_CONCURRENT_NODES:
|
||||
@@ -381,7 +402,7 @@ async def run_on_all_nodes(
|
||||
)
|
||||
log.info("run_on_all_nodes nodes=%s cmd=%r", node_ids, command)
|
||||
results = await _dispatch_parallel(
|
||||
server_kw, node_ids, command, _clamp_timeout(timeout), max_output
|
||||
console_kw, node_ids, command, _clamp_timeout(timeout), max_output
|
||||
)
|
||||
return json.dumps(results, indent=2)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from turnstone.sdk import TurnResult
|
||||
from mcp_cluster_ops.server import (
|
||||
_clamp_timeout,
|
||||
_exec_prompt,
|
||||
_extract_node_ids,
|
||||
_extract_output,
|
||||
_format_node_result,
|
||||
_truncate,
|
||||
@@ -190,3 +191,53 @@ class TestClampTimeout:
|
||||
|
||||
def test_negative(self):
|
||||
assert _clamp_timeout(-1) == 5.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_node_ids
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractNodeIds:
|
||||
def test_normal(self):
|
||||
nodes = [
|
||||
{"node_id": "a", "server_url": "http://a:8080"},
|
||||
{"node_id": "b", "server_url": "http://b:8080"},
|
||||
]
|
||||
assert _extract_node_ids(nodes) == ["a", "b"]
|
||||
|
||||
def test_deduplicates(self):
|
||||
nodes = [
|
||||
{"node_id": "a"},
|
||||
{"node_id": "a"},
|
||||
{"node_id": "b"},
|
||||
]
|
||||
assert _extract_node_ids(nodes) == ["a", "b"]
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
nodes = [{"node_id": " a "}, {"node_id": "b "}]
|
||||
assert _extract_node_ids(nodes) == ["a", "b"]
|
||||
|
||||
def test_skips_empty(self):
|
||||
nodes = [
|
||||
{"node_id": "a"},
|
||||
{"node_id": ""},
|
||||
{"node_id": " "},
|
||||
{"node_id": "b"},
|
||||
]
|
||||
assert _extract_node_ids(nodes) == ["a", "b"]
|
||||
|
||||
def test_skips_missing_key(self):
|
||||
nodes = [
|
||||
{"node_id": "a"},
|
||||
{"server_url": "http://orphan:8080"},
|
||||
{"node_id": "b"},
|
||||
]
|
||||
assert _extract_node_ids(nodes) == ["a", "b"]
|
||||
|
||||
def test_empty_list(self):
|
||||
assert _extract_node_ids([]) == []
|
||||
|
||||
def test_all_empty_ids(self):
|
||||
nodes = [{"node_id": ""}, {"node_id": " "}]
|
||||
assert _extract_node_ids(nodes) == []
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Tests for MCP tool handlers with mocked TurnstoneServer."""
|
||||
"""Tests for MCP tool handlers with mocked SDK clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from turnstone.sdk import TurnResult
|
||||
|
||||
from mcp_cluster_ops.server import (
|
||||
@@ -14,6 +16,22 @@ from mcp_cluster_ops.server import (
|
||||
_list_nodes_impl,
|
||||
)
|
||||
|
||||
_CONSOLE_KW: dict[str, Any] = {"base_url": "http://localhost:8090", "token": ""}
|
||||
_CONSOLE_KW_AUTH: dict[str, Any] = {"base_url": "http://localhost:8090", "token": "tok_test"}
|
||||
|
||||
|
||||
def _mock_console_ctx(mock_cls: MagicMock, mock_client: MagicMock) -> None:
|
||||
"""Wire up a TurnstoneConsole mock as a context manager."""
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
|
||||
def _mock_server_ctx(mock_cls: MagicMock, mock_server: MagicMock) -> None:
|
||||
"""Wire up a TurnstoneServer mock as a context manager."""
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_server)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _list_nodes_impl
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -21,26 +39,71 @@ from mcp_cluster_ops.server import (
|
||||
|
||||
class TestListNodesImpl:
|
||||
def test_returns_nodes(self):
|
||||
nodes = [{"node_id": "a", "model": "gpt-5"}, {"node_id": "b", "model": "gpt-5"}]
|
||||
with patch("mcp_cluster_ops.server.TurnstoneServer") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_nodes.return_value = nodes
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
mock_node_a = MagicMock()
|
||||
mock_node_a.model_dump.return_value = {"node_id": "a", "server_url": "http://a:8080"}
|
||||
mock_node_b = MagicMock()
|
||||
mock_node_b.model_dump.return_value = {"node_id": "b", "server_url": "http://b:8080"}
|
||||
|
||||
result = asyncio.run(_list_nodes_impl({"host": "localhost"}))
|
||||
assert result == nodes
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.nodes = [mock_node_a, mock_node_b]
|
||||
mock_resp.total = 2
|
||||
|
||||
with patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.nodes.return_value = mock_resp
|
||||
_mock_console_ctx(mock_cls, mock_client)
|
||||
|
||||
result = asyncio.run(_list_nodes_impl(_CONSOLE_KW))
|
||||
assert len(result) == 2
|
||||
assert result[0]["node_id"] == "a"
|
||||
assert result[1]["node_id"] == "b"
|
||||
|
||||
def test_empty_cluster(self):
|
||||
with patch("mcp_cluster_ops.server.TurnstoneServer") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_nodes.return_value = []
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.nodes = []
|
||||
mock_resp.total = 0
|
||||
|
||||
result = asyncio.run(_list_nodes_impl({"host": "localhost"}))
|
||||
with patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.nodes.return_value = mock_resp
|
||||
_mock_console_ctx(mock_cls, mock_client)
|
||||
|
||||
result = asyncio.run(_list_nodes_impl(_CONSOLE_KW))
|
||||
assert result == []
|
||||
|
||||
def test_paginates_large_clusters(self):
|
||||
"""Clusters with >100 nodes are fetched across multiple pages."""
|
||||
|
||||
def _make_node(nid: str) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.model_dump.return_value = {"node_id": nid}
|
||||
return m
|
||||
|
||||
page1_nodes = [_make_node(f"n-{i}") for i in range(100)]
|
||||
page2_nodes = [_make_node(f"n-{i}") for i in range(100, 150)]
|
||||
|
||||
page1_resp = MagicMock()
|
||||
page1_resp.nodes = page1_nodes
|
||||
page1_resp.total = 150
|
||||
|
||||
page2_resp = MagicMock()
|
||||
page2_resp.nodes = page2_nodes
|
||||
page2_resp.total = 150
|
||||
|
||||
with patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.nodes.side_effect = [page1_resp, page2_resp]
|
||||
_mock_console_ctx(mock_cls, mock_client)
|
||||
|
||||
result = asyncio.run(_list_nodes_impl(_CONSOLE_KW))
|
||||
assert len(result) == 150
|
||||
assert result[0]["node_id"] == "n-0"
|
||||
assert result[149]["node_id"] == "n-149"
|
||||
assert mock_client.nodes.call_count == 2
|
||||
# Verify offset was passed correctly
|
||||
mock_client.nodes.assert_any_call(limit=100, offset=0)
|
||||
mock_client.nodes.assert_any_call(limit=100, offset=100)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _exec_on_node_sync
|
||||
@@ -50,35 +113,111 @@ class TestListNodesImpl:
|
||||
class TestExecOnNodeSync:
|
||||
def test_success(self):
|
||||
turn_result = TurnResult(
|
||||
ws_id="ws-123",
|
||||
tool_results=[("bash", "hello world")],
|
||||
)
|
||||
with patch("mcp_cluster_ops.server.TurnstoneServer") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_and_wait.return_value = turn_result
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
with (
|
||||
patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_console_cls,
|
||||
patch("mcp_cluster_ops.server.TurnstoneServer") as mock_server_cls,
|
||||
):
|
||||
mock_console = MagicMock()
|
||||
mock_console.route_create_workstream.return_value = {
|
||||
"ws_id": "ws-123",
|
||||
"node_url": "http://node-1:8080",
|
||||
"node_id": "node-1",
|
||||
"name": "ws-123",
|
||||
}
|
||||
_mock_console_ctx(mock_console_cls, mock_console)
|
||||
|
||||
node_id, result = _exec_on_node_sync(
|
||||
{"host": "localhost"}, "node-1", "echo hello", 60.0
|
||||
)
|
||||
mock_server = MagicMock()
|
||||
mock_server.send_and_wait.return_value = turn_result
|
||||
_mock_server_ctx(mock_server_cls, mock_server)
|
||||
|
||||
node_id, result = _exec_on_node_sync(_CONSOLE_KW_AUTH, "node-1", "echo hello", 60.0)
|
||||
assert node_id == "node-1"
|
||||
assert result.ok
|
||||
mock_client.send_and_wait.assert_called_once()
|
||||
call_kwargs = mock_client.send_and_wait.call_args
|
||||
assert call_kwargs.kwargs["target_node"] == "node-1"
|
||||
assert call_kwargs.kwargs["auto_approve"] is True
|
||||
|
||||
# Verify console created ws on the right node
|
||||
mock_console.route_create_workstream.assert_called_once_with(
|
||||
target_node="node-1",
|
||||
auto_approve=True,
|
||||
)
|
||||
|
||||
# Verify server connected to the node URL with the token
|
||||
mock_server_cls.assert_called_once_with(
|
||||
base_url="http://node-1:8080",
|
||||
token="tok_test",
|
||||
)
|
||||
|
||||
# Verify send_and_wait got the right ws_id
|
||||
call_kwargs = mock_server.send_and_wait.call_args
|
||||
assert call_kwargs.args[1] == "ws-123"
|
||||
|
||||
# Verify workstream was closed
|
||||
mock_console.route_close.assert_called_once_with("ws-123")
|
||||
|
||||
def test_timeout(self):
|
||||
turn_result = TurnResult(timed_out=True)
|
||||
with patch("mcp_cluster_ops.server.TurnstoneServer") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.send_and_wait.return_value = turn_result
|
||||
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||
turn_result = TurnResult(ws_id="ws-456", timed_out=True)
|
||||
with (
|
||||
patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_console_cls,
|
||||
patch("mcp_cluster_ops.server.TurnstoneServer") as mock_server_cls,
|
||||
):
|
||||
mock_console = MagicMock()
|
||||
mock_console.route_create_workstream.return_value = {
|
||||
"ws_id": "ws-456",
|
||||
"node_url": "http://node-1:8080",
|
||||
"node_id": "node-1",
|
||||
}
|
||||
_mock_console_ctx(mock_console_cls, mock_console)
|
||||
|
||||
_, result = _exec_on_node_sync({"host": "localhost"}, "node-1", "sleep 9999", 1.0)
|
||||
mock_server = MagicMock()
|
||||
mock_server.send_and_wait.return_value = turn_result
|
||||
_mock_server_ctx(mock_server_cls, mock_server)
|
||||
|
||||
_, result = _exec_on_node_sync(_CONSOLE_KW, "node-1", "sleep 9999", 1.0)
|
||||
assert result.timed_out
|
||||
assert not result.ok
|
||||
# Workstream still closed even on timeout
|
||||
mock_console.route_close.assert_called_once_with("ws-456")
|
||||
|
||||
def test_send_failure_still_closes_workstream(self):
|
||||
"""Workstream must be closed even if send_and_wait raises."""
|
||||
with (
|
||||
patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_console_cls,
|
||||
patch("mcp_cluster_ops.server.TurnstoneServer") as mock_server_cls,
|
||||
):
|
||||
mock_console = MagicMock()
|
||||
mock_console.route_create_workstream.return_value = {
|
||||
"ws_id": "ws-789",
|
||||
"node_url": "http://node-1:8080",
|
||||
"node_id": "node-1",
|
||||
}
|
||||
_mock_console_ctx(mock_console_cls, mock_console)
|
||||
|
||||
mock_server = MagicMock()
|
||||
mock_server.send_and_wait.side_effect = ConnectionError("lost connection")
|
||||
_mock_server_ctx(mock_server_cls, mock_server)
|
||||
|
||||
with contextlib.suppress(ConnectionError):
|
||||
_exec_on_node_sync(_CONSOLE_KW, "node-1", "echo hi", 60.0)
|
||||
|
||||
mock_console.route_close.assert_called_once_with("ws-789")
|
||||
|
||||
def test_malformed_route_response_no_leak(self):
|
||||
"""If route response is missing ws_id, no route_close is attempted."""
|
||||
with patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_console_cls:
|
||||
mock_console = MagicMock()
|
||||
mock_console.route_create_workstream.return_value = {
|
||||
# Missing "ws_id" and "node_url"
|
||||
"node_id": "node-1",
|
||||
}
|
||||
_mock_console_ctx(mock_console_cls, mock_console)
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
_exec_on_node_sync(_CONSOLE_KW, "node-1", "echo hi", 60.0)
|
||||
|
||||
# route_close must NOT be called — ws_id was never assigned
|
||||
mock_console.route_close.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -88,40 +227,32 @@ class TestExecOnNodeSync:
|
||||
|
||||
class TestDispatchParallel:
|
||||
def test_parallel_success(self):
|
||||
def fake_exec(server_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
return (node_id, TurnResult(tool_results=[("bash", f"output-{node_id}")]))
|
||||
def fake_exec(console_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
return (
|
||||
node_id,
|
||||
TurnResult(tool_results=[("bash", f"output-{node_id}")]),
|
||||
)
|
||||
|
||||
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
|
||||
results = asyncio.run(
|
||||
_dispatch_parallel(
|
||||
{"host": "localhost"},
|
||||
["a", "b", "c"],
|
||||
"echo hi",
|
||||
60.0,
|
||||
8192,
|
||||
)
|
||||
_dispatch_parallel(_CONSOLE_KW, ["a", "b", "c"], "echo hi", 60.0, 8192)
|
||||
)
|
||||
assert len(results) == 3
|
||||
assert all(r["ok"] for r in results)
|
||||
outputs = {r["node"]: r["output"] for r in results}
|
||||
assert outputs["a"] == "output-a"
|
||||
assert outputs["b"] == "output-b"
|
||||
assert outputs["c"] == "output-c"
|
||||
|
||||
def test_partial_failure(self):
|
||||
def fake_exec(server_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
def fake_exec(console_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
if node_id == "bad":
|
||||
raise ConnectionError("connection refused")
|
||||
return (node_id, TurnResult(tool_results=[("bash", "ok")]))
|
||||
|
||||
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
|
||||
results = asyncio.run(
|
||||
_dispatch_parallel(
|
||||
{"host": "localhost"},
|
||||
["good", "bad"],
|
||||
"echo hi",
|
||||
60.0,
|
||||
8192,
|
||||
)
|
||||
_dispatch_parallel(_CONSOLE_KW, ["good", "bad"], "echo hi", 60.0, 8192)
|
||||
)
|
||||
assert len(results) == 2
|
||||
good = next(r for r in results if r["node"] == "good")
|
||||
@@ -131,18 +262,12 @@ class TestDispatchParallel:
|
||||
assert "connection refused" in bad["error"]
|
||||
|
||||
def test_all_fail(self):
|
||||
def fake_exec(server_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
def fake_exec(console_kw: Any, node_id: str, command: str, timeout: float) -> Any:
|
||||
raise RuntimeError(f"fail-{node_id}")
|
||||
|
||||
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
|
||||
results = asyncio.run(
|
||||
_dispatch_parallel(
|
||||
{"host": "localhost"},
|
||||
["a", "b"],
|
||||
"echo hi",
|
||||
60.0,
|
||||
8192,
|
||||
)
|
||||
_dispatch_parallel(_CONSOLE_KW, ["a", "b"], "echo hi", 60.0, 8192)
|
||||
)
|
||||
assert all(not r["ok"] for r in results)
|
||||
assert "fail-a" in results[0]["error"]
|
||||
|
||||
+17
-9
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.9.7"
|
||||
version = "1.5.0a3"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
@@ -12,7 +12,7 @@ requires-python = ">=3.11"
|
||||
authors = [{name = "Patrick Buckley", email = "buckleypm@gmail.com"}]
|
||||
keywords = ["ai", "chat", "llm", "agent", "tools", "openai"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
@@ -44,16 +44,17 @@ Repository = "https://github.com/turnstonelabs/turnstone"
|
||||
Issues = "https://github.com/turnstonelabs/turnstone/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest>=9.0", "pytest-cov>=6.0", "croniter>=3.0"]
|
||||
test = ["pytest>=9.0", "pytest-cov>=6.0", "croniter>=3.0", "slack-bolt>=1.18", "aiohttp>=3.9"]
|
||||
dev = ["ruff>=0.9", "mypy>=1.14"]
|
||||
console = ["croniter>=3.0"]
|
||||
anthropic = ["anthropic>=0.39"]
|
||||
postgres = ["psycopg[binary]>=3.2"]
|
||||
ddg = ["ddgs>=9.0"]
|
||||
discord = ["discord.py>=2.4"]
|
||||
tls = ["lacme>=1.0.4"]
|
||||
tls = ["lacme>=1.0.5"]
|
||||
sandbox = ["sympy>=1.13", "numpy>=2.0", "scipy>=1.14", "pytest>=9.0"]
|
||||
all = ["turnstone[console,anthropic,postgres,discord,ddg,tls,sandbox]"]
|
||||
slack = ["slack-bolt>=1.18", "aiohttp>=3.9"]
|
||||
all = ["turnstone[console,anthropic,postgres,discord,ddg,tls,sandbox,slack]"]
|
||||
|
||||
[project.scripts]
|
||||
turnstone = "turnstone.cli:main"
|
||||
@@ -67,6 +68,7 @@ turnstone-bootstrap = "turnstone.bootstrap:main"
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = [
|
||||
"turnstone/**/*.py",
|
||||
"turnstone/prompts/**/*.md",
|
||||
"turnstone/tools/*.json",
|
||||
"turnstone/ui/static/*.html",
|
||||
"turnstone/ui/static/*.css",
|
||||
@@ -74,12 +76,17 @@ include = [
|
||||
"turnstone/console/static/*.html",
|
||||
"turnstone/console/static/*.css",
|
||||
"turnstone/console/static/*.js",
|
||||
"turnstone/console/static/coordinator/*.html",
|
||||
"turnstone/console/static/coordinator/*.js",
|
||||
"turnstone/shared_static/*.css",
|
||||
"turnstone/shared_static/*.js",
|
||||
"turnstone/shared_static/katex-0.16.44/**/*",
|
||||
"turnstone/shared_static/design/**/*",
|
||||
"turnstone/shared_static/katex-0.16.45/**/*",
|
||||
"turnstone/shared_static/hljs-11.11.1/**/*",
|
||||
"turnstone/shared_static/mermaid-11.13.0/**/*",
|
||||
"turnstone/shared_static/mermaid-11.14.0/**/*",
|
||||
"turnstone/shared_static/hls-1.6.16/**/*",
|
||||
"turnstone/sdk/py.typed",
|
||||
"turnstone/deploy/*.yaml",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
@@ -178,5 +185,6 @@ disallow_untyped_decorators = false
|
||||
warn_unused_ignores = false
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tests.*"
|
||||
disallow_untyped_defs = false
|
||||
module = ["slack_bolt", "slack_bolt.*", "slack_sdk", "slack_sdk.*"]
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_calls = false
|
||||
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Bump version, regenerate lockfile, commit, and tag.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/release.sh 1.0.0 # stable release
|
||||
# scripts/release.sh 1.1.0a1 # experimental pre-release
|
||||
# scripts/release.sh 1.0.1 --push # bump + push tag to origin
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?Usage: scripts/release.sh VERSION [--push]}"
|
||||
PUSH="${2:-}"
|
||||
|
||||
# Validate PEP 440 version
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(a[0-9]+|b[0-9]+|rc[0-9]+)?$'; then
|
||||
echo "error: invalid PEP 440 version: $VERSION" >&2
|
||||
echo " examples: 1.0.0, 1.1.0a1, 1.0.1rc2" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="v${VERSION}"
|
||||
|
||||
# Check for clean working tree
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "error: working tree is dirty — commit or stash first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check tag doesn't already exist
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "error: tag $TAG already exists" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect current version
|
||||
CURRENT=$(grep -oP '(?<=^version = ")[^"]+' pyproject.toml)
|
||||
echo "Bumping $CURRENT → $VERSION"
|
||||
|
||||
# Update version in both files
|
||||
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
|
||||
sed -i "s/^__version__ = \".*\"/__version__ = \"$VERSION\"/" turnstone/__init__.py
|
||||
|
||||
# Regenerate lockfile
|
||||
echo "Regenerating uv.lock..."
|
||||
uv lock
|
||||
|
||||
# Commit and tag
|
||||
git add pyproject.toml turnstone/__init__.py uv.lock
|
||||
git commit -m "chore: bump version to $VERSION"
|
||||
git tag "$TAG"
|
||||
|
||||
echo ""
|
||||
echo "Created commit and tag $TAG"
|
||||
|
||||
if [ "$PUSH" = "--push" ]; then
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo "Pushing $BRANCH + $TAG to origin..."
|
||||
git push origin "$BRANCH" "$TAG"
|
||||
else
|
||||
echo "Run 'git push origin <branch> $TAG' to publish"
|
||||
fi
|
||||
@@ -5,6 +5,7 @@
|
||||
# scripts/update-vendored-js.sh katex 0.16.39
|
||||
# scripts/update-vendored-js.sh hljs 11.12.0
|
||||
# scripts/update-vendored-js.sh mermaid 11.14.0
|
||||
# scripts/update-vendored-js.sh hls 1.6.15
|
||||
#
|
||||
# This script:
|
||||
# 1. Downloads the new version from CDN
|
||||
@@ -18,7 +19,7 @@ STATIC_DIR="turnstone/shared_static"
|
||||
CDN="https://cdn.jsdelivr.net/npm"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <katex|hljs|mermaid> <version>"
|
||||
echo "Usage: $0 <katex|hljs|mermaid|hls> <version>"
|
||||
echo "Example: $0 katex 0.16.39"
|
||||
exit 1
|
||||
}
|
||||
@@ -147,12 +148,42 @@ case "$LIB" in
|
||||
echo "Done. Old directory removed: ${OLD_DIR}"
|
||||
;;
|
||||
|
||||
hls)
|
||||
OLD_VERSION=$(detect_old_version "hls")
|
||||
check_same_version "$OLD_VERSION" "$VERSION" "hls"
|
||||
OLD_DIR="${STATIC_DIR}/hls-${OLD_VERSION}"
|
||||
NEW_DIR="${STATIC_DIR}/hls-${VERSION}"
|
||||
|
||||
echo "Updating hls.js ${OLD_VERSION} -> ${VERSION}"
|
||||
mkdir -p "${NEW_DIR}"
|
||||
|
||||
echo " Downloading hls.min.js..."
|
||||
curl -sSfL "${CDN}/hls.js@${VERSION}/dist/hls.min.js" -o "${NEW_DIR}/hls.min.js"
|
||||
|
||||
echo " Downloading LICENSE..."
|
||||
if ! curl -sSfL "${CDN}/hls.js@${VERSION}/LICENSE" -o "${NEW_DIR}/LICENSE" 2>/dev/null; then
|
||||
if [[ -f "${OLD_DIR}/LICENSE" ]]; then
|
||||
cp "${OLD_DIR}/LICENSE" "${NEW_DIR}/LICENSE"
|
||||
else
|
||||
echo " WARNING: Could not obtain LICENSE for hls.js ${VERSION}"
|
||||
fi
|
||||
fi
|
||||
|
||||
update_refs "hls-${OLD_VERSION}" "hls-${VERSION}"
|
||||
rm -rf "${OLD_DIR}"
|
||||
echo "Done. Old directory removed: ${OLD_DIR}"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown library: ${LIB}"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "NOTE: If you added a NEW library (not just updating a version), also update"
|
||||
echo " the _ASSET_RE regex in turnstone/core/web_helpers.py — its negative lookahead"
|
||||
echo " skips vendored directories to avoid double-versioning static asset URLs."
|
||||
echo ""
|
||||
echo "Verify the update:"
|
||||
echo " git diff --stat"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Server API",
|
||||
"version": "0.9.2",
|
||||
"version": "1.5.0a2",
|
||||
"description": "Single-node workstream management, chat interaction, and real-time streaming."
|
||||
},
|
||||
"paths": {
|
||||
@@ -55,6 +55,7 @@
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"description": "Accepts two content types. Default is `application/json` with a `CreateWorkstreamRequest` body. Alternatively, `multipart/form-data` with one `meta` field (JSON-encoded `CreateWorkstreamRequest` shape) plus zero-or-more `file` parts saves each file as an attachment under the new workstream. When `initial_message` is also set, attachments are reserved onto that turn before the worker thread dispatches; otherwise they remain pending for a follow-up `POST /v1/api/send`.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -85,6 +86,26 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Error 409",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Error 413",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,7 +429,7 @@
|
||||
"tags": [
|
||||
"Streaming"
|
||||
],
|
||||
"description": "Global Server-Sent Events stream for state-change broadcasts across all workstreams. Returns text/event-stream.",
|
||||
"description": "Server-Sent Events stream for node-level state broadcasts. Emits a node_snapshot event on connect (workstreams, health, aggregate), followed by real-time delta events (ws_state, ws_activity, ws_created, ws_closed, ws_rename, health_changed, aggregate). Pass ?expected_node_id=X for identity verification (returns 409 on mismatch).",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
@@ -416,6 +437,426 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/workstreams/{ws_id}/delete": {
|
||||
"post": {
|
||||
"summary": "Permanently delete a saved workstream",
|
||||
"operationId": "v1_api_workstreams_{ws_id}_delete_post",
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ws_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Error 500",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/workstreams/{ws_id}/open": {
|
||||
"post": {
|
||||
"summary": "Load a saved workstream into memory",
|
||||
"operationId": "v1_api_workstreams_{ws_id}_open_post",
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ws_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Error 500",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/workstreams/{ws_id}/title": {
|
||||
"post": {
|
||||
"summary": "Set workstream title manually",
|
||||
"operationId": "v1_api_workstreams_{ws_id}_title_post",
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ws_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Error 409",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/workstreams/{ws_id}/refresh-title": {
|
||||
"post": {
|
||||
"summary": "Regenerate workstream title via LLM",
|
||||
"operationId": "v1_api_workstreams_{ws_id}_refresh-title_post",
|
||||
"tags": [
|
||||
"Workstreams"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ws_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/workstreams/{ws_id}/attachments": {
|
||||
"post": {
|
||||
"summary": "Upload a file (multipart/form-data, field 'file') and attach it to the caller's next user turn on this workstream. Validates size, MIME, and UTF-8 for text; magic-byte sniff for images. Ownership failures are masked as 404 so non-owners cannot enumerate workstream existence; a 403 indicates a scope/auth failure from the middleware layer.",
|
||||
"operationId": "v1_api_workstreams_{ws_id}_attachments_post",
|
||||
"tags": [
|
||||
"Attachments"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ws_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UploadAttachmentResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Error 403",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Error 409",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Error 413",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"summary": "List the caller's pending (unconsumed) attachments for this workstream. Ownership failures are masked as 404.",
|
||||
"operationId": "v1_api_workstreams_{ws_id}_attachments_get",
|
||||
"tags": [
|
||||
"Attachments"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ws_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListAttachmentsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Error 403",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/workstreams/{ws_id}/attachments/{attachment_id}/content": {
|
||||
"get": {
|
||||
"summary": "Return raw bytes of an attachment with its stored Content-Type. Ownership failures are masked as 404.",
|
||||
"operationId": "v1_api_workstreams_{ws_id}_attachments_{attachment_id}_content_get",
|
||||
"tags": [
|
||||
"Attachments"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ws_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "attachment_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"403": {
|
||||
"description": "Error 403",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/workstreams/{ws_id}/attachments/{attachment_id}": {
|
||||
"delete": {
|
||||
"summary": "Remove a pending attachment (consumed attachments return 404). Ownership failures are also masked as 404.",
|
||||
"operationId": "v1_api_workstreams_{ws_id}_attachments_{attachment_id}_delete",
|
||||
"tags": [
|
||||
"Attachments"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ws_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "attachment_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"403": {
|
||||
"description": "Error 403",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/workstreams/saved": {
|
||||
"get": {
|
||||
"summary": "List saved workstreams",
|
||||
@@ -891,6 +1332,106 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/settings": {
|
||||
"get": {
|
||||
"summary": "List interface.* settings with values and sources",
|
||||
"operationId": "v1_api_admin_settings_get",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/settings/{key}": {
|
||||
"put": {
|
||||
"summary": "Update an interface.* setting",
|
||||
"operationId": "v1_api_admin_settings_{key}_put",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "key",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Error 503",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Update an interface.* setting (alias for PUT)",
|
||||
"operationId": "v1_api_admin_settings_{key}_post",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "key",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Error 503",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"summary": "Server health check",
|
||||
@@ -1132,6 +1673,22 @@
|
||||
"description": "Target workstream ID",
|
||||
"title": "Ws Id",
|
||||
"type": "string"
|
||||
},
|
||||
"attachment_ids": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Explicit list of attachment ids to inject into this turn. When omitted, any pending attachments for the caller on this workstream are auto-consumed. An empty list disables auto-consumption for this send.",
|
||||
"title": "Attachment Ids"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1144,13 +1701,57 @@
|
||||
"SendResponse": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"description": "'ok' or 'busy'",
|
||||
"description": "'ok', 'busy', 'queued', or 'queue_full'",
|
||||
"examples": [
|
||||
"ok",
|
||||
"busy"
|
||||
"busy",
|
||||
"queued",
|
||||
"queue_full"
|
||||
],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"attached_ids": {
|
||||
"description": "Attachment ids actually reserved onto this turn. Subset of the request's `attachment_ids` (or the auto-consumed pending set). Empty when the send carries no attachments.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Attached Ids",
|
||||
"type": "array"
|
||||
},
|
||||
"dropped_attachment_ids": {
|
||||
"description": "Attachment ids the caller requested that the server could not reserve (lost a race, already consumed, or cross-scope). The request still proceeds with whatever was reserved; the client can retry uploads or surface a partial-attach warning.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Dropped Attachment Ids",
|
||||
"type": "array"
|
||||
},
|
||||
"priority": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Set on `queued` responses: relative priority of the queued message.",
|
||||
"title": "Priority"
|
||||
},
|
||||
"msg_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Set on `queued` responses: id used to dequeue the message.",
|
||||
"title": "Msg Id"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1289,11 +1890,75 @@
|
||||
"description": "Skill name (replaces default skills)",
|
||||
"title": "Skill",
|
||||
"type": "string"
|
||||
},
|
||||
"notify_targets": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
],
|
||||
"default": "[]",
|
||||
"description": "Notification targets, accepted as either a JSON string or a structured array of objects containing channel_type + channel_id/user_id",
|
||||
"title": "Notify Targets"
|
||||
},
|
||||
"client_type": {
|
||||
"default": "",
|
||||
"description": "Client surface type (web, cli, chat). Defaults to web for server-created sessions.",
|
||||
"title": "Client Type",
|
||||
"type": "string"
|
||||
},
|
||||
"initial_message": {
|
||||
"default": "",
|
||||
"description": "Optional first user message dispatched as a background turn after the workstream is created. When attachments are also provided (via the multipart variant), they are reserved onto this turn.",
|
||||
"title": "Initial Message",
|
||||
"type": "string"
|
||||
},
|
||||
"ws_id": {
|
||||
"default": "",
|
||||
"description": "Optional caller-supplied workstream id (32-hex). Required when creating with attachments via the cluster routing layer so the console can hash to the owning node before the multipart body lands. Auto-generated when omitted.",
|
||||
"title": "Ws Id",
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/components/schemas/WorkstreamKind",
|
||||
"default": "interactive",
|
||||
"description": "Workstream kind \u2014 'interactive' (default) or 'coordinator'. Coordinator workstreams are created by the console's own /v1/api/coordinator/new endpoint; clients hitting /v1/api/workstreams/new should leave this at the default."
|
||||
},
|
||||
"parent_ws_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Optional parent workstream id. Populated on children spawned by a coordinator so the parent/child relationship survives restart and appears in audit / list views.",
|
||||
"title": "Parent Ws Id"
|
||||
}
|
||||
},
|
||||
"title": "CreateWorkstreamRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"WorkstreamKind": {
|
||||
"description": "Classifier for which manager hosts a workstream.\n\nStrEnum so members are drop-in ``str`` replacements for the DB column,\nJSON payloads, and existing ``==`` comparisons against raw strings.\nNarrow internal annotations to this type; wide boundaries (HTTP body,\nDB row) stay ``str`` and parse via ``WorkstreamKind(raw)`` / ``from_raw``\nat the edge.",
|
||||
"enum": [
|
||||
"interactive",
|
||||
"coordinator"
|
||||
],
|
||||
"title": "WorkstreamKind",
|
||||
"type": "string"
|
||||
},
|
||||
"CreateWorkstreamResponse": {
|
||||
"properties": {
|
||||
"ws_id": {
|
||||
@@ -1317,6 +1982,14 @@
|
||||
"description": "Number of messages in the resumed workstream",
|
||||
"title": "Message Count",
|
||||
"type": "integer"
|
||||
},
|
||||
"attachment_ids": {
|
||||
"description": "Ids of attachments saved by this request (multipart variant only). Already reserved onto the initial_message turn when one was provided; otherwise left pending for a follow-up POST /v1/api/send.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Attachment Ids",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1369,6 +2042,22 @@
|
||||
"state": {
|
||||
"title": "State",
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/components/schemas/WorkstreamKind",
|
||||
"default": "interactive"
|
||||
},
|
||||
"parent_ws_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Parent Ws Id"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1493,6 +2182,27 @@
|
||||
"default": "",
|
||||
"title": "Model Alias",
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/components/schemas/WorkstreamKind",
|
||||
"default": "interactive"
|
||||
},
|
||||
"parent_ws_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Parent Ws Id"
|
||||
},
|
||||
"user_id": {
|
||||
"default": "",
|
||||
"title": "User Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1571,6 +2281,108 @@
|
||||
"title": "SavedWorkstreamInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"UploadAttachmentResponse": {
|
||||
"description": "Returned after a successful upload.",
|
||||
"properties": {
|
||||
"attachment_id": {
|
||||
"description": "Opaque id for this attachment",
|
||||
"title": "Attachment Id",
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"description": "Original upload filename",
|
||||
"title": "Filename",
|
||||
"type": "string"
|
||||
},
|
||||
"mime_type": {
|
||||
"description": "Canonicalized MIME type",
|
||||
"title": "Mime Type",
|
||||
"type": "string"
|
||||
},
|
||||
"size_bytes": {
|
||||
"description": "Payload size in bytes",
|
||||
"title": "Size Bytes",
|
||||
"type": "integer"
|
||||
},
|
||||
"kind": {
|
||||
"description": "'image' or 'text'",
|
||||
"examples": [
|
||||
"image",
|
||||
"text"
|
||||
],
|
||||
"title": "Kind",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"attachment_id",
|
||||
"filename",
|
||||
"mime_type",
|
||||
"size_bytes",
|
||||
"kind"
|
||||
],
|
||||
"title": "UploadAttachmentResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ListAttachmentsResponse": {
|
||||
"properties": {
|
||||
"attachments": {
|
||||
"description": "Pending (unconsumed) attachments for caller+workstream",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AttachmentInfo"
|
||||
},
|
||||
"title": "Attachments",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"attachments"
|
||||
],
|
||||
"title": "ListAttachmentsResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"AttachmentInfo": {
|
||||
"properties": {
|
||||
"attachment_id": {
|
||||
"description": "Opaque id for this attachment",
|
||||
"title": "Attachment Id",
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"description": "Original upload filename",
|
||||
"title": "Filename",
|
||||
"type": "string"
|
||||
},
|
||||
"mime_type": {
|
||||
"description": "Canonicalized MIME type",
|
||||
"title": "Mime Type",
|
||||
"type": "string"
|
||||
},
|
||||
"size_bytes": {
|
||||
"description": "Payload size in bytes",
|
||||
"title": "Size Bytes",
|
||||
"type": "integer"
|
||||
},
|
||||
"kind": {
|
||||
"description": "'image' or 'text'",
|
||||
"examples": [
|
||||
"image",
|
||||
"text"
|
||||
],
|
||||
"title": "Kind",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"attachment_id",
|
||||
"filename",
|
||||
"mime_type",
|
||||
"size_bytes",
|
||||
"kind"
|
||||
],
|
||||
"title": "AttachmentInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"HealthResponse": {
|
||||
"properties": {
|
||||
"status": {
|
||||
@@ -1656,20 +2468,10 @@
|
||||
],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"circuit_state": {
|
||||
"examples": [
|
||||
"closed",
|
||||
"open",
|
||||
"half_open"
|
||||
],
|
||||
"title": "Circuit State",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"circuit_state"
|
||||
"status"
|
||||
],
|
||||
"title": "BackendStatus",
|
||||
"type": "object"
|
||||
@@ -2036,6 +2838,16 @@
|
||||
},
|
||||
"title": "Models",
|
||||
"type": "array"
|
||||
},
|
||||
"default_alias": {
|
||||
"default": "",
|
||||
"title": "Default Alias",
|
||||
"type": "string"
|
||||
},
|
||||
"channel_default_alias": {
|
||||
"default": "",
|
||||
"title": "Channel Default Alias",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "ListAvailableModelsResponse",
|
||||
@@ -2043,4 +2855,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+158
-151
@@ -14,38 +14,35 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
||||
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.0",
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
|
||||
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
|
||||
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -58,9 +55,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
|
||||
"integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -77,9 +74,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.122.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
|
||||
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
|
||||
"version": "0.124.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
|
||||
"integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -87,9 +84,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -104,9 +101,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -121,9 +118,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -138,9 +135,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -155,9 +152,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -172,9 +169,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -192,9 +189,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -212,9 +209,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -232,9 +229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -252,9 +249,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -272,9 +269,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -292,9 +289,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -309,9 +306,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -319,16 +316,18 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@napi-rs/wasm-runtime": "^1.1.1"
|
||||
"@emnapi/core": "1.9.2",
|
||||
"@emnapi/runtime": "1.9.2",
|
||||
"@napi-rs/wasm-runtime": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -343,9 +342,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -360,9 +359,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -410,16 +409,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
|
||||
"integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
|
||||
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"@vitest/spy": "4.1.4",
|
||||
"@vitest/utils": "4.1.4",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -428,13 +427,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
|
||||
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
|
||||
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.2",
|
||||
"@vitest/spy": "4.1.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -455,9 +454,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
|
||||
"integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
|
||||
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -468,13 +467,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
|
||||
"integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
|
||||
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.2",
|
||||
"@vitest/utils": "4.1.4",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -482,14 +481,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
|
||||
"integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
|
||||
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"@vitest/pretty-format": "4.1.4",
|
||||
"@vitest/utils": "4.1.4",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -498,9 +497,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
|
||||
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
|
||||
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -508,13 +507,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
|
||||
"integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
|
||||
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"@vitest/pretty-format": "4.1.4",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -960,9 +959,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -989,14 +988,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.122.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.12"
|
||||
"@oxc-project/types": "=0.124.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.15"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -1005,21 +1004,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
@@ -1047,9 +1046,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
|
||||
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1061,9 +1060,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
|
||||
"integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
|
||||
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1071,14 +1070,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -1106,9 +1105,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1120,16 +1119,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
|
||||
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
|
||||
"version": "8.0.8",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
|
||||
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.12",
|
||||
"rolldown": "1.0.0-rc.15",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1147,7 +1146,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
@@ -1198,19 +1197,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
|
||||
"integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
|
||||
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.2",
|
||||
"@vitest/mocker": "4.1.2",
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"@vitest/runner": "4.1.2",
|
||||
"@vitest/snapshot": "4.1.2",
|
||||
"@vitest/spy": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"@vitest/expect": "4.1.4",
|
||||
"@vitest/mocker": "4.1.4",
|
||||
"@vitest/pretty-format": "4.1.4",
|
||||
"@vitest/runner": "4.1.4",
|
||||
"@vitest/snapshot": "4.1.4",
|
||||
"@vitest/spy": "4.1.4",
|
||||
"@vitest/utils": "4.1.4",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -1238,10 +1237,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.2",
|
||||
"@vitest/browser-preview": "4.1.2",
|
||||
"@vitest/browser-webdriverio": "4.1.2",
|
||||
"@vitest/ui": "4.1.2",
|
||||
"@vitest/browser-playwright": "4.1.4",
|
||||
"@vitest/browser-preview": "4.1.4",
|
||||
"@vitest/browser-webdriverio": "4.1.4",
|
||||
"@vitest/coverage-istanbul": "4.1.4",
|
||||
"@vitest/coverage-v8": "4.1.4",
|
||||
"@vitest/ui": "4.1.4",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
@@ -1265,6 +1266,12 @@
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
|
||||
+69
-16
@@ -29,6 +29,12 @@ export interface ClientOptions {
|
||||
export interface RequestOptions {
|
||||
json?: object;
|
||||
params?: Record<string, string | number>;
|
||||
/**
|
||||
* When set, send as multipart form-data with this body. The runtime's
|
||||
* fetch sets the Content-Type + boundary itself, so we deliberately do
|
||||
* not include a Content-Type header in this case.
|
||||
*/
|
||||
form?: FormData;
|
||||
}
|
||||
|
||||
export class BaseClient {
|
||||
@@ -47,36 +53,34 @@ export class BaseClient {
|
||||
path: string,
|
||||
options?: RequestOptions,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const headers: Record<string, string> = {};
|
||||
if (!options?.form) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
if (this.token) {
|
||||
headers["Authorization"] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
let url = `${this.baseUrl}${path}`;
|
||||
if (options?.params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(options.params)) {
|
||||
if (value !== undefined && value !== "") {
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const qs = searchParams.toString();
|
||||
if (qs) url += `?${qs}`;
|
||||
const url = this._buildUrl(path, options?.params);
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (options?.form) {
|
||||
body = options.form;
|
||||
} else if (options?.json) {
|
||||
body = JSON.stringify(options.json);
|
||||
}
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method,
|
||||
headers,
|
||||
body: options?.json ? JSON.stringify(options.json) : undefined,
|
||||
body,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
let msg = "";
|
||||
try {
|
||||
const body = (await resp.json()) as Record<string, unknown>;
|
||||
msg = (body.error as string) ?? (body.detail as string) ?? "";
|
||||
const errBody = (await resp.json()) as Record<string, unknown>;
|
||||
msg = (errBody.error as string) ?? (errBody.detail as string) ?? "";
|
||||
} catch {
|
||||
msg = await resp.text().catch(() => "");
|
||||
}
|
||||
@@ -86,6 +90,55 @@ export class BaseClient {
|
||||
return (await resp.json()) as T;
|
||||
}
|
||||
|
||||
protected async requestBytes(
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { params?: Record<string, string | number> },
|
||||
): Promise<{ bytes: Uint8Array; contentType: string; filename: string }> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.token) {
|
||||
headers["Authorization"] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const url = this._buildUrl(path, options?.params);
|
||||
const resp = await this.fetchFn(url, { method, headers });
|
||||
if (!resp.ok) {
|
||||
let msg = "";
|
||||
try {
|
||||
const errBody = (await resp.json()) as Record<string, unknown>;
|
||||
msg = (errBody.error as string) ?? (errBody.detail as string) ?? "";
|
||||
} catch {
|
||||
msg = await resp.text().catch(() => "");
|
||||
}
|
||||
throw new TurnstoneAPIError(resp.status, msg || `HTTP ${resp.status}`);
|
||||
}
|
||||
const contentType =
|
||||
resp.headers.get("content-type") ?? "application/octet-stream";
|
||||
const disposition = resp.headers.get("content-disposition") ?? "";
|
||||
const match = /filename="?([^";]+)"?/.exec(disposition);
|
||||
const filename = match ? match[1] : "";
|
||||
const buf = await resp.arrayBuffer();
|
||||
return { bytes: new Uint8Array(buf), contentType, filename };
|
||||
}
|
||||
|
||||
private _buildUrl(
|
||||
path: string,
|
||||
params?: Record<string, string | number>,
|
||||
): string {
|
||||
let url = `${this.baseUrl}${path}`;
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== "") {
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const qs = searchParams.toString();
|
||||
if (qs) url += `?${qs}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
protected async *streamSSE<T = Record<string, unknown>>(
|
||||
path: string,
|
||||
params?: Record<string, string | number>,
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
AdminListMemoriesOptions,
|
||||
AdminMemoryInfo,
|
||||
AdminSearchMemoriesOptions,
|
||||
AttachmentContent,
|
||||
AttachmentUpload,
|
||||
AuditQueryOptions,
|
||||
AuditResponse,
|
||||
AuthLoginResponse,
|
||||
@@ -16,6 +18,9 @@ import type {
|
||||
ConsoleCreateWsRequest,
|
||||
ConsoleCreateWsResponse,
|
||||
ConsoleHealthResponse,
|
||||
CreateWorkstreamRequest,
|
||||
CreateWorkstreamResponse,
|
||||
ListAttachmentsResponse,
|
||||
CreateMcpServerRequest,
|
||||
CreatePolicyOptions,
|
||||
CreateRoleOptions,
|
||||
@@ -55,12 +60,37 @@ import type {
|
||||
UpdateScheduleRequest,
|
||||
UpdateSettingOptions,
|
||||
UpdateSkillRequest,
|
||||
UploadAttachmentResponse,
|
||||
UsageQueryOptions,
|
||||
UsageResponse,
|
||||
UserRoleInfo,
|
||||
WorkstreamsOptions,
|
||||
} from "./types.js";
|
||||
|
||||
function generateConsoleWsId(): string {
|
||||
// 16 bytes => 32 hex chars; matches `secrets.token_hex(16)` server-side.
|
||||
const buf = new Uint8Array(16);
|
||||
crypto.getRandomValues(buf);
|
||||
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function consoleAttachmentToBlob(att: AttachmentUpload): Blob {
|
||||
if (att.data instanceof Blob) {
|
||||
return att.mimeType
|
||||
? new Blob([att.data], { type: att.mimeType })
|
||||
: att.data;
|
||||
}
|
||||
// Copy bytes into a fresh ArrayBuffer-backed Uint8Array. The Blob
|
||||
// BlobPart type rejects ArrayBufferLike views (could be backed by
|
||||
// SharedArrayBuffer); a freshly allocated buffer is plainly ArrayBuffer.
|
||||
const src = att.data;
|
||||
const fresh = new Uint8Array(new ArrayBuffer(src.byteLength));
|
||||
fresh.set(src);
|
||||
return new Blob([fresh], {
|
||||
type: att.mimeType ?? "application/octet-stream",
|
||||
});
|
||||
}
|
||||
|
||||
/** Async client for the turnstone console API. */
|
||||
export class TurnstoneConsole extends BaseClient {
|
||||
constructor(options: ClientOptions) {
|
||||
@@ -113,6 +143,92 @@ export class TurnstoneConsole extends BaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
// -- Routing proxy --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a workstream via the console rendezvous router.
|
||||
*
|
||||
* When `attachments` is non-empty the request is sent as
|
||||
* multipart/form-data and the console routes via `?ws_id=<hex>`
|
||||
* (auto-generated when not supplied) so the body lands on the
|
||||
* owning node directly.
|
||||
*/
|
||||
async routeCreateWorkstream(
|
||||
opts?: CreateWorkstreamRequest & { target_node?: string },
|
||||
): Promise<
|
||||
CreateWorkstreamResponse & { node_url?: string; node_id?: string }
|
||||
> {
|
||||
const attachments = opts?.attachments;
|
||||
if (attachments && attachments.length > 0) {
|
||||
// The console's multipart route_create routes by `?ws_id=` only —
|
||||
// it does not parse the body to honor `target_node`. Refuse the
|
||||
// combination at the SDK boundary so callers don't silently get
|
||||
// routed to the wrong node.
|
||||
if (opts?.target_node) {
|
||||
throw new Error(
|
||||
"target_node is not supported with attachments; " +
|
||||
"use ws_id (caller-generated to hash to the desired node) instead",
|
||||
);
|
||||
}
|
||||
const meta: Record<string, unknown> = { ...opts };
|
||||
delete (meta as { attachments?: unknown }).attachments;
|
||||
let wsId = (meta.ws_id as string | undefined) ?? "";
|
||||
if (!wsId) {
|
||||
wsId = generateConsoleWsId();
|
||||
meta.ws_id = wsId;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append("meta", JSON.stringify(meta));
|
||||
for (const att of attachments) {
|
||||
form.append("file", consoleAttachmentToBlob(att), att.filename);
|
||||
}
|
||||
return this.request("POST", "/v1/api/route/workstreams/new", {
|
||||
form,
|
||||
params: { ws_id: wsId },
|
||||
});
|
||||
}
|
||||
return this.request("POST", "/v1/api/route/workstreams/new", {
|
||||
json: opts ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
async routeUploadAttachment(
|
||||
wsId: string,
|
||||
file: AttachmentUpload,
|
||||
): Promise<UploadAttachmentResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", consoleAttachmentToBlob(file), file.filename);
|
||||
return this.request(
|
||||
"POST",
|
||||
`/v1/api/route/workstreams/${wsId}/attachments`,
|
||||
{ form },
|
||||
);
|
||||
}
|
||||
|
||||
async routeListAttachments(wsId: string): Promise<ListAttachmentsResponse> {
|
||||
return this.request("GET", `/v1/api/route/workstreams/${wsId}/attachments`);
|
||||
}
|
||||
|
||||
async routeGetAttachmentContent(
|
||||
wsId: string,
|
||||
attachmentId: string,
|
||||
): Promise<AttachmentContent> {
|
||||
return this.requestBytes(
|
||||
"GET",
|
||||
`/v1/api/route/workstreams/${wsId}/attachments/${attachmentId}/content`,
|
||||
);
|
||||
}
|
||||
|
||||
async routeDeleteAttachment(
|
||||
wsId: string,
|
||||
attachmentId: string,
|
||||
): Promise<StatusResponse> {
|
||||
return this.request(
|
||||
"DELETE",
|
||||
`/v1/api/route/workstreams/${wsId}/attachments/${attachmentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// -- Streaming ------------------------------------------------------------
|
||||
|
||||
async *clusterEvents(): AsyncIterableIterator<ClusterEvent> {
|
||||
|
||||
@@ -92,6 +92,11 @@ export interface PlanReviewEvent {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface PlanResolvedEvent {
|
||||
type: "plan_resolved";
|
||||
feedback: string;
|
||||
}
|
||||
|
||||
export interface InfoEvent {
|
||||
type: "info";
|
||||
message: string;
|
||||
@@ -165,6 +170,7 @@ export type ServerEvent =
|
||||
| ToolOutputChunkEvent
|
||||
| StatusEvent
|
||||
| PlanReviewEvent
|
||||
| PlanResolvedEvent
|
||||
| InfoEvent
|
||||
| ErrorEvent
|
||||
| BusyErrorEvent
|
||||
@@ -283,6 +289,10 @@ export function isPlanReviewEvent(e: ServerEvent): e is PlanReviewEvent {
|
||||
return e.type === "plan_review";
|
||||
}
|
||||
|
||||
export function isPlanResolvedEvent(e: ServerEvent): e is PlanResolvedEvent {
|
||||
return e.type === "plan_resolved";
|
||||
}
|
||||
|
||||
export function isCancelledEvent(e: ServerEvent): e is CancelledEvent {
|
||||
return e.type === "cancelled";
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* const client = new TurnstoneServer({
|
||||
* baseUrl: "http://localhost:8080",
|
||||
* token: "tok_xxx",
|
||||
* token: "ts_your_api_token",
|
||||
* });
|
||||
*
|
||||
* const ws = await client.createWorkstream({ name: "demo" });
|
||||
@@ -183,6 +183,12 @@ export type {
|
||||
SkillInstallRequest,
|
||||
SkillInstallResponse,
|
||||
SkillInstallSkipped,
|
||||
// Attachment types
|
||||
AttachmentUpload,
|
||||
AttachmentInfo,
|
||||
UploadAttachmentResponse,
|
||||
ListAttachmentsResponse,
|
||||
AttachmentContent,
|
||||
} from "./types.js";
|
||||
|
||||
// SSE parser (for advanced usage)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BaseClient, type ClientOptions } from "./base.js";
|
||||
import type { ServerEvent } from "./events.js";
|
||||
import type {
|
||||
AttachmentContent,
|
||||
AttachmentUpload,
|
||||
AuthLoginResponse,
|
||||
AuthSetupResponse,
|
||||
AuthStatusResponse,
|
||||
@@ -9,20 +11,46 @@ import type {
|
||||
DashboardResponse,
|
||||
DeleteMemoryOptions,
|
||||
HealthResponse,
|
||||
ListAttachmentsResponse,
|
||||
ListMemoriesOptions,
|
||||
ListMemoriesResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
SkillSummary,
|
||||
ListWorkstreamsResponse,
|
||||
MemoryInfo,
|
||||
SaveMemoryRequest,
|
||||
SearchMemoriesRequest,
|
||||
SendAndWaitOptions,
|
||||
SendResponse,
|
||||
SkillSummary,
|
||||
StatusResponse,
|
||||
TurnResult,
|
||||
UploadAttachmentResponse,
|
||||
} from "./types.js";
|
||||
|
||||
function generateWsId(): string {
|
||||
// 16 bytes => 32 hex chars; matches `secrets.token_hex(16)` server-side.
|
||||
const buf = new Uint8Array(16);
|
||||
crypto.getRandomValues(buf);
|
||||
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function attachmentToBlob(att: AttachmentUpload): Blob {
|
||||
if (att.data instanceof Blob) {
|
||||
return att.mimeType
|
||||
? new Blob([att.data], { type: att.mimeType })
|
||||
: att.data;
|
||||
}
|
||||
// Copy bytes into a fresh ArrayBuffer-backed Uint8Array. The Blob
|
||||
// BlobPart type rejects ArrayBufferLike views (could be backed by
|
||||
// SharedArrayBuffer); a freshly allocated buffer is plainly ArrayBuffer.
|
||||
const src = att.data;
|
||||
const fresh = new Uint8Array(new ArrayBuffer(src.byteLength));
|
||||
fresh.set(src);
|
||||
return new Blob([fresh], {
|
||||
type: att.mimeType ?? "application/octet-stream",
|
||||
});
|
||||
}
|
||||
|
||||
/** Async client for the turnstone server API. */
|
||||
export class TurnstoneServer extends BaseClient {
|
||||
constructor(options: ClientOptions) {
|
||||
@@ -42,7 +70,27 @@ export class TurnstoneServer extends BaseClient {
|
||||
async createWorkstream(
|
||||
opts?: CreateWorkstreamRequest,
|
||||
): Promise<CreateWorkstreamResponse> {
|
||||
return this.request("POST", "/v1/api/workstreams/new", { json: opts });
|
||||
const attachments = opts?.attachments;
|
||||
if (attachments && attachments.length > 0) {
|
||||
// Multipart variant: pre-generate ws_id so cluster routers can
|
||||
// hash to the owning node before this body lands. Server accepts
|
||||
// either a server-generated id (when meta.ws_id is empty) or the
|
||||
// caller-supplied one.
|
||||
const meta: Record<string, unknown> = { ...opts };
|
||||
delete (meta as { attachments?: unknown }).attachments;
|
||||
if (!meta.ws_id) {
|
||||
meta.ws_id = generateWsId();
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append("meta", JSON.stringify(meta));
|
||||
for (const att of attachments) {
|
||||
form.append("file", attachmentToBlob(att), att.filename);
|
||||
}
|
||||
return this.request("POST", "/v1/api/workstreams/new", { form });
|
||||
}
|
||||
return this.request("POST", "/v1/api/workstreams/new", {
|
||||
json: opts ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
async closeWorkstream(wsId: string): Promise<StatusResponse> {
|
||||
@@ -53,12 +101,55 @@ export class TurnstoneServer extends BaseClient {
|
||||
|
||||
// -- Chat interaction -----------------------------------------------------
|
||||
|
||||
async send(message: string, wsId: string): Promise<SendResponse> {
|
||||
return this.request("POST", "/v1/api/send", {
|
||||
json: { message, ws_id: wsId },
|
||||
async send(
|
||||
message: string,
|
||||
wsId: string,
|
||||
opts?: { attachmentIds?: string[] },
|
||||
): Promise<SendResponse> {
|
||||
const body: Record<string, unknown> = { message, ws_id: wsId };
|
||||
if (opts?.attachmentIds !== undefined) {
|
||||
body.attachment_ids = opts.attachmentIds;
|
||||
}
|
||||
return this.request("POST", "/v1/api/send", { json: body });
|
||||
}
|
||||
|
||||
// -- Attachments ----------------------------------------------------------
|
||||
|
||||
async uploadAttachment(
|
||||
wsId: string,
|
||||
file: AttachmentUpload,
|
||||
): Promise<UploadAttachmentResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", attachmentToBlob(file), file.filename);
|
||||
return this.request("POST", `/v1/api/workstreams/${wsId}/attachments`, {
|
||||
form,
|
||||
});
|
||||
}
|
||||
|
||||
async listAttachments(wsId: string): Promise<ListAttachmentsResponse> {
|
||||
return this.request("GET", `/v1/api/workstreams/${wsId}/attachments`);
|
||||
}
|
||||
|
||||
async getAttachmentContent(
|
||||
wsId: string,
|
||||
attachmentId: string,
|
||||
): Promise<AttachmentContent> {
|
||||
return this.requestBytes(
|
||||
"GET",
|
||||
`/v1/api/workstreams/${wsId}/attachments/${attachmentId}/content`,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAttachment(
|
||||
wsId: string,
|
||||
attachmentId: string,
|
||||
): Promise<StatusResponse> {
|
||||
return this.request(
|
||||
"DELETE",
|
||||
`/v1/api/workstreams/${wsId}/attachments/${attachmentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async approve(opts: {
|
||||
wsId: string;
|
||||
approved?: boolean;
|
||||
|
||||
@@ -50,10 +50,67 @@ export interface AuthSetupResponse {
|
||||
export interface SendRequest {
|
||||
message: string;
|
||||
ws_id: string;
|
||||
/**
|
||||
* Explicit list of pending attachment ids to inject into this turn.
|
||||
* When omitted, any pending attachments for the caller on the
|
||||
* workstream are auto-consumed; an empty list disables auto-consume.
|
||||
*/
|
||||
attachment_ids?: string[];
|
||||
}
|
||||
|
||||
export interface SendResponse {
|
||||
/** "ok" | "busy" | "queued" | "queue_full". */
|
||||
status: string;
|
||||
/**
|
||||
* Attachment ids actually reserved onto this turn. Subset of the
|
||||
* request's `attachment_ids` (or the auto-consumed pending set).
|
||||
*/
|
||||
attached_ids?: string[];
|
||||
/**
|
||||
* Attachment ids the caller requested that the server could not
|
||||
* reserve (lost a race, already consumed, or cross-scope). The
|
||||
* request still proceeds with whatever was reserved.
|
||||
*/
|
||||
dropped_attachment_ids?: string[];
|
||||
/** Set on "queued" responses: relative priority of the queued message. */
|
||||
priority?: string | null;
|
||||
/** Set on "queued" responses: id used to dequeue the message. */
|
||||
msg_id?: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server API — Attachments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A file to upload as an attachment. */
|
||||
export interface AttachmentUpload {
|
||||
filename: string;
|
||||
/** Raw file bytes; use a `Blob` in browsers and a `Uint8Array` in Node. */
|
||||
data: Blob | Uint8Array;
|
||||
/** Optional advisory MIME type; the server applies its own validation. */
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface AttachmentInfo {
|
||||
attachment_id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
/** "image" or "text". */
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export type UploadAttachmentResponse = AttachmentInfo;
|
||||
|
||||
export interface ListAttachmentsResponse {
|
||||
attachments: AttachmentInfo[];
|
||||
}
|
||||
|
||||
/** Raw bytes returned from the attachment `/content` endpoint. */
|
||||
export interface AttachmentContent {
|
||||
bytes: Uint8Array;
|
||||
contentType: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface ApproveRequest {
|
||||
@@ -79,6 +136,20 @@ export interface CreateWorkstreamRequest {
|
||||
auto_approve?: boolean;
|
||||
resume_ws?: string;
|
||||
skill?: string;
|
||||
/** First user message dispatched in a background worker after creation. */
|
||||
initial_message?: string;
|
||||
/**
|
||||
* Caller-supplied workstream id (32-hex). Auto-generated when omitted.
|
||||
* Required for cluster-routed multipart creates so the console can
|
||||
* hash to the owning node before the body lands.
|
||||
*/
|
||||
ws_id?: string;
|
||||
/**
|
||||
* Files to attach to the first turn. When non-empty the request is
|
||||
* sent as multipart/form-data and (with `initial_message`) reserved
|
||||
* onto that turn before the worker dispatches.
|
||||
*/
|
||||
attachments?: AttachmentUpload[];
|
||||
}
|
||||
|
||||
export interface CreateWorkstreamResponse {
|
||||
@@ -86,6 +157,8 @@ export interface CreateWorkstreamResponse {
|
||||
name: string;
|
||||
resumed?: boolean;
|
||||
message_count?: number;
|
||||
/** Ids of attachments saved by this request (multipart variant only). */
|
||||
attachment_ids?: string[];
|
||||
}
|
||||
|
||||
export interface CloseWorkstreamRequest {
|
||||
@@ -284,7 +357,6 @@ export interface CreateSkillResourceRequest {
|
||||
|
||||
export interface BackendStatus {
|
||||
status: string;
|
||||
circuit_state: string;
|
||||
}
|
||||
|
||||
export interface WorkstreamCounts {
|
||||
@@ -880,7 +952,7 @@ export interface SkillDiscoverListing {
|
||||
install_count: number;
|
||||
tags: string[];
|
||||
installed: boolean;
|
||||
scan_status?: string;
|
||||
risk_level?: string;
|
||||
template_id?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,28 @@ describe("TurnstoneConsole", () => {
|
||||
expect(url).toContain("page=2");
|
||||
});
|
||||
|
||||
it("routeCreateWorkstream rejects attachments + target_node", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(
|
||||
new Response("{}", {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const client = new TurnstoneConsole({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
const data = new TextEncoder().encode("hi");
|
||||
await expect(
|
||||
client.routeCreateWorkstream({
|
||||
name: "x",
|
||||
target_node: "n1",
|
||||
attachments: [{ filename: "a.txt", data }],
|
||||
}),
|
||||
).rejects.toThrow(/target_node/);
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("health returns parsed response", async () => {
|
||||
const fetchFn = mockFetch({
|
||||
status: "ok",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isApproveRequestEvent,
|
||||
isApprovalResolvedEvent,
|
||||
isPlanReviewEvent,
|
||||
isPlanResolvedEvent,
|
||||
isReasoningEvent,
|
||||
} from "../src/events.js";
|
||||
import type { ServerEvent } from "../src/events.js";
|
||||
@@ -76,4 +77,9 @@ describe("event type guards", () => {
|
||||
const e: ServerEvent = { type: "plan_review", content: "## Plan" };
|
||||
expect(isPlanReviewEvent(e)).toBe(true);
|
||||
});
|
||||
|
||||
it("isPlanResolvedEvent", () => {
|
||||
const e: ServerEvent = { type: "plan_resolved", feedback: "approved" };
|
||||
expect(isPlanResolvedEvent(e)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TurnstoneServer } from "../src/server.js";
|
||||
|
||||
function mockFetch(response: object, status = 200): typeof globalThis.fetch {
|
||||
return vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(response), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchBytes(
|
||||
body: Uint8Array,
|
||||
contentType: string,
|
||||
filename = "",
|
||||
): typeof globalThis.fetch {
|
||||
const headers: Record<string, string> = { "content-type": contentType };
|
||||
if (filename)
|
||||
headers["content-disposition"] = `inline; filename="${filename}"`;
|
||||
return vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(body, { status: 200, headers }));
|
||||
}
|
||||
|
||||
describe("TurnstoneServer attachments", () => {
|
||||
it("uploadAttachment sends multipart with filename", async () => {
|
||||
const fetchFn = mockFetch({
|
||||
attachment_id: "att-1",
|
||||
filename: "a.txt",
|
||||
mime_type: "text/plain",
|
||||
size_bytes: 5,
|
||||
kind: "text",
|
||||
});
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
const data = new TextEncoder().encode("hello");
|
||||
const result = await client.uploadAttachment("ws-X", {
|
||||
filename: "a.txt",
|
||||
data,
|
||||
mimeType: "text/plain",
|
||||
});
|
||||
expect(result.attachment_id).toBe("att-1");
|
||||
|
||||
const [url, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("http://test/v1/api/workstreams/ws-X/attachments");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.body).toBeInstanceOf(FormData);
|
||||
// Browser/Node fetch sets the Content-Type header from FormData itself
|
||||
expect(init.headers["Content-Type"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("listAttachments hits the GET endpoint", async () => {
|
||||
const fetchFn = mockFetch({ attachments: [] });
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
const resp = await client.listAttachments("ws-X");
|
||||
expect(resp.attachments).toEqual([]);
|
||||
const [url, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("http://test/v1/api/workstreams/ws-X/attachments");
|
||||
expect(init.method).toBe("GET");
|
||||
});
|
||||
|
||||
it("getAttachmentContent returns raw bytes + parsed headers", async () => {
|
||||
const bytes = new TextEncoder().encode("hello world");
|
||||
const fetchFn = mockFetchBytes(
|
||||
bytes,
|
||||
"text/plain; charset=utf-8",
|
||||
"notes.md",
|
||||
);
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
const result = await client.getAttachmentContent("ws-X", "att-1");
|
||||
expect(new TextDecoder().decode(result.bytes)).toBe("hello world");
|
||||
expect(result.contentType).toBe("text/plain; charset=utf-8");
|
||||
expect(result.filename).toBe("notes.md");
|
||||
});
|
||||
|
||||
it("deleteAttachment hits the DELETE endpoint", async () => {
|
||||
const fetchFn = mockFetch({ status: "deleted" });
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
const resp = await client.deleteAttachment("ws-X", "att-1");
|
||||
expect(resp.status).toBe("deleted");
|
||||
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("send threads attachment_ids when provided", async () => {
|
||||
const fetchFn = mockFetch({ status: "ok" });
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
await client.send("hi", "ws-X", { attachmentIds: ["a1", "a2"] });
|
||||
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
message: "hi",
|
||||
ws_id: "ws-X",
|
||||
attachment_ids: ["a1", "a2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("send omits attachment_ids when not supplied", async () => {
|
||||
const fetchFn = mockFetch({ status: "ok" });
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
await client.send("hi", "ws-X");
|
||||
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(JSON.parse(init.body)).toEqual({ message: "hi", ws_id: "ws-X" });
|
||||
});
|
||||
|
||||
it("createWorkstream with attachments sends multipart and auto-generates ws_id", async () => {
|
||||
const fetchFn = mockFetch({
|
||||
ws_id: "00ff00000000000000000000000000ff",
|
||||
name: "demo",
|
||||
attachment_ids: ["att-1"],
|
||||
});
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
const data = new TextEncoder().encode("hello");
|
||||
const resp = await client.createWorkstream({
|
||||
name: "demo",
|
||||
initial_message: "describe",
|
||||
attachments: [{ filename: "a.txt", data, mimeType: "text/plain" }],
|
||||
});
|
||||
expect(resp.attachment_ids).toEqual(["att-1"]);
|
||||
|
||||
const [url, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("http://test/v1/api/workstreams/new");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.body).toBeInstanceOf(FormData);
|
||||
|
||||
const form = init.body as FormData;
|
||||
const meta = JSON.parse(form.get("meta") as string);
|
||||
expect(meta.name).toBe("demo");
|
||||
expect(meta.initial_message).toBe("describe");
|
||||
expect(meta.ws_id).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(meta.attachments).toBeUndefined();
|
||||
|
||||
const file = form.get("file");
|
||||
expect(file).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it("createWorkstream without attachments uses JSON body", async () => {
|
||||
const fetchFn = mockFetch({ ws_id: "ws-json", name: "j" });
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "http://test",
|
||||
fetch: fetchFn,
|
||||
});
|
||||
await client.createWorkstream({ name: "j" });
|
||||
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(init.headers["Content-Type"]).toBe("application/json");
|
||||
expect(JSON.parse(init.body)).toEqual({ name: "j" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Shared builders for the coordinator-endpoint test files.
|
||||
|
||||
The four coordinator test modules each ship a copy of the same
|
||||
``_AuthMiddleware`` / ``_FakeConfigStore`` / ``_fake_registry`` /
|
||||
``_build_mgr`` helpers — this module is the single home for them so
|
||||
future edits land once. Named with a leading underscore so pytest
|
||||
does not collect it.
|
||||
|
||||
``_make_client`` stays local to each test module because the route
|
||||
list differs per file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from turnstone.console.coordinator import CoordinatorManager
|
||||
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
|
||||
from turnstone.core.auth import AuthResult
|
||||
|
||||
|
||||
class _AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Inject a configurable AuthResult from a header-based contract.
|
||||
|
||||
Tests set ``X-Test-Perms`` to a comma-separated permission list, and
|
||||
``X-Test-User`` to the user id. Empty or missing → no auth.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request, call_next): # type: ignore[no-untyped-def]
|
||||
perms = request.headers.get("X-Test-Perms", "")
|
||||
user_id = request.headers.get("X-Test-User", "")
|
||||
if perms or user_id:
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id=user_id,
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="test",
|
||||
permissions=frozenset(p for p in perms.split(",") if p),
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class _FakeConfigStore:
|
||||
"""Minimal ConfigStore stub — returns values from a dict."""
|
||||
|
||||
def __init__(self, values: dict[str, Any]) -> None:
|
||||
self._values = values
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self._values.get(key, default)
|
||||
|
||||
|
||||
def _fake_registry() -> MagicMock:
|
||||
"""MagicMock whose ``.resolve()`` succeeds so the 503 gate passes."""
|
||||
reg = MagicMock()
|
||||
reg.resolve.return_value = (MagicMock(), "gpt-4", MagicMock())
|
||||
return reg
|
||||
|
||||
|
||||
def _build_mgr(storage: Any) -> CoordinatorManager:
|
||||
"""Build a CoordinatorManager with stub factories (test default)."""
|
||||
|
||||
def _sf(ui, model_alias=None, ws_id=None, **kw): # type: ignore[no-untyped-def]
|
||||
s = MagicMock()
|
||||
s.send.return_value = None
|
||||
return s
|
||||
|
||||
return CoordinatorManager(
|
||||
session_factory=_sf,
|
||||
ui_factory=lambda w, u: ConsoleCoordinatorUI(ws_id=w, user_id=u),
|
||||
storage=storage,
|
||||
max_active=3,
|
||||
)
|
||||
@@ -6,6 +6,37 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# Shared test auth — JWT-based
|
||||
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
|
||||
def _server_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-versioning",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_SERVER,
|
||||
)
|
||||
|
||||
|
||||
def _console_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-versioning",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
|
||||
|
||||
_SERVER_AUTH_HEADERS = {"Authorization": f"Bearer {_server_jwt()}"}
|
||||
_CONSOLE_AUTH_HEADERS = {"Authorization": f"Bearer {_console_jwt()}"}
|
||||
|
||||
|
||||
class TestServerVersioning:
|
||||
"""Test /v1/ routes and OpenAPI endpoints on the server."""
|
||||
@@ -14,7 +45,6 @@ class TestServerVersioning:
|
||||
def client(self):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.core.auth import AuthConfig
|
||||
from turnstone.server import create_app
|
||||
|
||||
mock_mgr = MagicMock()
|
||||
@@ -26,19 +56,19 @@ class TestServerVersioning:
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
def test_v1_workstreams(self, client):
|
||||
resp = client.get("/v1/api/workstreams")
|
||||
resp = client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
assert "workstreams" in resp.json()
|
||||
|
||||
def test_unversioned_api_404(self, client):
|
||||
resp = client.get("/api/workstreams")
|
||||
resp = client.get("/api/workstreams", headers=_SERVER_AUTH_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_openapi_json(self, client):
|
||||
@@ -72,7 +102,6 @@ class TestConsoleVersioning:
|
||||
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
@@ -84,18 +113,18 @@ class TestConsoleVersioning:
|
||||
}
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
def test_v1_cluster_overview(self, client):
|
||||
resp = client.get("/v1/api/cluster/overview")
|
||||
resp = client.get("/v1/api/cluster/overview", headers=_CONSOLE_AUTH_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_unversioned_api_404(self, client):
|
||||
resp = client.get("/api/cluster/overview")
|
||||
resp = client.get("/api/cluster/overview", headers=_CONSOLE_AUTH_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_openapi_json(self, client):
|
||||
|
||||
@@ -56,3 +56,155 @@ def test_record_audit_generates_unique_ids(storage):
|
||||
events = storage.list_audit_events()
|
||||
assert len(events) == 2
|
||||
assert events[0]["event_id"] != events[1]["event_id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential redaction at the audit boundary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_record_audit_redacts_passwords_by_default(storage):
|
||||
"""Detail strings go through redact_credentials by default."""
|
||||
record_audit(
|
||||
storage,
|
||||
"u1",
|
||||
"coordinator.spawn",
|
||||
detail={
|
||||
"initial_message": "connect via postgresql://alice:s3cret@db.example.com/app",
|
||||
},
|
||||
)
|
||||
event = storage.list_audit_events()[0]
|
||||
detail = json.loads(event["detail"])
|
||||
# Exact redaction text comes from output_guard._redact_credentials —
|
||||
# assert the token is stripped rather than the exact marker so
|
||||
# this test doesn't break if the marker format evolves.
|
||||
assert "s3cret" not in detail["initial_message"]
|
||||
assert "REDACTED" in detail["initial_message"]
|
||||
|
||||
|
||||
def test_record_audit_redacts_nested_strings(storage):
|
||||
"""Walker descends into lists / nested dicts."""
|
||||
record_audit(
|
||||
storage,
|
||||
"u1",
|
||||
"task_list.update",
|
||||
detail={
|
||||
"tasks": [
|
||||
{"title": "normal task"},
|
||||
{"title": "pull secret from AWS_SECRET_ACCESS_KEY=AKIAEXAMPLE123"},
|
||||
],
|
||||
},
|
||||
)
|
||||
event = storage.list_audit_events()[0]
|
||||
detail = json.loads(event["detail"])
|
||||
assert detail["tasks"][0]["title"] == "normal task"
|
||||
assert "AKIAEXAMPLE123" not in detail["tasks"][1]["title"]
|
||||
|
||||
|
||||
def test_record_audit_raw_detail_preserves_payload(storage):
|
||||
"""`raw_detail=True` bypasses the scrub — operator-originated detail only."""
|
||||
secret_like = "postgresql://alice:s3cret@db.example.com/app"
|
||||
record_audit(
|
||||
storage,
|
||||
"admin-1",
|
||||
"investigation.note",
|
||||
detail={"note": secret_like},
|
||||
raw_detail=True,
|
||||
)
|
||||
event = storage.list_audit_events()[0]
|
||||
detail = json.loads(event["detail"])
|
||||
assert detail["note"] == secret_like
|
||||
|
||||
|
||||
def test_record_audit_strips_control_chars(storage):
|
||||
"""CR/LF/NUL/DEL and C0 controls are replaced with spaces so a
|
||||
downstream exporter that prints raw detail strings can't re-surface
|
||||
log-injection. Tab/newline are deliberately preserved."""
|
||||
record_audit(
|
||||
storage,
|
||||
"u1",
|
||||
"coordinator.note",
|
||||
detail={
|
||||
"msg": "hello\r\nInjected: bad\x00 escape \x1b[31mred\x1b[0m\x7f",
|
||||
"ok_tab": "a\tb\nc",
|
||||
},
|
||||
)
|
||||
event = storage.list_audit_events()[0]
|
||||
detail = json.loads(event["detail"])
|
||||
# CR / NUL / ESC / DEL scrubbed to spaces; tab + newline kept.
|
||||
assert "\r" not in detail["msg"]
|
||||
assert "\x00" not in detail["msg"]
|
||||
assert "\x1b" not in detail["msg"]
|
||||
assert "\x7f" not in detail["msg"]
|
||||
assert "hello" in detail["msg"]
|
||||
assert detail["ok_tab"] == "a\tb\nc"
|
||||
|
||||
|
||||
def test_record_audit_clean_strings_roundtrip_unchanged(storage):
|
||||
"""Detail strings with no credential patterns and no control chars
|
||||
pass through unchanged — the fast-path / scrub must not corrupt the
|
||||
common case."""
|
||||
clean = {"note": "hello world", "code": "import foo", "state": "ok"}
|
||||
record_audit(storage, "u1", "coordinator.note", detail=clean)
|
||||
event = storage.list_audit_events()[0]
|
||||
assert json.loads(event["detail"]) == clean
|
||||
|
||||
|
||||
def test_record_audit_fast_path_skips_no_string_detail(storage):
|
||||
"""A detail carrying only scalars (no strings anywhere) must persist
|
||||
identically — exercises the ``_has_any_string`` fast path."""
|
||||
record_audit(
|
||||
storage,
|
||||
"u1",
|
||||
"coordinator.metric",
|
||||
detail={"spawned": 5, "ok": True, "parent": None, "tail": [1, 2, 3]},
|
||||
)
|
||||
event = storage.list_audit_events()[0]
|
||||
assert json.loads(event["detail"]) == {
|
||||
"spawned": 5,
|
||||
"ok": True,
|
||||
"parent": None,
|
||||
"tail": [1, 2, 3],
|
||||
}
|
||||
|
||||
|
||||
def test_record_audit_redacts_dict_keys(storage):
|
||||
"""Walker descends into dict keys too — a caller using
|
||||
model-controlled text as a key can't leak it verbatim."""
|
||||
record_audit(
|
||||
storage,
|
||||
"u1",
|
||||
"coordinator.note",
|
||||
detail={"postgresql://alice:s3cret@db.example.com/app": True},
|
||||
)
|
||||
event = storage.list_audit_events()[0]
|
||||
detail = json.loads(event["detail"])
|
||||
assert all("s3cret" not in k for k in detail)
|
||||
|
||||
|
||||
def test_record_audit_walks_set_and_frozenset(storage):
|
||||
"""Walker handles set/frozenset values (docstring promise)."""
|
||||
record_audit(
|
||||
storage,
|
||||
"u1",
|
||||
"coordinator.note",
|
||||
detail={"tags": frozenset({"ak_" + "x" * 40, "plain"})},
|
||||
)
|
||||
event = storage.list_audit_events()[0]
|
||||
detail = json.loads(event["detail"])
|
||||
# The credential-looking AK token gets scrubbed; the plain one survives.
|
||||
tags = detail["tags"]
|
||||
assert "plain" in tags
|
||||
|
||||
|
||||
def test_record_audit_leaves_non_string_scalars_alone(storage):
|
||||
"""Non-string scalars (int / bool / None) pass through unchanged."""
|
||||
record_audit(
|
||||
storage,
|
||||
"u1",
|
||||
"coordinator.spawn",
|
||||
detail={"budget_ok": True, "spawned": 5, "parent": None},
|
||||
)
|
||||
event = storage.list_audit_events()[0]
|
||||
detail = json.loads(event["detail"])
|
||||
assert detail == {"budget_ok": True, "spawned": 5, "parent": None}
|
||||
|
||||
+424
-325
File diff suppressed because it is too large
Load Diff
+37
-57
@@ -7,7 +7,6 @@ import time
|
||||
import pytest
|
||||
|
||||
from turnstone.core.auth import (
|
||||
AuthConfig,
|
||||
AuthResult,
|
||||
_authenticate_token,
|
||||
check_request,
|
||||
@@ -203,24 +202,10 @@ class TestRequiredScope:
|
||||
|
||||
|
||||
class TestAuthenticateToken:
|
||||
def test_config_token_read(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
result = _authenticate_token("tok_read", cfg)
|
||||
assert result is not None
|
||||
assert result.scopes == frozenset({"read"})
|
||||
assert result.token_source == "config"
|
||||
|
||||
def test_config_token_full(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
result = _authenticate_token("tok_full", cfg)
|
||||
assert result is not None
|
||||
assert result.scopes == frozenset({"read", "write", "approve"})
|
||||
|
||||
def test_jwt_token(self):
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("user1", frozenset({"read", "write"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(jwt_tok, cfg, jwt_secret=secret)
|
||||
result = _authenticate_token(jwt_tok, jwt_secret=secret)
|
||||
assert result is not None
|
||||
assert result.user_id == "user1"
|
||||
assert result.token_source == "db"
|
||||
@@ -243,8 +228,7 @@ class TestAuthenticateToken:
|
||||
}
|
||||
return None
|
||||
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(raw, cfg, storage=MockStorage())
|
||||
result = _authenticate_token(raw, storage=MockStorage())
|
||||
assert result is not None
|
||||
assert result.user_id == "user1"
|
||||
assert result.has_scope("write")
|
||||
@@ -266,13 +250,11 @@ class TestAuthenticateToken:
|
||||
"expires": "2020-01-02T00:00:00",
|
||||
}
|
||||
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(raw, cfg, storage=MockStorage())
|
||||
result = _authenticate_token(raw, storage=MockStorage())
|
||||
assert result is None
|
||||
|
||||
def test_unknown_token(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok": "full"})
|
||||
result = _authenticate_token("unknown", cfg)
|
||||
result = _authenticate_token("unknown")
|
||||
assert result is None
|
||||
|
||||
|
||||
@@ -282,76 +264,74 @@ class TestAuthenticateToken:
|
||||
|
||||
|
||||
class TestCheckRequestScopes:
|
||||
def test_config_read_on_write_403(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
allowed, status, msg, _ = check_request(cfg, "POST", "/api/send", "Bearer tok_read")
|
||||
_SECRET = "test-secret-key-for-jwt-min-32b!"
|
||||
|
||||
def test_jwt_read_on_write_403(self):
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
||||
allowed, status, msg, _ = check_request(
|
||||
"POST",
|
||||
"/api/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
assert "write" in msg
|
||||
|
||||
def test_config_read_on_approve_403(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
allowed, status, msg, _ = check_request(cfg, "POST", "/api/approve", "Bearer tok_read")
|
||||
def test_jwt_read_on_approve_403(self):
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
||||
allowed, status, msg, _ = check_request(
|
||||
"POST",
|
||||
"/api/approve",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
assert "approve" in msg
|
||||
|
||||
def test_config_full_on_approve_ok(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
allowed, status, msg, result = check_request(cfg, "POST", "/api/approve", "Bearer tok_full")
|
||||
def test_jwt_full_on_approve_ok(self):
|
||||
jwt_tok = create_jwt("u1", frozenset({"read", "write", "approve"}), "test", self._SECRET)
|
||||
allowed, status, msg, result = check_request(
|
||||
"POST",
|
||||
"/api/approve",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
assert result.has_scope("approve")
|
||||
|
||||
def test_jwt_with_scopes(self):
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", self._SECRET)
|
||||
allowed, status, msg, result = check_request(
|
||||
cfg,
|
||||
"POST",
|
||||
"/api/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=secret,
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
assert result.user_id == "u1"
|
||||
|
||||
def test_jwt_insufficient_scope(self):
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", self._SECRET)
|
||||
allowed, status, msg, _ = check_request(
|
||||
cfg,
|
||||
"POST",
|
||||
"/api/send",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=secret,
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
|
||||
def test_admin_path_requires_approve(self):
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
|
||||
allowed, status, msg, _ = check_request(
|
||||
cfg,
|
||||
"GET",
|
||||
"/v1/api/admin/users",
|
||||
"Bearer tok_read",
|
||||
f"Bearer {jwt_tok}",
|
||||
jwt_secret=self._SECRET,
|
||||
)
|
||||
assert not allowed
|
||||
assert status == 403
|
||||
|
||||
def test_backward_compat_role_full(self):
|
||||
"""Config tokens with role='full' get all scopes."""
|
||||
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
|
||||
allowed, _, _, result = check_request(
|
||||
cfg,
|
||||
"GET",
|
||||
"/v1/api/admin/users",
|
||||
"Bearer tok_full",
|
||||
)
|
||||
assert allowed
|
||||
assert result is not None
|
||||
assert result.has_scope("approve")
|
||||
|
||||
+48
-1
@@ -19,6 +19,7 @@ from turnstone.bootstrap import (
|
||||
_tool_generate_secret,
|
||||
_tool_read_file,
|
||||
_tool_validate_api_key,
|
||||
_tool_write_compose,
|
||||
_tool_write_file,
|
||||
execute_tool,
|
||||
)
|
||||
@@ -103,6 +104,52 @@ class TestWriteFile:
|
||||
assert (tmp_path / "changed.txt").read_text() == "new\n"
|
||||
|
||||
|
||||
class TestWriteCompose:
|
||||
def test_writes_compose_file(self, tmp_path: Path) -> None:
|
||||
with patch("builtins.input", return_value="y"):
|
||||
result = _tool_write_compose(tmp_path, {})
|
||||
assert "written successfully" in result
|
||||
assert "ghcr.io" in result
|
||||
content = (tmp_path / "compose.yaml").read_text()
|
||||
assert "ghcr.io/turnstonelabs/turnstone" in content
|
||||
assert "TURNSTONE_IMAGE_TAG" in content
|
||||
|
||||
def test_user_declines(self, tmp_path: Path) -> None:
|
||||
with patch("builtins.input", return_value="n"):
|
||||
result = _tool_write_compose(tmp_path, {})
|
||||
assert "declined" in result
|
||||
assert not (tmp_path / "compose.yaml").exists()
|
||||
|
||||
def test_identical_content_skipped(self, tmp_path: Path) -> None:
|
||||
# Write it once
|
||||
with patch("builtins.input", return_value="y"):
|
||||
_tool_write_compose(tmp_path, {})
|
||||
# Second call should skip
|
||||
result = _tool_write_compose(tmp_path, {})
|
||||
assert "already exists" in result
|
||||
|
||||
def test_no_build_blocks(self, tmp_path: Path) -> None:
|
||||
with patch("builtins.input", return_value="y"):
|
||||
_tool_write_compose(tmp_path, {})
|
||||
content = (tmp_path / "compose.yaml").read_text()
|
||||
assert "build:" not in content
|
||||
assert "dockerfile:" not in content.lower()
|
||||
|
||||
def test_overwrites_different_content(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "compose.yaml").write_text("old content\n")
|
||||
with patch("builtins.input", return_value="y"):
|
||||
result = _tool_write_compose(tmp_path, {})
|
||||
assert "written successfully" in result
|
||||
content = (tmp_path / "compose.yaml").read_text()
|
||||
assert "ghcr.io" in content
|
||||
|
||||
def test_no_local_image_references(self, tmp_path: Path) -> None:
|
||||
with patch("builtins.input", return_value="y"):
|
||||
_tool_write_compose(tmp_path, {})
|
||||
content = (tmp_path / "compose.yaml").read_text()
|
||||
assert "turnstone:local" not in content
|
||||
|
||||
|
||||
class TestGenerateSecret:
|
||||
def test_default_length(self) -> None:
|
||||
secret = _tool_generate_secret({})
|
||||
@@ -620,7 +667,7 @@ class TestConstants:
|
||||
assert func["parameters"]["type"] == "object"
|
||||
|
||||
def test_tool_count(self) -> None:
|
||||
assert len(TOOLS) == 7
|
||||
assert len(TOOLS) == 8
|
||||
|
||||
def test_all_tools_have_implementations(self) -> None:
|
||||
from turnstone.bootstrap import TOOL_FUNCTIONS
|
||||
|
||||
+1145
-37
File diff suppressed because it is too large
Load Diff
@@ -1,55 +1,13 @@
|
||||
"""Tests for turnstone.channels._protocol and turnstone.channels._formatter."""
|
||||
"""Tests for turnstone.channels._formatter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from turnstone.channels._formatter import (
|
||||
chunk_message,
|
||||
format_approval_request,
|
||||
format_plan_review,
|
||||
format_verdict,
|
||||
truncate,
|
||||
)
|
||||
from turnstone.channels._protocol import ChannelEvent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChannelEvent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChannelEvent:
|
||||
def test_construction(self) -> None:
|
||||
evt = ChannelEvent(
|
||||
channel_type="discord",
|
||||
channel_id="ch-1",
|
||||
channel_user_id="u-42",
|
||||
message="hello",
|
||||
parent_channel_id="parent",
|
||||
metadata={"key": "val"},
|
||||
)
|
||||
assert evt.channel_type == "discord"
|
||||
assert evt.channel_id == "ch-1"
|
||||
assert evt.channel_user_id == "u-42"
|
||||
assert evt.message == "hello"
|
||||
assert evt.parent_channel_id == "parent"
|
||||
assert evt.metadata == {"key": "val"}
|
||||
|
||||
def test_defaults(self) -> None:
|
||||
evt = ChannelEvent(
|
||||
channel_type="slack",
|
||||
channel_id="ch-2",
|
||||
channel_user_id="u-7",
|
||||
message="hi",
|
||||
)
|
||||
assert evt.parent_channel_id == ""
|
||||
assert evt.metadata == {}
|
||||
|
||||
def test_metadata_independence(self) -> None:
|
||||
"""Default metadata dicts are independent across instances."""
|
||||
a = ChannelEvent(channel_type="x", channel_id="1", channel_user_id="u", message="m")
|
||||
b = ChannelEvent(channel_type="x", channel_id="2", channel_user_id="u", message="m")
|
||||
a.metadata["key"] = "val"
|
||||
assert "key" not in b.metadata
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chunk_message
|
||||
@@ -172,18 +130,6 @@ class TestFormatApprovalRequest:
|
||||
assert "/etc/hosts" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_plan_review
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatPlanReview:
|
||||
def test_format(self) -> None:
|
||||
result = format_plan_review("Step 1: do stuff")
|
||||
assert result.startswith("**Plan review requested:**")
|
||||
assert "Step 1: do stuff" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_verdict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,397 @@
|
||||
"""Tests for the shared SSE reconnect helper in turnstone.channels._sse."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
def _run(coro): # type: ignore[no-untyped-def]
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
class _FakeSSEEvent:
|
||||
"""A fake ``httpx_sse.ServerSentEvent`` with the subset we read."""
|
||||
|
||||
def __init__(self, event: str, data: str) -> None:
|
||||
self.event = event
|
||||
self.data = data
|
||||
|
||||
|
||||
class _FakeEventSource:
|
||||
"""Context manager returned by our fake ``aconnect_sse``.
|
||||
|
||||
Captures the (status_code, events) the test wants to deliver.
|
||||
``aiter_sse`` yields the events then returns; the caller then hits
|
||||
the outer ``while True`` loop again, which will pick up the next
|
||||
queued response via the shared iterator state on _FakeConnect.
|
||||
"""
|
||||
|
||||
def __init__(self, *, status_code: int, events: list[_FakeSSEEvent]) -> None:
|
||||
self.response = SimpleNamespace(
|
||||
status_code=status_code,
|
||||
request=MagicMock(),
|
||||
)
|
||||
self._events = events
|
||||
|
||||
async def __aenter__(self) -> _FakeEventSource:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
return None
|
||||
|
||||
async def aiter_sse(self): # type: ignore[no-untyped-def]
|
||||
for event in self._events:
|
||||
yield event
|
||||
|
||||
|
||||
class _FakeConnect:
|
||||
"""Drop-in replacement for ``httpx_sse.aconnect_sse``.
|
||||
|
||||
On each call, pops the next ``_FakeEventSource`` from *queue*. When
|
||||
the queue is empty, raises ``asyncio.CancelledError`` so the loop
|
||||
terminates cleanly in tests.
|
||||
"""
|
||||
|
||||
def __init__(self, queue: list[_FakeEventSource]) -> None:
|
||||
self._queue = queue
|
||||
self.call_count = 0
|
||||
|
||||
def __call__(self, *args, **kwargs): # noqa: ANN001, ANN204
|
||||
self.call_count += 1
|
||||
if not self._queue:
|
||||
raise asyncio.CancelledError
|
||||
return self._queue.pop(0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _fast_sleep(monkeypatch):
|
||||
"""Patch asyncio.sleep so backoff doesn't actually wait; record calls."""
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def fake_sleep(delay: float) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr("turnstone.channels._sse.asyncio.sleep", fake_sleep)
|
||||
return sleeps
|
||||
|
||||
|
||||
def _valid_event_data(ws_id: str = "ws-1") -> str:
|
||||
"""A payload ``ServerEvent.from_dict`` will accept (a ContentEvent)."""
|
||||
return json.dumps(
|
||||
{
|
||||
"type": "content",
|
||||
"ws_id": ws_id,
|
||||
"text": "hello",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 404 → on_stale + exit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStaleRoute:
|
||||
def test_404_calls_on_stale_and_returns(self, monkeypatch, _fast_sleep):
|
||||
from turnstone.channels import _sse
|
||||
|
||||
queue = [_FakeEventSource(status_code=404, events=[])]
|
||||
fake_connect = _FakeConnect(queue)
|
||||
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
|
||||
|
||||
on_stale = AsyncMock()
|
||||
on_event = AsyncMock()
|
||||
|
||||
async def node_url_fn(ws_id: str) -> str:
|
||||
return "http://node"
|
||||
|
||||
_run(
|
||||
_sse.run_sse_stream(
|
||||
http_client=MagicMock(),
|
||||
log_prefix="test",
|
||||
ws_id="ws-1",
|
||||
node_url_fn=node_url_fn,
|
||||
token_factory=None,
|
||||
on_event=on_event,
|
||||
on_stale=on_stale,
|
||||
)
|
||||
)
|
||||
|
||||
on_stale.assert_awaited_once()
|
||||
on_event.assert_not_awaited()
|
||||
# No reconnect after 404.
|
||||
assert fake_connect.call_count == 1
|
||||
assert _fast_sleep == []
|
||||
|
||||
def test_on_stale_exception_still_exits(self, monkeypatch, _fast_sleep):
|
||||
"""If on_stale raises, the loop must not reconnect."""
|
||||
from turnstone.channels import _sse
|
||||
|
||||
queue = [_FakeEventSource(status_code=404, events=[])]
|
||||
fake_connect = _FakeConnect(queue)
|
||||
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
|
||||
|
||||
on_stale = AsyncMock(side_effect=RuntimeError("storage down"))
|
||||
|
||||
async def node_url_fn(ws_id: str) -> str:
|
||||
return "http://node"
|
||||
|
||||
_run(
|
||||
_sse.run_sse_stream(
|
||||
http_client=MagicMock(),
|
||||
log_prefix="test",
|
||||
ws_id="ws-1",
|
||||
node_url_fn=node_url_fn,
|
||||
token_factory=None,
|
||||
on_event=AsyncMock(),
|
||||
on_stale=on_stale,
|
||||
)
|
||||
)
|
||||
|
||||
on_stale.assert_awaited_once()
|
||||
# Still a single connect — no livelock.
|
||||
assert fake_connect.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 500+ → exponential backoff
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBackoff:
|
||||
def test_500_triggers_backoff_and_retries(self, monkeypatch, _fast_sleep):
|
||||
from turnstone.channels import _sse
|
||||
|
||||
queue = [
|
||||
_FakeEventSource(status_code=503, events=[]),
|
||||
_FakeEventSource(status_code=503, events=[]),
|
||||
_FakeEventSource(status_code=503, events=[]),
|
||||
]
|
||||
fake_connect = _FakeConnect(queue)
|
||||
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
|
||||
|
||||
async def node_url_fn(ws_id: str) -> str:
|
||||
return "http://node"
|
||||
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
_run(
|
||||
_sse.run_sse_stream(
|
||||
http_client=MagicMock(),
|
||||
log_prefix="test",
|
||||
ws_id="ws-1",
|
||||
node_url_fn=node_url_fn,
|
||||
token_factory=None,
|
||||
on_event=AsyncMock(),
|
||||
on_stale=AsyncMock(),
|
||||
)
|
||||
)
|
||||
|
||||
assert fake_connect.call_count >= 3
|
||||
# First three recorded sleeps are 2s, 4s, 8s (starts at
|
||||
# SSE_RECONNECT_DELAY, doubles each time, capped at
|
||||
# SSE_MAX_RECONNECT_DELAY).
|
||||
assert _fast_sleep[0] == _sse.SSE_RECONNECT_DELAY
|
||||
assert _fast_sleep[1] == _sse.SSE_RECONNECT_DELAY * 2
|
||||
assert _fast_sleep[2] == _sse.SSE_RECONNECT_DELAY * 4
|
||||
|
||||
def test_backoff_resets_after_successful_dispatch(self, monkeypatch, _fast_sleep):
|
||||
"""After a 200 + successful event dispatch, the next error
|
||||
restarts backoff at the initial delay."""
|
||||
from turnstone.channels import _sse
|
||||
|
||||
good_event = _FakeSSEEvent(event="message", data=_valid_event_data())
|
||||
queue = [
|
||||
_FakeEventSource(status_code=503, events=[]),
|
||||
_FakeEventSource(status_code=200, events=[good_event]),
|
||||
_FakeEventSource(status_code=503, events=[]),
|
||||
]
|
||||
fake_connect = _FakeConnect(queue)
|
||||
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
|
||||
|
||||
on_event = AsyncMock()
|
||||
|
||||
async def node_url_fn(ws_id: str) -> str:
|
||||
return "http://node"
|
||||
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
_run(
|
||||
_sse.run_sse_stream(
|
||||
http_client=MagicMock(),
|
||||
log_prefix="test",
|
||||
ws_id="ws-1",
|
||||
node_url_fn=node_url_fn,
|
||||
token_factory=None,
|
||||
on_event=on_event,
|
||||
on_stale=AsyncMock(),
|
||||
)
|
||||
)
|
||||
|
||||
on_event.assert_awaited()
|
||||
# Sleep sequence: 2 (after first 503), 2 (reset after 200/event),
|
||||
# then CancelledError exits. First two sleeps are both the base
|
||||
# delay — the reset did its job.
|
||||
assert len(_fast_sleep) >= 2
|
||||
assert _fast_sleep[0] == _sse.SSE_RECONNECT_DELAY
|
||||
assert _fast_sleep[1] == _sse.SSE_RECONNECT_DELAY
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEventDispatch:
|
||||
def test_invalid_json_is_skipped(self, monkeypatch, _fast_sleep):
|
||||
from turnstone.channels import _sse
|
||||
|
||||
bad = _FakeSSEEvent(event="message", data="{not json")
|
||||
good = _FakeSSEEvent(event="message", data=_valid_event_data())
|
||||
queue = [_FakeEventSource(status_code=200, events=[bad, good])]
|
||||
fake_connect = _FakeConnect(queue)
|
||||
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
|
||||
|
||||
on_event = AsyncMock()
|
||||
|
||||
async def node_url_fn(ws_id: str) -> str:
|
||||
return "http://node"
|
||||
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
_run(
|
||||
_sse.run_sse_stream(
|
||||
http_client=MagicMock(),
|
||||
log_prefix="test",
|
||||
ws_id="ws-1",
|
||||
node_url_fn=node_url_fn,
|
||||
token_factory=None,
|
||||
on_event=on_event,
|
||||
on_stale=AsyncMock(),
|
||||
)
|
||||
)
|
||||
|
||||
# Good event delivered, bad one silently dropped.
|
||||
assert on_event.await_count == 1
|
||||
|
||||
def test_on_event_exception_does_not_kill_stream(self, monkeypatch, _fast_sleep):
|
||||
from turnstone.channels import _sse
|
||||
|
||||
e1 = _FakeSSEEvent(event="message", data=_valid_event_data())
|
||||
e2 = _FakeSSEEvent(event="message", data=_valid_event_data())
|
||||
queue = [_FakeEventSource(status_code=200, events=[e1, e2])]
|
||||
fake_connect = _FakeConnect(queue)
|
||||
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
|
||||
|
||||
on_event = AsyncMock(side_effect=[RuntimeError("boom"), None])
|
||||
|
||||
async def node_url_fn(ws_id: str) -> str:
|
||||
return "http://node"
|
||||
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
_run(
|
||||
_sse.run_sse_stream(
|
||||
http_client=MagicMock(),
|
||||
log_prefix="test",
|
||||
ws_id="ws-1",
|
||||
node_url_fn=node_url_fn,
|
||||
token_factory=None,
|
||||
on_event=on_event,
|
||||
on_stale=AsyncMock(),
|
||||
)
|
||||
)
|
||||
|
||||
# Both events attempted — first raised but second still delivered.
|
||||
assert on_event.await_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTokenFactory:
|
||||
def test_header_refreshed_per_connection(self, monkeypatch, _fast_sleep):
|
||||
"""token_factory is called once per reconnect so rotating service
|
||||
JWTs stay fresh."""
|
||||
from turnstone.channels import _sse
|
||||
|
||||
# Two reconnects followed by CancelledError to exit.
|
||||
queue = [
|
||||
_FakeEventSource(status_code=503, events=[]),
|
||||
_FakeEventSource(status_code=503, events=[]),
|
||||
]
|
||||
fake_connect = _FakeConnect(queue)
|
||||
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
|
||||
|
||||
tokens: list[str] = []
|
||||
|
||||
def factory() -> str:
|
||||
tok = f"tok-{len(tokens)}"
|
||||
tokens.append(tok)
|
||||
return tok
|
||||
|
||||
async def node_url_fn(ws_id: str) -> str:
|
||||
return "http://node"
|
||||
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
_run(
|
||||
_sse.run_sse_stream(
|
||||
http_client=MagicMock(),
|
||||
log_prefix="test",
|
||||
ws_id="ws-1",
|
||||
node_url_fn=node_url_fn,
|
||||
token_factory=factory,
|
||||
on_event=AsyncMock(),
|
||||
on_stale=AsyncMock(),
|
||||
)
|
||||
)
|
||||
|
||||
assert len(tokens) >= 2
|
||||
assert tokens[0] != tokens[1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# httpx errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTransportErrors:
|
||||
def test_connect_error_falls_through_to_backoff(self, monkeypatch, _fast_sleep):
|
||||
"""ConnectError is caught and treated as retryable."""
|
||||
from turnstone.channels import _sse
|
||||
|
||||
call_order = {"n": 0}
|
||||
|
||||
def fake_connect(*args, **kwargs): # noqa: ANN001, ANN003
|
||||
call_order["n"] += 1
|
||||
if call_order["n"] == 1:
|
||||
raise httpx.ConnectError("boom")
|
||||
# Second attempt: signal the loop to exit.
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
|
||||
|
||||
async def node_url_fn(ws_id: str) -> str:
|
||||
return "http://node"
|
||||
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
_run(
|
||||
_sse.run_sse_stream(
|
||||
http_client=MagicMock(),
|
||||
log_prefix="test",
|
||||
ws_id="ws-1",
|
||||
node_url_fn=node_url_fn,
|
||||
token_factory=None,
|
||||
on_event=AsyncMock(),
|
||||
on_stale=AsyncMock(),
|
||||
)
|
||||
)
|
||||
|
||||
assert call_order["n"] == 2
|
||||
# Backoff ran once after the ConnectError.
|
||||
assert _fast_sleep == [_sse.SSE_RECONNECT_DELAY]
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Server-side tests for the close_workstream handler's close_reason
|
||||
persistence — guards the seam that lets coordinator inspect surface
|
||||
why a workstream was retired without scraping the audit log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import turnstone.server as srv_mod
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
|
||||
from turnstone.core.metrics import MetricsCollector
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
from turnstone.core.workstream import WorkstreamState
|
||||
|
||||
_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
|
||||
def _full_hdr() -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": (
|
||||
f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', _JWT_SECRET, audience=JWT_AUD_SERVER)}"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _make_app(storage: Any) -> TestClient:
|
||||
srv_mod._metrics = MetricsCollector()
|
||||
srv_mod._metrics.model = "test-model"
|
||||
mock_session = MagicMock()
|
||||
mock_ws = MagicMock()
|
||||
mock_ws.id = "ws-target"
|
||||
mock_ws.name = "test"
|
||||
mock_ws.state = WorkstreamState.IDLE
|
||||
mock_ws.session = mock_session
|
||||
# Tenant gate (#375) checks ws.user_id == JWT subject; explicit set
|
||||
# so MagicMock's auto-generated truthy attribute doesn't reject the
|
||||
# request before the persistence path runs. kind / parent_ws_id
|
||||
# land in the audit_detail dict alongside ``reason``.
|
||||
mock_ws.user_id = "u1"
|
||||
mock_ws.kind = "interactive"
|
||||
mock_ws.parent_ws_id = None
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.get.return_value = mock_ws
|
||||
mock_mgr.close.return_value = True
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
mock_mgr.max_workstreams = 10
|
||||
|
||||
app = srv_mod.create_app(
|
||||
workstreams=mock_mgr,
|
||||
global_queue=queue.Queue(),
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
skip_permissions=False,
|
||||
jwt_secret=_JWT_SECRET,
|
||||
auth_storage=storage,
|
||||
cors_origins=["*"],
|
||||
)
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path):
|
||||
return SQLiteBackend(str(tmp_path / "close.db"))
|
||||
|
||||
|
||||
def test_close_with_reason_persists_to_workstream_config(storage):
|
||||
client = _make_app(storage)
|
||||
resp = client.post(
|
||||
"/v1/api/workstreams/close",
|
||||
json={"ws_id": "ws-target", "reason": "task complete"},
|
||||
headers=_full_hdr(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
cfg = storage.load_workstream_config("ws-target")
|
||||
assert cfg.get("close_reason") == "task complete"
|
||||
|
||||
|
||||
def test_close_without_reason_does_not_touch_config(storage):
|
||||
client = _make_app(storage)
|
||||
resp = client.post(
|
||||
"/v1/api/workstreams/close",
|
||||
json={"ws_id": "ws-target"},
|
||||
headers=_full_hdr(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
cfg = storage.load_workstream_config("ws-target")
|
||||
assert "close_reason" not in cfg
|
||||
|
||||
|
||||
def test_close_reason_capped_at_512_bytes(storage):
|
||||
"""A model that dumps a multi-KB blob (or a captured secret) into the
|
||||
close reason must not be able to grow the workstream_config row
|
||||
without bound — the handler enforces a 512-byte ceiling. Tested
|
||||
with ASCII (1B/char) so the byte cap and char count coincide."""
|
||||
huge = "x" * 5000
|
||||
client = _make_app(storage)
|
||||
resp = client.post(
|
||||
"/v1/api/workstreams/close",
|
||||
json={"ws_id": "ws-target", "reason": huge},
|
||||
headers=_full_hdr(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
cfg = storage.load_workstream_config("ws-target")
|
||||
stored = cfg.get("close_reason")
|
||||
assert stored is not None
|
||||
assert len(stored.encode("utf-8")) <= 512
|
||||
|
||||
|
||||
def test_close_reason_byte_cap_holds_for_multibyte_utf8(storage):
|
||||
"""Repro for the char-cap-vs-byte-cap mismatch: a CJK-only payload
|
||||
of 600 chars would have leaked through a code-point slice at
|
||||
600*3=1800 bytes. The byte-aware cap holds it at <=512 bytes."""
|
||||
huge = "\u6f22" * 600 # 3 bytes/char in UTF-8
|
||||
client = _make_app(storage)
|
||||
resp = client.post(
|
||||
"/v1/api/workstreams/close",
|
||||
json={"ws_id": "ws-target", "reason": huge},
|
||||
headers=_full_hdr(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
cfg = storage.load_workstream_config("ws-target")
|
||||
stored = cfg.get("close_reason")
|
||||
assert stored is not None
|
||||
assert len(stored.encode("utf-8")) <= 512
|
||||
|
||||
|
||||
def test_close_with_non_string_reason_drops_silently(storage):
|
||||
"""A malformed body (reason=dict / list / int) should not crash the
|
||||
handler — non-string reasons are coerced to empty and the close
|
||||
proceeds without writing to workstream_config."""
|
||||
client = _make_app(storage)
|
||||
resp = client.post(
|
||||
"/v1/api/workstreams/close",
|
||||
json={"ws_id": "ws-target", "reason": {"unexpected": "shape"}},
|
||||
headers=_full_hdr(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
cfg = storage.load_workstream_config("ws-target")
|
||||
assert "close_reason" not in cfg
|
||||
|
||||
|
||||
def test_close_reason_redacts_credentials(storage):
|
||||
"""A model under prompt injection that captures a secret and stuffs
|
||||
it into ``reason`` must not get to plant the plaintext secret in
|
||||
audit logs / workstream_config. The output guard's credential-
|
||||
redaction pass runs at the close handler boundary."""
|
||||
client = _make_app(storage)
|
||||
secret = "AKIAIOSFODNN7EXAMPLE" # AWS access key — output guard catches.
|
||||
resp = client.post(
|
||||
"/v1/api/workstreams/close",
|
||||
json={"ws_id": "ws-target", "reason": f"task done; key={secret}"},
|
||||
headers=_full_hdr(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
cfg = storage.load_workstream_config("ws-target")
|
||||
stored = cfg.get("close_reason")
|
||||
assert stored is not None
|
||||
assert secret not in stored
|
||||
assert "[REDACTED:" in stored
|
||||
|
||||
|
||||
def test_close_reason_persistence_failure_does_not_block_close(storage):
|
||||
"""If the storage save raises, the close still succeeds — persistence
|
||||
is best-effort; a transient storage error must not block the user
|
||||
from closing a workstream."""
|
||||
client = _make_app(storage)
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise RuntimeError("storage down")
|
||||
|
||||
storage.save_workstream_config = _boom # type: ignore[method-assign]
|
||||
resp = client.post(
|
||||
"/v1/api/workstreams/close",
|
||||
json={"ws_id": "ws-target", "reason": "task complete"},
|
||||
headers=_full_hdr(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
@@ -3,7 +3,10 @@
|
||||
import argparse
|
||||
|
||||
import turnstone.core.config as config_mod
|
||||
from turnstone.core.config import apply_config, load_config, set_config_path
|
||||
|
||||
apply_config = config_mod.apply_config
|
||||
load_config = config_mod.load_config
|
||||
set_config_path = config_mod.set_config_path
|
||||
|
||||
|
||||
def _reset_cache():
|
||||
|
||||
@@ -87,8 +87,20 @@ class TestSetGetRoundTrip:
|
||||
assert store.get("tools.skip_permissions") is False
|
||||
|
||||
def test_str(self, store):
|
||||
store.set("model.name", "gpt-5")
|
||||
assert store.get("model.name") == "gpt-5"
|
||||
store.set("model.default_alias", "gpt5-prod")
|
||||
assert store.get("model.default_alias") == "gpt5-prod"
|
||||
|
||||
def test_plan_task_alias(self, store):
|
||||
store.set("model.plan_alias", "smart")
|
||||
store.set("model.task_alias", "fast")
|
||||
assert store.get("model.plan_alias") == "smart"
|
||||
assert store.get("model.task_alias") == "fast"
|
||||
|
||||
def test_plan_task_effort(self, store):
|
||||
store.set("model.plan_effort", "max")
|
||||
store.set("model.task_effort", "low")
|
||||
assert store.get("model.plan_effort") == "max"
|
||||
assert store.get("model.task_effort") == "low"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -105,7 +117,8 @@ class TestDelete:
|
||||
assert store.get("tools.timeout") == defn.default
|
||||
|
||||
def test_returns_false_for_non_existent(self, store):
|
||||
assert store.delete("tools.timeout") is False
|
||||
result = store.delete("tools.timeout")
|
||||
assert result is False
|
||||
|
||||
def test_rejects_unknown_key(self, store):
|
||||
with pytest.raises(ValueError, match="Unknown setting"):
|
||||
@@ -164,10 +177,10 @@ class TestStoredKeys:
|
||||
assert store.stored_keys() == frozenset()
|
||||
store.set("tools.timeout", 30)
|
||||
assert store.stored_keys() == frozenset({"tools.timeout"})
|
||||
store.set("model.name", "gpt-5")
|
||||
assert store.stored_keys() == frozenset({"tools.timeout", "model.name"})
|
||||
store.set("model.default_alias", "gpt5-prod")
|
||||
assert store.stored_keys() == frozenset({"tools.timeout", "model.default_alias"})
|
||||
store.delete("tools.timeout")
|
||||
assert store.stored_keys() == frozenset({"model.name"})
|
||||
assert store.stored_keys() == frozenset({"model.default_alias"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+248
-128
@@ -3,12 +3,30 @@
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.console.collector import ClusterCollector, NodeSnapshot
|
||||
|
||||
# Shared test auth — JWT-based
|
||||
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
|
||||
def _test_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-console",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
|
||||
|
||||
_TEST_AUTH_HEADERS = {"Authorization": f"Bearer {_test_jwt()}"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock storage for collector tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -21,7 +39,7 @@ class MockStorage:
|
||||
self.services: list[dict[str, str]] = []
|
||||
|
||||
def list_services(self, service_type: str, max_age_seconds: int = 120) -> list[dict[str, str]]:
|
||||
return [s for s in self.services if True] # all services match
|
||||
return list(self.services)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -29,32 +47,15 @@ class MockStorage:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_collector(storage=None, poll_interval=0, discovery_interval=999):
|
||||
"""Create a collector with zero poll interval (no jitter delay in tests)."""
|
||||
def _make_collector(storage=None, discovery_interval=999):
|
||||
"""Create a collector for tests (discovery disabled by default)."""
|
||||
s = storage or MockStorage()
|
||||
return ClusterCollector(
|
||||
storage=s,
|
||||
poll_interval=poll_interval,
|
||||
discovery_interval=discovery_interval,
|
||||
)
|
||||
|
||||
|
||||
def _dashboard_response(workstreams=None, aggregate=None):
|
||||
"""Build a /v1/api/dashboard-style response dict."""
|
||||
return {
|
||||
"workstreams": workstreams or [],
|
||||
"aggregate": aggregate
|
||||
or {
|
||||
"total_tokens": 0,
|
||||
"total_tool_calls": 0,
|
||||
"active_count": 0,
|
||||
"total_count": 0,
|
||||
"uptime_seconds": 0,
|
||||
"node": "local",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ClusterCollector — unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -157,30 +158,35 @@ class TestCollectorDiscovery:
|
||||
assert c._nodes["node-a"].started == 1234567890.0
|
||||
|
||||
|
||||
class TestCollectorPolling:
|
||||
"""Polling /v1/api/dashboard from nodes."""
|
||||
class TestCollectorSnapshot:
|
||||
"""Applying node_snapshot SSE events."""
|
||||
|
||||
def test_apply_poll_populates_workstreams(self):
|
||||
def test_apply_snapshot_populates_workstreams(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
|
||||
dashboard = _dashboard_response(
|
||||
workstreams=[
|
||||
{
|
||||
"id": "ws1",
|
||||
"name": "test",
|
||||
"state": "running",
|
||||
"tokens": 1000,
|
||||
"context_ratio": 0.15,
|
||||
"activity": "bash: ls",
|
||||
"activity_state": "tool",
|
||||
"tool_calls": 3,
|
||||
"title": "My task",
|
||||
},
|
||||
],
|
||||
aggregate={"total_tokens": 1000, "total_tool_calls": 3},
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [
|
||||
{
|
||||
"id": "ws1",
|
||||
"name": "test",
|
||||
"state": "running",
|
||||
"tokens": 1000,
|
||||
"context_ratio": 0.15,
|
||||
"activity": "bash: ls",
|
||||
"activity_state": "tool",
|
||||
"tool_calls": 3,
|
||||
"title": "My task",
|
||||
},
|
||||
],
|
||||
"health": {"status": "ok"},
|
||||
"aggregate": {"total_tokens": 1000, "total_tool_calls": 3},
|
||||
},
|
||||
)
|
||||
c._apply_poll("node-a", dashboard, {"status": "ok"})
|
||||
|
||||
detail = c.get_node_detail("node-a")
|
||||
assert len(detail["workstreams"]) == 1
|
||||
@@ -189,7 +195,7 @@ class TestCollectorPolling:
|
||||
assert detail["workstreams"][0]["server_url"] == "http://a:8080"
|
||||
assert detail["health"]["status"] == "ok"
|
||||
|
||||
def test_apply_poll_replaces_stale_workstreams(self):
|
||||
def test_apply_snapshot_replaces_stale_workstreams(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
@@ -197,30 +203,44 @@ class TestCollectorPolling:
|
||||
workstreams={"old-ws": {"id": "old-ws", "name": "old", "state": "idle"}},
|
||||
)
|
||||
|
||||
dashboard = _dashboard_response(
|
||||
workstreams=[{"id": "new-ws", "name": "new", "state": "running"}]
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [{"id": "new-ws", "name": "new", "state": "running"}],
|
||||
"health": {},
|
||||
"aggregate": {},
|
||||
},
|
||||
)
|
||||
c._apply_poll("node-a", dashboard, {})
|
||||
|
||||
detail = c.get_node_detail("node-a")
|
||||
assert len(detail["workstreams"]) == 1
|
||||
assert detail["workstreams"][0]["id"] == "new-ws"
|
||||
|
||||
def test_apply_poll_ignores_unknown_node(self):
|
||||
def test_apply_snapshot_ignores_unknown_node(self):
|
||||
c = _make_collector()
|
||||
# Should not raise
|
||||
c._apply_poll("unknown", _dashboard_response(), {})
|
||||
c._apply_snapshot(
|
||||
"unknown", {"type": "node_snapshot", "workstreams": [], "health": {}, "aggregate": {}}
|
||||
)
|
||||
|
||||
def test_apply_poll_emits_ws_created_for_new_workstream(self):
|
||||
def test_apply_snapshot_emits_ws_created_for_new_workstream(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
dashboard = _dashboard_response(
|
||||
workstreams=[{"id": "ws1", "name": "new-task", "state": "idle"}]
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [{"id": "ws1", "name": "new-task", "state": "idle"}],
|
||||
"health": {},
|
||||
"aggregate": {},
|
||||
},
|
||||
)
|
||||
c._apply_poll("node-a", dashboard, {})
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_created"
|
||||
@@ -228,7 +248,7 @@ class TestCollectorPolling:
|
||||
assert event["name"] == "new-task"
|
||||
assert event["node_id"] == "node-a"
|
||||
|
||||
def test_apply_poll_emits_ws_closed_for_removed_workstream(self):
|
||||
def test_apply_snapshot_emits_ws_closed_for_removed_workstream(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
@@ -238,13 +258,22 @@ class TestCollectorPolling:
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_poll("node-a", _dashboard_response(), {})
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [],
|
||||
"health": {},
|
||||
"aggregate": {},
|
||||
},
|
||||
)
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_closed"
|
||||
assert event["ws_id"] == "ws1"
|
||||
|
||||
def test_apply_poll_no_events_when_unchanged(self):
|
||||
def test_apply_snapshot_no_events_when_unchanged(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
@@ -254,15 +283,21 @@ class TestCollectorPolling:
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
# Same state — no events expected
|
||||
dashboard = _dashboard_response(
|
||||
workstreams=[{"id": "ws1", "name": "same", "state": "idle"}]
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [{"id": "ws1", "name": "same", "state": "idle"}],
|
||||
"health": {},
|
||||
"aggregate": {},
|
||||
},
|
||||
)
|
||||
c._apply_poll("node-a", dashboard, {})
|
||||
|
||||
assert q.empty()
|
||||
|
||||
def test_apply_poll_emits_state_change(self):
|
||||
def test_apply_snapshot_emits_state_change_as_cluster_state(self):
|
||||
"""State change events must use type 'cluster_state' for the frontend."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
@@ -272,30 +307,140 @@ class TestCollectorPolling:
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
dashboard = _dashboard_response(
|
||||
workstreams=[{"id": "ws1", "name": "same", "state": "running"}]
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [{"id": "ws1", "name": "same", "state": "running"}],
|
||||
"health": {},
|
||||
"aggregate": {},
|
||||
},
|
||||
)
|
||||
c._apply_poll("node-a", dashboard, {})
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_state"
|
||||
assert event["type"] == "cluster_state"
|
||||
assert event["ws_id"] == "ws1"
|
||||
assert event["state"] == "running"
|
||||
|
||||
def test_apply_poll_skips_empty_id_workstream(self):
|
||||
def test_apply_snapshot_skips_empty_id_workstream(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
dashboard = _dashboard_response(workstreams=[{"name": "no-id", "state": "idle"}])
|
||||
c._apply_poll("node-a", dashboard, {})
|
||||
c._apply_snapshot(
|
||||
"node-a",
|
||||
{
|
||||
"type": "node_snapshot",
|
||||
"node_id": "node-a",
|
||||
"workstreams": [{"name": "no-id", "state": "idle"}],
|
||||
"health": {},
|
||||
"aggregate": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert q.empty()
|
||||
assert len(c._nodes["node-a"].workstreams) == 0
|
||||
|
||||
def test_poll_401_preserves_workstreams_and_marks_unreachable(self):
|
||||
"""A 401 from the server must NOT wipe workstream data."""
|
||||
|
||||
class TestCollectorDelta:
|
||||
"""Applying individual SSE delta events."""
|
||||
|
||||
def test_apply_delta_ws_state_fans_out_as_cluster_state(self):
|
||||
"""Server emits ws_state; collector must translate to cluster_state."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
workstreams={"ws1": {"id": "ws1", "name": "test", "state": "idle"}},
|
||||
)
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_delta(
|
||||
"node-a", {"type": "ws_state", "ws_id": "ws1", "state": "running", "tokens": 500}
|
||||
)
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "cluster_state"
|
||||
assert event["state"] == "running"
|
||||
# Verify in-memory state was updated
|
||||
assert c._nodes["node-a"].workstreams["ws1"]["state"] == "running"
|
||||
|
||||
def test_apply_delta_ws_created(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_delta("node-a", {"type": "ws_created", "ws_id": "ws1", "name": "new"})
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_created"
|
||||
assert "ws1" in c._nodes["node-a"].workstreams
|
||||
|
||||
def test_apply_delta_ws_closed(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
workstreams={"ws1": {"id": "ws1", "name": "old", "state": "idle"}},
|
||||
)
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_delta("node-a", {"type": "ws_closed", "ws_id": "ws1"})
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_closed"
|
||||
assert "ws1" not in c._nodes["node-a"].workstreams
|
||||
|
||||
def test_apply_delta_ws_rename(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
workstreams={"ws1": {"id": "ws1", "name": "old-name", "state": "idle"}},
|
||||
)
|
||||
q: queue.Queue[dict] = queue.Queue()
|
||||
c.register_listener(q)
|
||||
|
||||
c._apply_delta("node-a", {"type": "ws_rename", "ws_id": "ws1", "name": "new-name"})
|
||||
|
||||
event = q.get_nowait()
|
||||
assert event["type"] == "ws_rename"
|
||||
assert event["name"] == "new-name"
|
||||
assert c._nodes["node-a"].workstreams["ws1"]["name"] == "new-name"
|
||||
|
||||
def test_apply_delta_health_changed(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
health={"status": "ok", "backend": {"status": "up"}},
|
||||
)
|
||||
|
||||
c._apply_delta("node-a", {"type": "health_changed", "backend_status": "degraded"})
|
||||
|
||||
health = c._nodes["node-a"].health
|
||||
assert health["backend"]["status"] == "down"
|
||||
assert health["status"] == "degraded"
|
||||
|
||||
def test_apply_delta_aggregate(self):
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
|
||||
|
||||
c._apply_delta(
|
||||
"node-a",
|
||||
{"type": "aggregate", "total_tokens": 5000, "total_tool_calls": 42, "active_count": 3},
|
||||
)
|
||||
|
||||
assert c._nodes["node-a"].aggregate["total_tokens"] == 5000
|
||||
assert c._nodes["node-a"].aggregate["total_tool_calls"] == 42
|
||||
|
||||
def test_mark_unreachable_preserves_workstreams(self):
|
||||
"""Disconnection marks unreachable but preserves workstream data."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
@@ -304,47 +449,12 @@ class TestCollectorPolling:
|
||||
workstreams={"ws1": {"id": "ws1", "name": "existing", "state": "idle"}},
|
||||
)
|
||||
|
||||
# Mock httpx to return 401
|
||||
import httpx as _httpx
|
||||
c._mark_unreachable("node-a")
|
||||
|
||||
mock_response = _httpx.Response(
|
||||
401,
|
||||
json={"error": "Unauthorized"},
|
||||
request=_httpx.Request("GET", "http://a:8080/v1/api/dashboard"),
|
||||
)
|
||||
|
||||
with patch.object(c._http_client, "get", return_value=mock_response):
|
||||
c._poll_all_nodes()
|
||||
|
||||
# Workstream data must be preserved, node marked unreachable
|
||||
assert c._nodes["node-a"].reachable is False
|
||||
assert "ws1" in c._nodes["node-a"].workstreams
|
||||
assert c._nodes["node-a"].workstreams["ws1"]["name"] == "existing"
|
||||
|
||||
def test_poll_403_preserves_workstreams(self):
|
||||
"""A 403 should also preserve state and mark unreachable."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
reachable=True,
|
||||
workstreams={"ws1": {"id": "ws1", "name": "keep-me", "state": "running"}},
|
||||
)
|
||||
|
||||
import httpx as _httpx
|
||||
|
||||
mock_response = _httpx.Response(
|
||||
403,
|
||||
json={"error": "Forbidden"},
|
||||
request=_httpx.Request("GET", "http://a:8080/v1/api/dashboard"),
|
||||
)
|
||||
|
||||
with patch.object(c._http_client, "get", return_value=mock_response):
|
||||
c._poll_all_nodes()
|
||||
|
||||
assert c._nodes["node-a"].reachable is False
|
||||
assert "ws1" in c._nodes["node-a"].workstreams
|
||||
|
||||
|
||||
class TestCollectorFanout:
|
||||
"""SSE fan-out to registered listeners."""
|
||||
@@ -618,13 +728,11 @@ class TestConsoleHTTPEndpoints:
|
||||
|
||||
_load_static()
|
||||
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
@@ -649,7 +757,9 @@ class TestConsoleHTTPEndpoints:
|
||||
assert status == 200
|
||||
assert len(data["nodes"]) == 1
|
||||
assert data["total"] == 1
|
||||
mock_collector.get_nodes.assert_called_once_with(sort_by="activity", limit=10, offset=0)
|
||||
mock_collector.get_nodes.assert_called_once_with(
|
||||
sort_by="activity", limit=10, offset=0, node_ids=None
|
||||
)
|
||||
|
||||
def test_get_workstreams(self, client, mock_collector):
|
||||
status, data = self._get(
|
||||
@@ -667,6 +777,7 @@ class TestConsoleHTTPEndpoints:
|
||||
sort_by="state",
|
||||
page=1,
|
||||
per_page=25,
|
||||
extra_rows=[],
|
||||
)
|
||||
|
||||
def test_get_workstreams_per_page_capped(self, client, mock_collector):
|
||||
@@ -860,12 +971,11 @@ class TestConsoleWorkstreamCreation:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
|
||||
# Set up a mock proxy_client (lifespan doesn't run in TestClient)
|
||||
@@ -881,7 +991,7 @@ class TestConsoleWorkstreamCreation:
|
||||
mock_proxy.post = mock_post
|
||||
app.state.proxy_client = mock_proxy
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client, mock_post
|
||||
client.close()
|
||||
|
||||
@@ -1058,14 +1168,13 @@ class TestConsoleProxy:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
@@ -1229,14 +1338,13 @@ class TestConsoleVersionEndpoints:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
app = create_app(
|
||||
collector=mock_collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
@@ -1271,7 +1379,6 @@ class TestSharedStatic:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
@@ -1283,9 +1390,9 @@ class TestSharedStatic:
|
||||
}
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
@@ -1323,7 +1430,7 @@ class TestSharedStatic:
|
||||
def test_index_imports_shared_base_css(self, client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert '/shared/base.css"' in resp.text
|
||||
assert "/shared/base.css?v=" in resp.text
|
||||
|
||||
def test_index_imports_shared_scripts(self, client):
|
||||
resp = client.get("/")
|
||||
@@ -1341,6 +1448,20 @@ class TestSharedStatic:
|
||||
app_pos = body.find("/static/app.js")
|
||||
assert shared_pos < app_pos
|
||||
|
||||
def test_index_cache_control_no_cache(self, client):
|
||||
resp = client.get("/")
|
||||
assert resp.headers.get("cache-control") == "no-cache"
|
||||
|
||||
def test_index_etag_present(self, client):
|
||||
resp = client.get("/")
|
||||
assert resp.headers.get("etag")
|
||||
|
||||
def test_index_etag_304(self, client):
|
||||
resp = client.get("/")
|
||||
etag = resp.headers.get("etag")
|
||||
resp2 = client.get("/", headers={"If-None-Match": etag})
|
||||
assert resp2.status_code == 304
|
||||
|
||||
|
||||
class TestProxySharedStatic:
|
||||
"""Tests for proxy rewriting of /shared/ paths."""
|
||||
@@ -1388,7 +1509,6 @@ class TestProxySharedStatic:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
@@ -1401,9 +1521,9 @@ class TestProxySharedStatic:
|
||||
collector.get_node_detail.return_value = None
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
resp = client.get("/node/unknown/shared/base.css")
|
||||
assert resp.status_code == 404
|
||||
client.close()
|
||||
@@ -1722,14 +1842,14 @@ class TestProxyAuthHeaders:
|
||||
# Should use ServiceTokenManager, not mint a user JWT
|
||||
assert headers["Authorization"] == f"Bearer {mgr.token}"
|
||||
|
||||
def test_fallback_static_token(self):
|
||||
"""No auth_result, no ServiceTokenManager → uses static proxy_auth_token."""
|
||||
def test_no_mgr_no_user_returns_empty(self):
|
||||
"""No auth_result, no ServiceTokenManager → empty headers."""
|
||||
from turnstone.console.server import _proxy_auth_headers
|
||||
|
||||
req = self._make_request(proxy_auth_token="static-tok-123")
|
||||
req = self._make_request()
|
||||
headers = _proxy_auth_headers(req)
|
||||
|
||||
assert headers == {"Authorization": "Bearer static-tok-123"}
|
||||
assert headers == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -37,61 +37,22 @@ class TestRecordRoute:
|
||||
assert "turnstone_router_request_duration_seconds_sum" in text
|
||||
|
||||
|
||||
class TestRingInfo:
|
||||
"""Ring membership and version gauges."""
|
||||
class TestRouterInfo:
|
||||
"""Live-membership gauge + refresh counter."""
|
||||
|
||||
def test_defaults_zero(self) -> None:
|
||||
m = ConsoleMetrics()
|
||||
text = m.generate_text()
|
||||
assert "turnstone_ring_membership_size 0" in text
|
||||
assert "turnstone_ring_version 0" in text
|
||||
assert "turnstone_router_membership_size 0" in text
|
||||
assert "turnstone_router_refresh_total 0" in text
|
||||
|
||||
def test_set_ring_info(self) -> None:
|
||||
def test_set_router_info(self) -> None:
|
||||
m = ConsoleMetrics()
|
||||
m.set_ring_info(3, 7)
|
||||
m.set_router_info(3, 7)
|
||||
|
||||
text = m.generate_text()
|
||||
assert "turnstone_ring_membership_size 3" in text
|
||||
assert "turnstone_ring_version 7" in text
|
||||
|
||||
|
||||
class TestRebalance:
|
||||
"""Rebalance and migration counters."""
|
||||
|
||||
def test_noop(self) -> None:
|
||||
m = ConsoleMetrics()
|
||||
m.record_rebalance("noop")
|
||||
|
||||
text = m.generate_text()
|
||||
assert 'turnstone_ring_rebalance_total{result="noop"} 1' in text
|
||||
|
||||
def test_seeded(self) -> None:
|
||||
m = ConsoleMetrics()
|
||||
m.record_rebalance("seeded")
|
||||
|
||||
text = m.generate_text()
|
||||
assert 'turnstone_ring_rebalance_total{result="seeded"} 1' in text
|
||||
|
||||
def test_rebalanced(self) -> None:
|
||||
m = ConsoleMetrics()
|
||||
m.record_rebalance("rebalanced")
|
||||
m.record_rebalance("rebalanced")
|
||||
|
||||
text = m.generate_text()
|
||||
assert 'turnstone_ring_rebalance_total{result="rebalanced"} 2' in text
|
||||
|
||||
def test_migrations(self) -> None:
|
||||
m = ConsoleMetrics()
|
||||
m.record_migrations(5)
|
||||
m.record_migrations(3)
|
||||
|
||||
text = m.generate_text()
|
||||
assert "turnstone_ring_migrations_total 8" in text
|
||||
|
||||
def test_migrations_default_zero(self) -> None:
|
||||
m = ConsoleMetrics()
|
||||
text = m.generate_text()
|
||||
assert "turnstone_ring_migrations_total 0" in text
|
||||
assert "turnstone_router_membership_size 3" in text
|
||||
assert "turnstone_router_refresh_total 7" in text
|
||||
|
||||
|
||||
class TestGenerateText:
|
||||
@@ -103,10 +64,8 @@ class TestGenerateText:
|
||||
expected = [
|
||||
"turnstone_router_requests_total",
|
||||
"turnstone_router_request_duration_seconds",
|
||||
"turnstone_ring_membership_size",
|
||||
"turnstone_ring_version",
|
||||
"turnstone_ring_rebalance_total",
|
||||
"turnstone_ring_migrations_total",
|
||||
"turnstone_router_membership_size",
|
||||
"turnstone_router_refresh_total",
|
||||
]
|
||||
for name in expected:
|
||||
assert name in text, f"Missing metric: {name}"
|
||||
@@ -116,8 +75,8 @@ class TestGenerateText:
|
||||
text = m.generate_text()
|
||||
assert "# HELP turnstone_router_requests_total" in text
|
||||
assert "# TYPE turnstone_router_requests_total counter" in text
|
||||
assert "# HELP turnstone_ring_membership_size" in text
|
||||
assert "# TYPE turnstone_ring_membership_size gauge" in text
|
||||
assert "# HELP turnstone_router_membership_size" in text
|
||||
assert "# TYPE turnstone_router_membership_size gauge" in text
|
||||
|
||||
def test_ends_with_newline(self) -> None:
|
||||
m = ConsoleMetrics()
|
||||
@@ -125,24 +84,16 @@ class TestGenerateText:
|
||||
assert text.endswith("\n")
|
||||
|
||||
def test_combined_scenario(self) -> None:
|
||||
"""Full scenario: routes, ring info, rebalances, migrations."""
|
||||
"""Full scenario: routes + router info."""
|
||||
m = ConsoleMetrics()
|
||||
m.record_route("create", 200, 0.1)
|
||||
m.record_route("send", 200, 0.05)
|
||||
m.record_route("send", 502, 1.2)
|
||||
m.set_ring_info(3, 12)
|
||||
m.record_rebalance("seeded")
|
||||
m.record_rebalance("noop")
|
||||
m.record_rebalance("rebalanced")
|
||||
m.record_migrations(4)
|
||||
m.set_router_info(3, 12)
|
||||
|
||||
text = m.generate_text()
|
||||
assert 'turnstone_router_requests_total{method="create",status="2xx"} 1' in text
|
||||
assert 'turnstone_router_requests_total{method="send",status="2xx"} 1' in text
|
||||
assert 'turnstone_router_requests_total{method="send",status="5xx"} 1' in text
|
||||
assert "turnstone_ring_membership_size 3" in text
|
||||
assert "turnstone_ring_version 12" in text
|
||||
assert 'turnstone_ring_rebalance_total{result="noop"} 1' in text
|
||||
assert 'turnstone_ring_rebalance_total{result="rebalanced"} 1' in text
|
||||
assert 'turnstone_ring_rebalance_total{result="seeded"} 1' in text
|
||||
assert "turnstone_ring_migrations_total 4" in text
|
||||
assert "turnstone_router_membership_size 3" in text
|
||||
assert "turnstone_router_refresh_total 12" in text
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Tests for console routing of attachment endpoints + multipart route_create.
|
||||
|
||||
Covers the cluster-routing surface added alongside the workstream
|
||||
attachment-on-create feature: the multipart variant of route_create and
|
||||
the four ws-id-keyed attachment proxies under /v1/api/route/.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.router import ConsoleRouter, NodeRef
|
||||
|
||||
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
|
||||
def _test_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-routing",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
|
||||
|
||||
_AUTH: dict[str, str] = {"Authorization": f"Bearer {_test_jwt()}"}
|
||||
|
||||
|
||||
def _make_app(router: Any) -> Any:
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
return create_app(
|
||||
collector=collector,
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
router=router,
|
||||
)
|
||||
|
||||
|
||||
def _make_router() -> MagicMock:
|
||||
router = MagicMock(spec=ConsoleRouter)
|
||||
router.is_ready.return_value = True
|
||||
router.route.return_value = NodeRef("node-a", "http://a:8080")
|
||||
return router
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# route_create multipart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRouteCreateMultipart:
|
||||
def test_multipart_requires_ws_id_query(self):
|
||||
router = _make_router()
|
||||
app = _make_app(router=router)
|
||||
app.state.proxy_client = MagicMock(spec=httpx.AsyncClient)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
files=[("file", ("a.txt", b"hello", "text/plain"))],
|
||||
data={"meta": "{}"},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "ws_id" in resp.json()["error"]
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_multipart_forwards_raw_body_to_routed_node(self):
|
||||
router = _make_router()
|
||||
app = _make_app(router=router)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def _mock_post(*args: Any, **kwargs: Any) -> httpx.Response:
|
||||
captured["url"] = args[0] if args else ""
|
||||
captured["headers"] = kwargs.get("headers") or {}
|
||||
captured["content"] = kwargs.get("content")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"ws_id": "00ff" + "0" * 28, "name": "demo"},
|
||||
request=httpx.Request("POST", args[0] if args else "http://test"),
|
||||
)
|
||||
|
||||
mock_proxy = MagicMock(spec=httpx.AsyncClient)
|
||||
mock_proxy.post = MagicMock(side_effect=_mock_post)
|
||||
app.state.proxy_client = mock_proxy
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
try:
|
||||
ws_id = "00ff" + "0" * 28
|
||||
resp = client.post(
|
||||
f"/v1/api/route/workstreams/new?ws_id={ws_id}",
|
||||
files=[("file", ("a.txt", b"hello", "text/plain"))],
|
||||
data={"meta": '{"name":"demo"}'},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["node_id"] == "node-a"
|
||||
# Forwarded multipart Content-Type
|
||||
assert captured["headers"].get("Content-Type", "").startswith("multipart/form-data")
|
||||
# Body bytes were forwarded raw
|
||||
assert isinstance(captured["content"], (bytes, bytearray))
|
||||
assert b"hello" in bytes(captured["content"])
|
||||
router.route.assert_called_with(ws_id)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_multipart_preserves_mixed_case_boundary(self):
|
||||
"""The boundary= param is case-sensitive — must match body bytes verbatim.
|
||||
|
||||
Regression for an earlier bug where route_create lowercased the
|
||||
whole Content-Type header before forwarding, mangling boundaries
|
||||
like ``WebKitFormBoundary7MA4YWxkTrZu0gW``.
|
||||
"""
|
||||
router = _make_router()
|
||||
app = _make_app(router=router)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def _mock_post(*args: Any, **kwargs: Any) -> httpx.Response:
|
||||
captured["headers"] = kwargs.get("headers") or {}
|
||||
captured["content"] = kwargs.get("content")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"ws_id": "00ff" + "0" * 28, "name": "ok"},
|
||||
request=httpx.Request("POST", args[0] if args else "http://test"),
|
||||
)
|
||||
|
||||
mock_proxy = MagicMock(spec=httpx.AsyncClient)
|
||||
mock_proxy.post = MagicMock(side_effect=_mock_post)
|
||||
app.state.proxy_client = mock_proxy
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
try:
|
||||
ws_id = "00ff" + "0" * 28
|
||||
boundary = "WebKitFormBoundary7MA4YWxkTrZu0gW" # mixed-case
|
||||
body = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="meta"\r\n\r\n'
|
||||
f'{{"name":"demo"}}\r\n'
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="file"; filename="a.txt"\r\n'
|
||||
f"Content-Type: text/plain\r\n\r\n"
|
||||
f"hello\r\n"
|
||||
f"--{boundary}--\r\n"
|
||||
).encode()
|
||||
resp = client.post(
|
||||
f"/v1/api/route/workstreams/new?ws_id={ws_id}",
|
||||
content=body,
|
||||
headers={
|
||||
**_AUTH,
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
forwarded = captured["headers"].get("Content-Type", "")
|
||||
assert boundary in forwarded, (
|
||||
f"boundary mangled in upstream Content-Type: {forwarded!r}"
|
||||
)
|
||||
# Body bytes still contain the mixed-case boundary
|
||||
assert boundary.encode() in bytes(captured["content"])
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_json_path_unchanged(self):
|
||||
"""Existing JSON callers should continue to work as before."""
|
||||
router = _make_router()
|
||||
app = _make_app(router=router)
|
||||
|
||||
async def _mock_post(*args: Any, **kwargs: Any) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"ws_id": "abc123", "name": "json"},
|
||||
request=httpx.Request("POST", args[0] if args else "http://test"),
|
||||
)
|
||||
|
||||
mock_proxy = MagicMock(spec=httpx.AsyncClient)
|
||||
mock_proxy.post = MagicMock(side_effect=_mock_post)
|
||||
app.state.proxy_client = mock_proxy
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "json"},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ws_id"] == "abc123"
|
||||
# JSON path uses json= kwarg, not content=
|
||||
call_kwargs = mock_proxy.post.call_args.kwargs
|
||||
assert "json" in call_kwargs
|
||||
assert "content" not in call_kwargs
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# route_attachment_proxy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRouteAttachmentProxy:
|
||||
def _wire(self, mock_request_fn) -> tuple[Any, MagicMock]:
|
||||
router = _make_router()
|
||||
app = _make_app(router=router)
|
||||
mock_proxy = MagicMock(spec=httpx.AsyncClient)
|
||||
mock_proxy.request = MagicMock(side_effect=mock_request_fn)
|
||||
mock_proxy.get = MagicMock(side_effect=mock_request_fn)
|
||||
mock_proxy.post = MagicMock(side_effect=mock_request_fn)
|
||||
app.state.proxy_client = mock_proxy
|
||||
return app, mock_proxy
|
||||
|
||||
def test_upload_proxies_multipart(self):
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def _mock(*args: Any, **kwargs: Any) -> httpx.Response:
|
||||
captured["method"] = args[0] if args else kwargs.get("method")
|
||||
captured["url"] = args[1] if len(args) > 1 else kwargs.get("url", "")
|
||||
captured["headers"] = kwargs.get("headers") or {}
|
||||
captured["content"] = kwargs.get("content")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"attachment_id": "att-1",
|
||||
"filename": "a.txt",
|
||||
"mime_type": "text/plain",
|
||||
"size_bytes": 5,
|
||||
"kind": "text",
|
||||
},
|
||||
request=httpx.Request("POST", "http://a:8080/x"),
|
||||
)
|
||||
|
||||
app, _ = self._wire(_mock)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/ws-X/attachments",
|
||||
files=[("file", ("a.txt", b"hello", "text/plain"))],
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["attachment_id"] == "att-1"
|
||||
assert "/v1/api/workstreams/ws-X/attachments" in captured["url"]
|
||||
assert "/route/" not in captured["url"]
|
||||
assert captured["headers"].get("Content-Type", "").startswith("multipart/form-data")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_list_proxies_get(self):
|
||||
async def _mock(*args: Any, **kwargs: Any) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"attachments": []},
|
||||
request=httpx.Request("GET", "http://a:8080/x"),
|
||||
)
|
||||
|
||||
app, mock_proxy = self._wire(_mock)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
try:
|
||||
resp = client.get(
|
||||
"/v1/api/route/workstreams/ws-X/attachments",
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"attachments": []}
|
||||
mock_proxy.get.assert_called()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_get_content_preserves_upstream_headers(self):
|
||||
async def _mock(*args: Any, **kwargs: Any) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=b"hello world",
|
||||
headers={
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": 'inline; filename="notes.md"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
request=httpx.Request("GET", "http://a:8080/x"),
|
||||
)
|
||||
|
||||
app, _ = self._wire(_mock)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
try:
|
||||
resp = client.get(
|
||||
"/v1/api/route/workstreams/ws-X/attachments/att-1/content",
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"hello world"
|
||||
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert "filename" in resp.headers.get("Content-Disposition", "")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_delete_proxies_method(self):
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def _mock(*args: Any, **kwargs: Any) -> httpx.Response:
|
||||
captured["method"] = args[0] if args else ""
|
||||
captured["url"] = args[1] if len(args) > 1 else ""
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"status": "deleted"},
|
||||
request=httpx.Request("DELETE", "http://a:8080/x"),
|
||||
)
|
||||
|
||||
app, _ = self._wire(_mock)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
try:
|
||||
resp = client.delete(
|
||||
"/v1/api/route/workstreams/ws-X/attachments/att-1",
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "deleted"}
|
||||
assert captured["method"] == "DELETE"
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing-failure paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRoutingFailures:
|
||||
def test_router_not_ready_returns_503(self):
|
||||
router = MagicMock(spec=ConsoleRouter)
|
||||
router.is_ready.return_value = False
|
||||
router.refresh_cache.return_value = None
|
||||
app = _make_app(router=router)
|
||||
app.state.proxy_client = MagicMock(spec=httpx.AsyncClient)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
try:
|
||||
resp = client.get(
|
||||
"/v1/api/route/workstreams/ws-X/attachments",
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
finally:
|
||||
client.close()
|
||||
+178
-138
@@ -1,17 +1,13 @@
|
||||
"""Tests for turnstone.console.router."""
|
||||
"""Tests for turnstone.console.router (rendezvous routing)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.console.router import ConsoleRouter, NodeRef
|
||||
from turnstone.core.hash_ring import RING_SIZE, NoAvailableNodeError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake storage
|
||||
# ---------------------------------------------------------------------------
|
||||
from turnstone.core.rendezvous import NoAvailableNodeError
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
@@ -19,26 +15,14 @@ class FakeStorage:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.services: list[dict[str, str]] = []
|
||||
self.buckets: list[dict[str, Any]] = []
|
||||
self.overrides: list[dict[str, str]] = []
|
||||
self.settings: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def list_services(self, service_type: str, max_age_seconds: int = 120) -> list[dict[str, str]]:
|
||||
return list(self.services)
|
||||
|
||||
def list_ring_buckets(self) -> list[dict[str, Any]]:
|
||||
return list(self.buckets)
|
||||
|
||||
def list_workstream_overrides(self) -> list[dict[str, str]]:
|
||||
return list(self.overrides)
|
||||
|
||||
def get_system_setting(self, key: str, node_id: str = "") -> dict[str, Any] | None:
|
||||
return self.settings.get(key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NODE_A = {"service_id": "node-a", "url": "http://a:8080", "metadata": "{}"}
|
||||
NODE_B = {"service_id": "node-b", "url": "http://b:8080", "metadata": "{}"}
|
||||
@@ -50,206 +34,262 @@ def _make_router(storage: FakeStorage | None = None) -> tuple[ConsoleRouter, Fak
|
||||
return ConsoleRouter(s), s # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _ws_id_for_bucket(bucket: int) -> str:
|
||||
"""Build a 32-char hex ws_id whose first 4 chars encode *bucket*."""
|
||||
return f"{bucket:04x}" + "0" * 28
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestRouteBasic
|
||||
# ---------------------------------------------------------------------------
|
||||
def _random_ws_id() -> str:
|
||||
return secrets.token_hex(16)
|
||||
|
||||
|
||||
class TestRouteBasic:
|
||||
"""Basic routing through the bucket cache."""
|
||||
|
||||
def test_route_returns_correct_node(self) -> None:
|
||||
def test_route_returns_a_live_node(self) -> None:
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A, NODE_B, NODE_C]
|
||||
storage.buckets = [
|
||||
{"bucket": 0x0000, "node_id": "node-a"},
|
||||
{"bucket": 0x0001, "node_id": "node-b"},
|
||||
{"bucket": 0x0002, "node_id": "node-c"},
|
||||
]
|
||||
router.refresh_cache()
|
||||
|
||||
assert router.route(_ws_id_for_bucket(0x0000)) == NodeRef("node-a", "http://a:8080")
|
||||
assert router.route(_ws_id_for_bucket(0x0001)) == NodeRef("node-b", "http://b:8080")
|
||||
assert router.route(_ws_id_for_bucket(0x0002)) == NodeRef("node-c", "http://c:8080")
|
||||
ref = router.route(_random_ws_id())
|
||||
assert ref.node_id in {"node-a", "node-b", "node-c"}
|
||||
|
||||
def test_route_is_deterministic_for_same_ws_id(self) -> None:
|
||||
"""Same ws_id + same membership → same target every time."""
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A, NODE_B, NODE_C]
|
||||
router.refresh_cache()
|
||||
|
||||
ws_id = _random_ws_id()
|
||||
first = router.route(ws_id)
|
||||
for _ in range(50):
|
||||
assert router.route(ws_id) == first
|
||||
|
||||
def test_route_override_priority(self) -> None:
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A, NODE_B]
|
||||
storage.buckets = [{"bucket": 0x0000, "node_id": "node-a"}]
|
||||
ws_id = _ws_id_for_bucket(0x0000)
|
||||
ws_id = _random_ws_id()
|
||||
storage.overrides = [{"ws_id": ws_id, "node_id": "node-b"}]
|
||||
router.refresh_cache()
|
||||
|
||||
# Override wins over bucket assignment
|
||||
# Override wins regardless of HRW score.
|
||||
assert router.route(ws_id) == NodeRef("node-b", "http://b:8080")
|
||||
|
||||
def test_route_empty_cache_raises(self) -> None:
|
||||
def test_route_empty_membership_raises(self) -> None:
|
||||
router, _ = _make_router()
|
||||
with pytest.raises(NoAvailableNodeError):
|
||||
router.route(_random_ws_id())
|
||||
|
||||
with pytest.raises(NoAvailableNodeError, match="not assigned"):
|
||||
router.route(_ws_id_for_bucket(0x0000))
|
||||
def test_route_empty_ws_id_raises(self) -> None:
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A]
|
||||
router.refresh_cache()
|
||||
with pytest.raises(NoAvailableNodeError, match="empty"):
|
||||
router.route("")
|
||||
|
||||
def test_route_url_convenience(self) -> None:
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A]
|
||||
storage.buckets = [{"bucket": 0x0010, "node_id": "node-a"}]
|
||||
router.refresh_cache()
|
||||
assert router.route_url(_random_ws_id()) == "http://a:8080"
|
||||
|
||||
|
||||
class TestMembershipConvergence:
|
||||
"""Rendezvous gives the minimal-moves property; pin it."""
|
||||
|
||||
def test_node_join_only_steals_some_keys(self) -> None:
|
||||
"""Adding a 4th node moves ~1/4 of keys to it; the other 3
|
||||
nodes' kept keys are unchanged."""
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A, NODE_B, NODE_C]
|
||||
router.refresh_cache()
|
||||
|
||||
assert router.route_url(_ws_id_for_bucket(0x0010)) == "http://a:8080"
|
||||
sample = [_random_ws_id() for _ in range(2000)]
|
||||
before = {ws: router.route(ws).node_id for ws in sample}
|
||||
|
||||
storage.services = [
|
||||
NODE_A,
|
||||
NODE_B,
|
||||
NODE_C,
|
||||
{"service_id": "node-d", "url": "http://d:8080", "metadata": "{}"},
|
||||
]
|
||||
router.refresh_cache()
|
||||
after = {ws: router.route(ws).node_id for ws in sample}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestRefreshCache
|
||||
# ---------------------------------------------------------------------------
|
||||
moved = sum(1 for ws in sample if before[ws] != after[ws])
|
||||
moved_to_new = sum(1 for ws in sample if after[ws] == "node-d")
|
||||
# Every move must be onto the new node — no churn between
|
||||
# existing nodes.
|
||||
assert moved == moved_to_new
|
||||
# Should be roughly 1/4 of keys; allow a wide band for variance.
|
||||
assert 0.15 < moved / len(sample) < 0.35
|
||||
|
||||
|
||||
class TestRefreshCache:
|
||||
"""Cache loading from storage."""
|
||||
|
||||
def test_refresh_loads_from_storage(self) -> None:
|
||||
def test_node_leave_only_redistributes_dead_node_keys(self) -> None:
|
||||
"""Removing node-a sends node-a's keys to b/c only; keys that
|
||||
were on b/c stay put."""
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A]
|
||||
storage.buckets = [{"bucket": 100, "node_id": "node-a"}]
|
||||
storage.services = [NODE_A, NODE_B, NODE_C]
|
||||
router.refresh_cache()
|
||||
|
||||
ref = router.route(_ws_id_for_bucket(100))
|
||||
assert ref.node_id == "node-a"
|
||||
sample = [_random_ws_id() for _ in range(2000)]
|
||||
before = {ws: router.route(ws).node_id for ws in sample}
|
||||
|
||||
def test_refresh_handles_dead_nodes(self) -> None:
|
||||
storage.services = [NODE_B, NODE_C]
|
||||
router.refresh_cache()
|
||||
after = {ws: router.route(ws).node_id for ws in sample}
|
||||
|
||||
for ws in sample:
|
||||
if before[ws] in ("node-b", "node-c"):
|
||||
assert after[ws] == before[ws], (
|
||||
f"key {ws} moved from {before[ws]} to {after[ws]} "
|
||||
"even though its old owner is still live"
|
||||
)
|
||||
else: # was on node-a
|
||||
assert after[ws] in ("node-b", "node-c")
|
||||
|
||||
|
||||
class TestWeights:
|
||||
def test_weight_2_node_gets_more_keys_than_weight_1(self) -> None:
|
||||
router, storage = _make_router()
|
||||
# node-b is in buckets but not in services (dead/expired)
|
||||
storage.services = [NODE_A]
|
||||
storage.buckets = [
|
||||
{"bucket": 0x0000, "node_id": "node-a"},
|
||||
{"bucket": 0x0001, "node_id": "node-b"},
|
||||
storage.services = [
|
||||
{"service_id": "node-a", "url": "http://a:8080", "metadata": '{"weight": 2}'},
|
||||
{"service_id": "node-b", "url": "http://b:8080", "metadata": '{"weight": 1}'},
|
||||
]
|
||||
router.refresh_cache()
|
||||
|
||||
assert router.route(_ws_id_for_bucket(0x0000)).node_id == "node-a"
|
||||
with pytest.raises(NoAvailableNodeError):
|
||||
router.route(_ws_id_for_bucket(0x0001))
|
||||
sample = [_random_ws_id() for _ in range(5000)]
|
||||
on_a = sum(1 for ws in sample if router.route(ws).node_id == "node-a")
|
||||
# Heavier node should win clearly more than half; exact ratio
|
||||
# depends on the simple hash×weight formulation but a/b > 1.4
|
||||
# for weight 2:1 across 5k samples is reliable.
|
||||
assert on_a / len(sample) > 0.55
|
||||
|
||||
def test_refresh_returns_true_on_change(self) -> None:
|
||||
def test_invalid_metadata_falls_back_to_weight_1(self) -> None:
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A]
|
||||
storage.buckets = [{"bucket": 0, "node_id": "node-a"}]
|
||||
|
||||
assert router.refresh_cache() is True
|
||||
|
||||
def test_refresh_returns_false_on_no_change(self) -> None:
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A]
|
||||
storage.buckets = [{"bucket": 0, "node_id": "node-a"}]
|
||||
|
||||
storage.services = [
|
||||
{"service_id": "node-a", "url": "http://a:8080", "metadata": "not json"},
|
||||
]
|
||||
router.refresh_cache()
|
||||
assert router.refresh_cache() is False
|
||||
# Just confirms it doesn't blow up.
|
||||
router.route(_random_ws_id())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCheckVersion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckVersion:
|
||||
"""Version-gated refresh."""
|
||||
|
||||
def test_version_change_triggers_refresh(self) -> None:
|
||||
class TestRefreshLifecycle:
|
||||
def test_refresh_cache_publishes_new_membership_immediately(self) -> None:
|
||||
"""refresh_cache() reloads on the calling thread — the next
|
||||
route() sees the new membership without any further trigger."""
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A]
|
||||
storage.buckets = [{"bucket": 0, "node_id": "node-a"}]
|
||||
storage.settings["rebalancer_version"] = {"value": "1"}
|
||||
router.refresh_cache()
|
||||
assert router.node_count() == 1
|
||||
|
||||
assert router.check_version() is True
|
||||
assert router.is_ready()
|
||||
storage.services = [NODE_A, NODE_B]
|
||||
router.refresh_cache()
|
||||
assert router.node_count() == 2
|
||||
|
||||
def test_concurrent_refresh_returns_false_on_lock_contention(self) -> None:
|
||||
"""refresh_cache uses a non-blocking lock acquire — if another
|
||||
thread is already refreshing, the second caller bails so the
|
||||
in-flight refresh's result is the one that publishes."""
|
||||
|
||||
def test_same_version_skips(self) -> None:
|
||||
router, storage = _make_router()
|
||||
# Default version is 0; setting absent also means 0
|
||||
storage.services = [NODE_A]
|
||||
storage.buckets = [{"bucket": 0, "node_id": "node-a"}]
|
||||
|
||||
# First call: version=0 matches self._version=0 -> no refresh
|
||||
assert router.check_version() is False
|
||||
assert not router.is_ready() # cache was never loaded
|
||||
with router._refresh_lock:
|
||||
# Lock held by this thread → the call below can't acquire.
|
||||
assert router.refresh_cache() is False
|
||||
|
||||
def test_force_refresh_blocks_until_in_flight_refresh_releases(self) -> None:
|
||||
"""force_refresh acquires the refresh lock blocking — used by the
|
||||
404-retry path to guarantee a fresh view even under contention."""
|
||||
import threading
|
||||
|
||||
def test_version_none_treated_as_zero(self) -> None:
|
||||
router, storage = _make_router()
|
||||
# settings dict is empty -> get_system_setting returns None
|
||||
assert router.check_version() is False
|
||||
storage.services = [NODE_A]
|
||||
|
||||
# Hold the refresh lock from another thread.
|
||||
lock_held = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestGenerateWsId
|
||||
# ---------------------------------------------------------------------------
|
||||
def hold_lock() -> None:
|
||||
with router._refresh_lock:
|
||||
lock_held.set()
|
||||
release.wait(timeout=2)
|
||||
|
||||
holder = threading.Thread(target=hold_lock, daemon=True)
|
||||
holder.start()
|
||||
assert lock_held.wait(timeout=1)
|
||||
|
||||
# force_refresh should block, not bail.
|
||||
result_box: list[bool] = []
|
||||
|
||||
def call_force() -> None:
|
||||
result_box.append(router.force_refresh())
|
||||
|
||||
caller = threading.Thread(target=call_force, daemon=True)
|
||||
caller.start()
|
||||
caller.join(timeout=0.2)
|
||||
assert caller.is_alive(), "force_refresh returned without acquiring lock"
|
||||
|
||||
release.set()
|
||||
holder.join(timeout=1)
|
||||
caller.join(timeout=1)
|
||||
assert not caller.is_alive()
|
||||
# Membership changed from empty → 1 live node.
|
||||
assert result_box == [True]
|
||||
assert router.node_count() == 1
|
||||
|
||||
def test_force_refresh_always_reloads(self) -> None:
|
||||
"""force_refresh skips the non-blocking-lock bail and always
|
||||
publishes a fresh view — back-to-back calls each pick up the
|
||||
latest storage state."""
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A]
|
||||
router.force_refresh()
|
||||
assert router.node_count() == 1
|
||||
|
||||
storage.services = [NODE_A, NODE_B]
|
||||
router.force_refresh()
|
||||
assert router.node_count() == 2
|
||||
|
||||
def test_version_is_monotonic_across_refreshes(self) -> None:
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A]
|
||||
router.refresh_cache()
|
||||
v1 = router.version
|
||||
router.refresh_cache()
|
||||
v2 = router.version
|
||||
assert v2 > v1
|
||||
router.force_refresh()
|
||||
assert router.version > v2
|
||||
|
||||
|
||||
class TestGenerateWsId:
|
||||
"""Workstream ID generation targeting a specific node."""
|
||||
|
||||
def test_generates_routable_id(self) -> None:
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A, NODE_B]
|
||||
storage.buckets = [
|
||||
{"bucket": 0x00FF, "node_id": "node-a"},
|
||||
{"bucket": 0x0100, "node_id": "node-b"},
|
||||
]
|
||||
storage.services = [NODE_A, NODE_B, NODE_C]
|
||||
router.refresh_cache()
|
||||
|
||||
ws_id = router.generate_ws_id_for_node("node-a")
|
||||
ws_id = router.generate_ws_id_for_node("node-b")
|
||||
assert len(ws_id) == 32
|
||||
assert router.route(ws_id).node_id == "node-a"
|
||||
assert router.route(ws_id).node_id == "node-b"
|
||||
|
||||
def test_unknown_node_raises(self) -> None:
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A]
|
||||
storage.buckets = [{"bucket": 0, "node_id": "node-a"}]
|
||||
router.refresh_cache()
|
||||
|
||||
with pytest.raises(NoAvailableNodeError, match="node-z"):
|
||||
router.generate_ws_id_for_node("node-z")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestIsReady
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsReady:
|
||||
"""Readiness checks."""
|
||||
|
||||
def test_false_when_empty(self) -> None:
|
||||
router, _ = _make_router()
|
||||
assert router.is_ready() is False
|
||||
|
||||
def test_true_after_refresh(self) -> None:
|
||||
def test_true_after_membership_loads(self) -> None:
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A]
|
||||
storage.buckets = [{"bucket": 0, "node_id": "node-a"}]
|
||||
router.refresh_cache()
|
||||
|
||||
assert router.is_ready() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestNodeCount
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNodeCount:
|
||||
"""Distinct node counting."""
|
||||
|
||||
def test_count_distinct_nodes(self) -> None:
|
||||
def test_count_matches_live_services(self) -> None:
|
||||
router, storage = _make_router()
|
||||
storage.services = [NODE_A, NODE_B, NODE_C]
|
||||
# Spread all 65536 buckets across 3 nodes
|
||||
storage.buckets = [
|
||||
{"bucket": b, "node_id": f"node-{['a', 'b', 'c'][b % 3]}"} for b in range(RING_SIZE)
|
||||
]
|
||||
router.refresh_cache()
|
||||
|
||||
assert router.node_count() == 3
|
||||
|
||||
@@ -11,7 +11,25 @@ from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
from turnstone.console.router import ConsoleRouter, NodeRef
|
||||
from turnstone.core.hash_ring import NoAvailableNodeError
|
||||
from turnstone.core.rendezvous import NoAvailableNodeError
|
||||
|
||||
# Shared test auth — JWT-based
|
||||
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
|
||||
|
||||
|
||||
def _test_jwt() -> str:
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
|
||||
|
||||
return create_jwt(
|
||||
user_id="test-routing",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
)
|
||||
|
||||
|
||||
_TEST_AUTH_HEADERS: dict[str, str] = {"Authorization": f"Bearer {_test_jwt()}"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -42,12 +60,11 @@ def _make_app(
|
||||
router: Any = None,
|
||||
) -> Any:
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
return create_app(
|
||||
collector=collector or _make_mock_collector(),
|
||||
auth_config=AuthConfig(),
|
||||
jwt_secret=_TEST_JWT_SECRET,
|
||||
router=router,
|
||||
)
|
||||
|
||||
@@ -85,7 +102,7 @@ def _wire_proxy(app: Any, mock_post: MagicMock | None = None) -> None:
|
||||
|
||||
|
||||
class TestRouteCreate:
|
||||
"""POST /v1/api/route/workstreams/new — create via hash-ring routing."""
|
||||
"""POST /v1/api/route/workstreams/new — create via rendezvous routing."""
|
||||
|
||||
@pytest.fixture()
|
||||
def client(self):
|
||||
@@ -100,6 +117,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test-ws"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -109,6 +127,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test-ws"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -126,6 +145,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"resume_ws": "old_ws_id"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -150,6 +170,7 @@ class TestRouteCreate:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"target_node": "node-c"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -157,6 +178,49 @@ class TestRouteCreate:
|
||||
router.generate_ws_id_for_node.assert_called_with("node-c")
|
||||
client.close()
|
||||
|
||||
def test_route_create_routing_strategy_rendezvous(self, client):
|
||||
"""Default fan-out (no resume_ws / no target_node) reports
|
||||
routing_strategy='rendezvous' so the coordinator's spawn tool
|
||||
can explain why the node was chosen."""
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test-ws"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["routing_strategy"] == "rendezvous"
|
||||
|
||||
def test_route_create_routing_strategy_target_node(self):
|
||||
router = _make_mock_router()
|
||||
router.generate_ws_id_for_node.return_value = "00ff" + "0" * 28
|
||||
router.route.return_value = NodeRef("node-c", "http://c:8080")
|
||||
app = _make_app(router=router)
|
||||
_wire_proxy(app, _make_proxy_post(json_data={"ws_id": "00ff" + "0" * 28, "name": "pinned"}))
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"target_node": "node-c"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["routing_strategy"] == "target_node"
|
||||
client.close()
|
||||
|
||||
def test_route_create_routing_strategy_resume(self):
|
||||
router = _make_mock_router()
|
||||
router.route.return_value = NodeRef("node-b", "http://b:8080")
|
||||
app = _make_app(router=router)
|
||||
_wire_proxy(app, _make_proxy_post(json_data={"ws_id": "old_ws_resumed", "name": "resumed"}))
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"resume_ws": "old_ws_id"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["routing_strategy"] == "resume"
|
||||
client.close()
|
||||
|
||||
|
||||
class TestRouteCreate503Retry:
|
||||
"""503 retry logic in route_create."""
|
||||
@@ -203,6 +267,7 @@ class TestRouteCreate503Retry:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test-ws"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -233,6 +298,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc123", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Verify upstream URL was /v1/api/send (not /v1/api/route/send)
|
||||
@@ -245,6 +311,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/approve",
|
||||
json={"ws_id": "abc123", "approved": True},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -252,6 +319,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/cancel",
|
||||
json={"ws_id": "abc123"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -259,6 +327,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/command",
|
||||
json={"ws_id": "abc123", "command": "status"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -266,6 +335,7 @@ class TestRouteProxy:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/close",
|
||||
json={"ws_id": "abc123"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -288,14 +358,14 @@ class TestRouteLookup:
|
||||
client.close()
|
||||
|
||||
def test_route_lookup(self, client):
|
||||
resp = client.get("/v1/api/route?ws_id=abc123")
|
||||
resp = client.get("/v1/api/route?ws_id=abc123", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["node_url"] == "http://a:8080"
|
||||
assert data["node_id"] == "node-a"
|
||||
|
||||
def test_route_lookup_missing_ws_id(self, client):
|
||||
resp = client.get("/v1/api/route")
|
||||
resp = client.get("/v1/api/route", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 400
|
||||
assert "ws_id" in resp.json()["error"]
|
||||
|
||||
@@ -329,6 +399,7 @@ class TestRouteNotReady:
|
||||
resp = client_no_router.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
@@ -336,6 +407,7 @@ class TestRouteNotReady:
|
||||
resp = client_empty_cache.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
@@ -343,22 +415,24 @@ class TestRouteNotReady:
|
||||
resp = client_no_router.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_route_lookup_no_router_503(self, client_no_router):
|
||||
resp = client_no_router.get("/v1/api/route?ws_id=abc")
|
||||
resp = client_no_router.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_route_proxy_empty_cache_503(self, client_empty_cache):
|
||||
resp = client_empty_cache.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_route_lookup_empty_cache_503(self, client_empty_cache):
|
||||
resp = client_empty_cache.get("/v1/api/route?ws_id=abc")
|
||||
resp = client_empty_cache.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
@@ -384,6 +458,7 @@ class TestRouteNoNode:
|
||||
resp = client.post(
|
||||
"/v1/api/route/workstreams/new",
|
||||
json={"name": "test"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert "No available node" in resp.json()["error"]
|
||||
@@ -392,9 +467,10 @@ class TestRouteNoNode:
|
||||
resp = client.post(
|
||||
"/v1/api/route/send",
|
||||
json={"ws_id": "abc", "message": "hello"},
|
||||
headers=_TEST_AUTH_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_route_lookup_no_node_503(self, client):
|
||||
resp = client.get("/v1/api/route?ws_id=abc")
|
||||
resp = client.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
|
||||
assert resp.status_code == 503
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
"""Tests for the coordinator ``close_all_children`` endpoint.
|
||||
|
||||
Near-twin of the ``stop_cascade`` tests in
|
||||
``test_coordinator_governance.py``. Keeps the close-cascade surface in
|
||||
its own file so PR A's review surface stays tight.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from tests._coord_test_helpers import (
|
||||
_AuthMiddleware,
|
||||
_build_mgr,
|
||||
_fake_registry,
|
||||
_FakeConfigStore,
|
||||
)
|
||||
from turnstone.console.server import coordinator_close_all_children
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path):
|
||||
return SQLiteBackend(str(tmp_path / "coord.db"))
|
||||
|
||||
|
||||
_COORD_HEADERS = {"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"}
|
||||
|
||||
|
||||
def _make_client(storage, *, coord_mgr, alias="my-model", registry=None) -> TestClient:
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/v1/api/coordinator/{ws_id}/close_all_children",
|
||||
coordinator_close_all_children,
|
||||
methods=["POST"],
|
||||
),
|
||||
],
|
||||
middleware=[Middleware(_AuthMiddleware)],
|
||||
)
|
||||
app.state.coord_mgr = coord_mgr
|
||||
app.state.config_store = _FakeConfigStore({"coordinator.model_alias": alias})
|
||||
app.state.coord_registry = registry
|
||||
app.state.coord_registry_error = "" if coord_mgr else "registry missing"
|
||||
app.state.auth_storage = storage
|
||||
app.state.jwt_secret = "x" * 64
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_close_all_children_closes_each_child_and_audits(storage):
|
||||
mgr = _build_mgr(storage)
|
||||
coord = mgr.create(user_id="user-1", name="coord-a")
|
||||
mgr.register_children(coord.id, ["child-1", "child-2", "child-3"])
|
||||
|
||||
def _close(wid, reason):
|
||||
if wid == "child-2":
|
||||
return {"error": "gateway_timeout", "status": 502}
|
||||
return {"status": "ok"}
|
||||
|
||||
coord_client = MagicMock()
|
||||
coord_client.close_workstream.side_effect = _close
|
||||
coord.session = MagicMock()
|
||||
coord.session._coord_client = coord_client
|
||||
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
resp = client.post(
|
||||
f"/v1/api/coordinator/{coord.id}/close_all_children",
|
||||
json={"reason": "tests done"},
|
||||
headers=_COORD_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert set(body["closed"] + body["failed"] + body["skipped"]) == {
|
||||
"child-1",
|
||||
"child-2",
|
||||
"child-3",
|
||||
}
|
||||
assert body["failed"] == ["child-2"]
|
||||
assert set(body["closed"]) == {"child-1", "child-3"}
|
||||
assert body["skipped"] == []
|
||||
assert coord_client.close_workstream.call_count == 3
|
||||
# Reason must propagate to each per-child close call.
|
||||
for call in coord_client.close_workstream.call_args_list:
|
||||
assert call.args[1] == "tests done"
|
||||
|
||||
events = [
|
||||
e for e in storage.list_audit_events() if e["action"] == "coordinator.closed_all_children"
|
||||
]
|
||||
assert len(events) == 1
|
||||
detail = json.loads(events[0]["detail"])
|
||||
assert detail["reason"] == "tests done"
|
||||
assert set(detail["closed"] + detail["failed"] + detail["skipped"]) == {
|
||||
"child-1",
|
||||
"child-2",
|
||||
"child-3",
|
||||
}
|
||||
|
||||
|
||||
def test_close_all_children_routes_404_to_skipped_bucket(storage):
|
||||
"""An upstream 404 (child row already deleted, stale registry entry)
|
||||
is 'already gone', not a dispatch failure. Route to skipped."""
|
||||
mgr = _build_mgr(storage)
|
||||
coord = mgr.create(user_id="user-1", name="coord-a")
|
||||
mgr.register_children(coord.id, ["stale-child"])
|
||||
|
||||
coord_client = MagicMock()
|
||||
coord_client.close_workstream.return_value = {
|
||||
"error": "workstream not in coordinator subtree: stale-child",
|
||||
"status": 404,
|
||||
}
|
||||
coord.session = MagicMock()
|
||||
coord.session._coord_client = coord_client
|
||||
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
resp = client.post(
|
||||
f"/v1/api/coordinator/{coord.id}/close_all_children",
|
||||
json={},
|
||||
headers=_COORD_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["closed"] == []
|
||||
assert body["failed"] == []
|
||||
assert body["skipped"] == ["stale-child"]
|
||||
|
||||
|
||||
def test_close_all_children_empty_children_still_audits(storage):
|
||||
mgr = _build_mgr(storage)
|
||||
coord = mgr.create(user_id="user-1", name="coord-a")
|
||||
coord.session = MagicMock()
|
||||
coord.session._coord_client = MagicMock()
|
||||
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
resp = client.post(
|
||||
f"/v1/api/coordinator/{coord.id}/close_all_children",
|
||||
json={},
|
||||
headers=_COORD_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body == {"status": "ok", "closed": [], "failed": [], "skipped": []}
|
||||
assert [
|
||||
e for e in storage.list_audit_events() if e["action"] == "coordinator.closed_all_children"
|
||||
]
|
||||
|
||||
|
||||
def test_close_all_children_without_coord_client_marks_all_failed(storage):
|
||||
mgr = _build_mgr(storage)
|
||||
coord = mgr.create(user_id="user-1", name="coord-a")
|
||||
mgr.register_children(coord.id, ["child-a", "child-b"])
|
||||
coord.session = MagicMock()
|
||||
coord.session._coord_client = None
|
||||
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
resp = client.post(
|
||||
f"/v1/api/coordinator/{coord.id}/close_all_children",
|
||||
json={},
|
||||
headers=_COORD_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["closed"] == []
|
||||
assert body["skipped"] == []
|
||||
assert set(body["failed"]) == {"child-a", "child-b"}
|
||||
|
||||
|
||||
def test_close_all_children_rejects_non_string_reason(storage):
|
||||
mgr = _build_mgr(storage)
|
||||
coord = mgr.create(user_id="user-1", name="coord-a")
|
||||
coord.session = MagicMock()
|
||||
coord.session._coord_client = MagicMock()
|
||||
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
resp = client.post(
|
||||
f"/v1/api/coordinator/{coord.id}/close_all_children",
|
||||
json={"reason": 123},
|
||||
headers=_COORD_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_close_all_children_rejects_overlong_reason(storage):
|
||||
mgr = _build_mgr(storage)
|
||||
coord = mgr.create(user_id="user-1", name="coord-a")
|
||||
coord.session = MagicMock()
|
||||
coord.session._coord_client = MagicMock()
|
||||
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
resp = client.post(
|
||||
f"/v1/api/coordinator/{coord.id}/close_all_children",
|
||||
json={"reason": "x" * 600},
|
||||
headers=_COORD_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_close_all_children_404_when_session_not_loaded(storage):
|
||||
mgr = _build_mgr(storage)
|
||||
coord = mgr.create(user_id="user-1", name="coord-a")
|
||||
coord.session = None
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
resp = client.post(
|
||||
f"/v1/api/coordinator/{coord.id}/close_all_children",
|
||||
json={},
|
||||
headers=_COORD_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_close_all_children_service_token_cannot_bypass_admin_coordinator(storage):
|
||||
"""Destructive endpoint — a service token matching the coord owner
|
||||
still needs the explicit ``admin.coordinator`` grant. Mirrors the
|
||||
stop_cascade treatment."""
|
||||
mgr = _build_mgr(storage)
|
||||
coord = mgr.create(user_id="user-1", name="coord-a")
|
||||
coord.session = MagicMock()
|
||||
coord.session._coord_client = MagicMock()
|
||||
|
||||
# Service token without admin.coordinator should be rejected.
|
||||
headers = {"X-Test-User": "user-1", "X-Test-Perms": ""}
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
resp = client.post(
|
||||
f"/v1/api/coordinator/{coord.id}/close_all_children",
|
||||
json={},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code in (401, 403)
|
||||
@@ -0,0 +1,435 @@
|
||||
"""End-to-end integration tests for the coordinator workstream feature.
|
||||
|
||||
Tests cover the full create → inspect → list → close lifecycle using
|
||||
real in-process components:
|
||||
|
||||
1. Create + list + detail round-trip via the Starlette TestClient.
|
||||
2. CoordinatorClient against a MockTransport "server node" stub.
|
||||
3. list_children storage read flow (kind filtering, parent scoping).
|
||||
4. Lazy rehydration via GET /v1/api/coordinator/{ws_id}.
|
||||
|
||||
Intentionally no real LLM infrastructure — session factories return
|
||||
MagicMock-backed stubs. All four tests run in < 2 s total.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.coordinator import CoordinatorManager
|
||||
from turnstone.console.coordinator_client import CoordinatorClient
|
||||
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
|
||||
from turnstone.console.server import (
|
||||
coordinator_close,
|
||||
coordinator_create,
|
||||
coordinator_detail,
|
||||
coordinator_list,
|
||||
)
|
||||
from turnstone.core.auth import AuthResult
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared auth-injection middleware (mirrors test_coordinator_endpoints.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Inject an ``AuthResult`` from ``X-Test-Perms`` / ``X-Test-User``."""
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
perms = request.headers.get("X-Test-Perms", "")
|
||||
user_id = request.headers.get("X-Test-User", "")
|
||||
if perms or user_id:
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id=user_id,
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="test",
|
||||
permissions=frozenset(p for p in perms.split(",") if p),
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared stubs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeConfigStore:
|
||||
"""Minimal ConfigStore stub returning values from a dict."""
|
||||
|
||||
def __init__(self, values: dict[str, Any]) -> None:
|
||||
self._values = values
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self._values.get(key, default)
|
||||
|
||||
|
||||
def _fake_registry() -> MagicMock:
|
||||
"""Registry stub that always succeeds on .resolve() so the 503 gate passes."""
|
||||
reg = MagicMock()
|
||||
reg.resolve.return_value = (MagicMock(), "gpt-test", MagicMock())
|
||||
return reg
|
||||
|
||||
|
||||
def _build_mgr(storage: SQLiteBackend) -> CoordinatorManager:
|
||||
"""Build a CoordinatorManager backed by stub factories."""
|
||||
|
||||
def _sf(ui, model_alias=None, ws_id=None, **kw):
|
||||
s = MagicMock()
|
||||
s.ws_id = ws_id
|
||||
s.send.return_value = None
|
||||
return s
|
||||
|
||||
return CoordinatorManager(
|
||||
session_factory=_sf,
|
||||
ui_factory=lambda w, u: ConsoleCoordinatorUI(ws_id=w, user_id=u),
|
||||
storage=storage,
|
||||
max_active=5,
|
||||
)
|
||||
|
||||
|
||||
def _make_client(
|
||||
storage: SQLiteBackend,
|
||||
*,
|
||||
coord_mgr: CoordinatorManager | None = None,
|
||||
alias: str = "my-model",
|
||||
registry: Any = None,
|
||||
) -> TestClient:
|
||||
"""Build a Starlette TestClient exposing the coordinator routes."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/v1/api/coordinator/new",
|
||||
coordinator_create,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route("/v1/api/coordinator", coordinator_list, methods=["GET"]),
|
||||
Route(
|
||||
"/v1/api/coordinator/{ws_id}/close",
|
||||
coordinator_close,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/v1/api/coordinator/{ws_id}",
|
||||
coordinator_detail,
|
||||
methods=["GET"],
|
||||
),
|
||||
],
|
||||
middleware=[Middleware(_AuthMiddleware)],
|
||||
)
|
||||
app.state.coord_mgr = coord_mgr
|
||||
app.state.config_store = _FakeConfigStore({"coordinator.model_alias": alias})
|
||||
app.state.coord_registry = registry
|
||||
app.state.coord_registry_error = "" if coord_mgr else "registry missing"
|
||||
app.state.auth_storage = storage
|
||||
app.state.jwt_secret = "x" * 64
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1 — Create + list + detail round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_COORD_HEADERS = {"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"}
|
||||
|
||||
|
||||
def test_create_list_detail_lifecycle(tmp_path):
|
||||
"""POST /new → appears in GET / → GET /{ws_id} returns correct detail."""
|
||||
storage = SQLiteBackend(str(tmp_path / "coord.db"))
|
||||
mgr = _build_mgr(storage)
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
|
||||
# --- Create ---
|
||||
resp = client.post(
|
||||
"/v1/api/coordinator/new",
|
||||
json={"name": "e2e-coord"},
|
||||
headers=_COORD_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
ws_id = body["ws_id"]
|
||||
assert ws_id
|
||||
assert "e2e-coord" in body["name"]
|
||||
|
||||
# --- List: caller sees their own coordinator ---
|
||||
resp = client.get("/v1/api/coordinator", headers=_COORD_HEADERS)
|
||||
assert resp.status_code == 200, resp.text
|
||||
coordinators = resp.json()["coordinators"]
|
||||
ids = {c["ws_id"] for c in coordinators}
|
||||
assert ws_id in ids
|
||||
|
||||
# Coordinator created by a different user is invisible to our caller.
|
||||
mgr.create(user_id="other-user", name="not-mine")
|
||||
resp = client.get("/v1/api/coordinator", headers=_COORD_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
names = {c["name"] for c in resp.json()["coordinators"]}
|
||||
assert "not-mine" not in names
|
||||
|
||||
# --- Detail ---
|
||||
resp = client.get(f"/v1/api/coordinator/{ws_id}", headers=_COORD_HEADERS)
|
||||
assert resp.status_code == 200, resp.text
|
||||
detail = resp.json()
|
||||
assert detail["ws_id"] == ws_id
|
||||
assert detail["kind"] == "coordinator"
|
||||
assert detail["user_id"] == "user-1"
|
||||
|
||||
# --- Close ---
|
||||
resp = client.post(f"/v1/api/coordinator/{ws_id}/close", headers=_COORD_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Manager no longer tracks it after close.
|
||||
assert mgr.get(ws_id) is None
|
||||
|
||||
# Storage row reflects closed state.
|
||||
row = storage.get_workstream(ws_id)
|
||||
assert row is not None
|
||||
assert row["state"] == "closed"
|
||||
|
||||
# Detail endpoint returns 404 after close (not in memory, not rehydratable
|
||||
# from a "closed" row — well, the manager would rehydrate it but let's verify
|
||||
# the row is gone from the in-memory index).
|
||||
assert mgr.get(ws_id) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2 — CoordinatorClient against a MockTransport "server node" stub
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_coordinator_client_spawn_close_delete(tmp_path):
|
||||
"""CoordinatorClient.spawn / close_workstream / delete produce correct
|
||||
upstream HTTP requests to the mocked server node."""
|
||||
storage = SQLiteBackend(str(tmp_path / "client.db"))
|
||||
# Register the coordinator + the soon-to-be-spawned child so the
|
||||
# client-side tenant guard on close/delete passes. In production
|
||||
# the spawn route adds the child row before the model can call
|
||||
# close on it; the test stub doesn't run that side-effect, so we
|
||||
# set it up here.
|
||||
storage.register_workstream("coord-42", kind="coordinator", user_id="user-1")
|
||||
storage.register_workstream(
|
||||
"child-99", kind="interactive", parent_ws_id="coord-42", user_id="user-1"
|
||||
)
|
||||
captured: list[httpx.Request] = []
|
||||
|
||||
def _handler(req: httpx.Request) -> httpx.Response:
|
||||
captured.append(req)
|
||||
path = req.url.path
|
||||
if path == "/v1/api/route/workstreams/new":
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={"ws_id": "child-99", "name": "spawned", "node_id": "node-a"},
|
||||
)
|
||||
# close and delete both return a generic ok
|
||||
return httpx.Response(200, json={"status": "ok"})
|
||||
|
||||
transport = httpx.MockTransport(_handler)
|
||||
http = httpx.Client(transport=transport)
|
||||
coord_client = CoordinatorClient(
|
||||
console_base_url="http://console",
|
||||
storage=storage,
|
||||
token_factory=lambda: "bearer-test-token",
|
||||
coord_ws_id="coord-42",
|
||||
user_id="user-1",
|
||||
http_client=http,
|
||||
)
|
||||
|
||||
# spawn ---------------------------------------------------------------
|
||||
result = coord_client.spawn(
|
||||
initial_message="analyse data",
|
||||
parent_ws_id="coord-42",
|
||||
user_id="user-1",
|
||||
skill="data-skill",
|
||||
target_node="node-a",
|
||||
)
|
||||
assert result["ws_id"] == "child-99"
|
||||
|
||||
spawn_req = captured[0]
|
||||
assert spawn_req.method == "POST"
|
||||
assert spawn_req.url.path == "/v1/api/route/workstreams/new"
|
||||
assert spawn_req.headers["Authorization"] == "Bearer bearer-test-token"
|
||||
|
||||
spawn_body = json.loads(spawn_req.content)
|
||||
assert spawn_body["kind"] == "interactive"
|
||||
assert spawn_body["parent_ws_id"] == "coord-42"
|
||||
assert spawn_body["user_id"] == "user-1"
|
||||
assert spawn_body["initial_message"] == "analyse data"
|
||||
assert spawn_body["skill"] == "data-skill"
|
||||
assert spawn_body["target_node"] == "node-a"
|
||||
|
||||
# close_workstream ----------------------------------------------------
|
||||
captured.clear()
|
||||
close_result = coord_client.close_workstream("child-99")
|
||||
assert close_result.get("status") in (200, "ok"), close_result
|
||||
|
||||
close_req = captured[0]
|
||||
assert close_req.url.path == "/v1/api/route/workstreams/close"
|
||||
close_body = json.loads(close_req.content)
|
||||
assert close_body["ws_id"] == "child-99"
|
||||
|
||||
# delete --------------------------------------------------------------
|
||||
captured.clear()
|
||||
del_result = coord_client.delete("child-99")
|
||||
assert del_result.get("status") in (200, "ok"), del_result
|
||||
|
||||
del_req = captured[0]
|
||||
assert del_req.url.path == "/v1/api/route/workstreams/delete"
|
||||
del_body = json.loads(del_req.content)
|
||||
assert del_body["ws_id"] == "child-99"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3 — list_children storage read: kind filtering + parent scoping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seeded_storage(tmp_path):
|
||||
"""SQLiteBackend with a coordinator + 2 interactive children + extras."""
|
||||
st = SQLiteBackend(str(tmp_path / "seed.db"))
|
||||
# Parent coordinator.
|
||||
st.register_workstream("coord-root", kind="coordinator", user_id="user-1")
|
||||
# Two interactive children — one idle, one running. Children inherit
|
||||
# the coord's user_id by construction (server-side create gate), which
|
||||
# the list_children SQL filter now enforces.
|
||||
st.register_workstream(
|
||||
"child-idle",
|
||||
kind="interactive",
|
||||
parent_ws_id="coord-root",
|
||||
state="idle",
|
||||
skill_id="skill-alpha",
|
||||
user_id="user-1",
|
||||
)
|
||||
st.register_workstream(
|
||||
"child-running",
|
||||
kind="interactive",
|
||||
parent_ws_id="coord-root",
|
||||
state="running",
|
||||
skill_id="skill-beta",
|
||||
user_id="user-1",
|
||||
)
|
||||
# Coordinator child — MUST be excluded from list_children results.
|
||||
st.register_workstream(
|
||||
"child-coord",
|
||||
kind="coordinator",
|
||||
parent_ws_id="coord-root",
|
||||
user_id="user-1",
|
||||
)
|
||||
# Unrelated workstream with no parent — MUST be excluded.
|
||||
st.register_workstream("unrelated-ws", kind="interactive", user_id="user-1")
|
||||
return st
|
||||
|
||||
|
||||
def _read_client(storage: SQLiteBackend) -> CoordinatorClient:
|
||||
"""Build a CoordinatorClient whose HTTP transport is a no-op stub."""
|
||||
transport = httpx.MockTransport(lambda r: httpx.Response(200))
|
||||
http = httpx.Client(transport=transport)
|
||||
return CoordinatorClient(
|
||||
console_base_url="http://x",
|
||||
storage=storage,
|
||||
token_factory=lambda: "t",
|
||||
coord_ws_id="coord-root",
|
||||
user_id="user-1",
|
||||
http_client=http,
|
||||
)
|
||||
|
||||
|
||||
def test_list_children_excludes_coordinator_and_unrelated_rows(seeded_storage):
|
||||
"""list_children returns only interactive children of the given parent."""
|
||||
client = _read_client(seeded_storage)
|
||||
result = client.list_children("coord-root")
|
||||
rows = result["children"]
|
||||
|
||||
ws_ids = {r["ws_id"] for r in rows}
|
||||
# The two interactive children are present.
|
||||
assert ws_ids == {"child-idle", "child-running"}
|
||||
# Every returned row must be interactive and linked to coord-root.
|
||||
for r in rows:
|
||||
assert r["kind"] == "interactive"
|
||||
assert r["parent_ws_id"] == "coord-root"
|
||||
|
||||
# Coordinator child and unrelated ws are absent.
|
||||
assert "child-coord" not in ws_ids
|
||||
assert "unrelated-ws" not in ws_ids
|
||||
assert result["truncated"] is False
|
||||
|
||||
|
||||
def test_list_children_state_filter(seeded_storage):
|
||||
"""list_children(state='running') filters to only running children."""
|
||||
client = _read_client(seeded_storage)
|
||||
result = client.list_children("coord-root", state="running")
|
||||
assert {r["ws_id"] for r in result["children"]} == {"child-running"}
|
||||
|
||||
|
||||
def test_list_children_skill_filter(seeded_storage):
|
||||
"""list_children(skill='skill-alpha') returns the matching child only."""
|
||||
client = _read_client(seeded_storage)
|
||||
result = client.list_children("coord-root", skill="skill-alpha")
|
||||
rows = result["children"]
|
||||
assert {r["ws_id"] for r in rows} == {"child-idle"}
|
||||
assert rows[0].get("skill_id") == "skill-alpha"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4 — Lazy rehydration via GET /v1/api/coordinator/{ws_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_lazy_rehydration_on_detail_get(tmp_path):
|
||||
"""A persisted coordinator row rehydrates into the manager on GET /{ws_id}.
|
||||
|
||||
Sequence:
|
||||
1. Pre-seed storage with a coordinator row (simulating a previous process).
|
||||
2. Build a CoordinatorManager that doesn't know about it yet.
|
||||
3. Hit GET /v1/api/coordinator/{ws_id} — expect 200.
|
||||
4. Manager now tracks the rehydrated session.
|
||||
5. The response body carries the correct kind / user_id metadata.
|
||||
"""
|
||||
storage = SQLiteBackend(str(tmp_path / "rehydrate.db"))
|
||||
|
||||
# Seed the row directly — the manager has never seen it.
|
||||
storage.register_workstream(
|
||||
"persisted-coord",
|
||||
node_id="console",
|
||||
user_id="user-1",
|
||||
name="old-coord",
|
||||
kind="coordinator",
|
||||
)
|
||||
|
||||
mgr = _build_mgr(storage)
|
||||
# Confirm: not tracked in memory yet.
|
||||
assert mgr.get("persisted-coord") is None
|
||||
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
resp = client.get("/v1/api/coordinator/persisted-coord", headers=_COORD_HEADERS)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
body = resp.json()
|
||||
assert body["ws_id"] == "persisted-coord"
|
||||
assert body["kind"] == "coordinator"
|
||||
assert body["user_id"] == "user-1"
|
||||
|
||||
# The endpoint triggers lazy rehydration — manager now tracks it.
|
||||
assert mgr.get("persisted-coord") is not None
|
||||
|
||||
# Non-owner cannot reach the same endpoint (returns 404 — no existence leak).
|
||||
resp_stranger = client.get(
|
||||
"/v1/api/coordinator/persisted-coord",
|
||||
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
|
||||
)
|
||||
assert resp_stranger.status_code == 404
|
||||
|
||||
# A workstream with kind='interactive' is not reachable via the coordinator
|
||||
# endpoint even when it exists in storage.
|
||||
storage.register_workstream("interactive-ws", kind="interactive", user_id="user-1")
|
||||
resp_int = client.get("/v1/api/coordinator/interactive-ws", headers=_COORD_HEADERS)
|
||||
assert resp_int.status_code == 404
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user