mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-14 07:52:25 -06:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d402fea6b | |||
| 46d14ddd86 | |||
| 7c4157f78d | |||
| 3856d80709 | |||
| 8142d2f1ad | |||
| c234d66ebf |
@@ -74,53 +74,6 @@ jobs:
|
||||
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:
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -65,12 +65,12 @@ jobs:
|
||||
fi
|
||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
|
||||
- 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@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
+9
-11
@@ -1,10 +1,5 @@
|
||||
# =============================================================================
|
||||
# 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.
|
||||
# Turnstone Docker Compose Stack
|
||||
#
|
||||
# Usage:
|
||||
# Infra only: docker compose up
|
||||
@@ -65,7 +60,9 @@ services:
|
||||
# turnstone-server — Web UI + chat workstreams + LLM interaction
|
||||
# -------------------------------------------------------------------
|
||||
server:
|
||||
image: turnstone:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
profiles:
|
||||
- production
|
||||
command:
|
||||
@@ -118,7 +115,6 @@ services:
|
||||
# turnstone-console — Cluster dashboard
|
||||
# -------------------------------------------------------------------
|
||||
console:
|
||||
image: turnstone:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -149,7 +145,9 @@ services:
|
||||
# Requires TURNSTONE_DISCORD_TOKEN to enable Discord adapter
|
||||
# -------------------------------------------------------------------
|
||||
channel:
|
||||
image: turnstone:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
@@ -166,7 +164,7 @@ services:
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-postgresql}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
|
||||
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
|
||||
networks:
|
||||
- turnstone-net
|
||||
@@ -216,7 +214,7 @@ services:
|
||||
MODEL: ${MODEL:-}
|
||||
MCP_CONFIG: ${MCP_CONFIG:-}
|
||||
TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql}
|
||||
TURNSTONE_DB_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone}
|
||||
TURNSTONE_DB_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone}
|
||||
TURNSTONE_NODE_ID: node-1
|
||||
TURNSTONE_ADVERTISE_URL: http://server-1:8080
|
||||
extra_hosts: ["host.docker.internal:host-gateway"]
|
||||
|
||||
+1
-3
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0a2"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
@@ -67,7 +67,6 @@ 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",
|
||||
@@ -81,7 +80,6 @@ include = [
|
||||
"turnstone/shared_static/hljs-11.11.1/**/*",
|
||||
"turnstone/shared_static/mermaid-11.14.0/**/*",
|
||||
"turnstone/sdk/py.typed",
|
||||
"turnstone/deploy/*.yaml",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
Generated
+10
-10
@@ -14,22 +14,22 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
@@ -39,9 +39,9 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
|
||||
+1
-48
@@ -19,7 +19,6 @@ from turnstone.bootstrap import (
|
||||
_tool_generate_secret,
|
||||
_tool_read_file,
|
||||
_tool_validate_api_key,
|
||||
_tool_write_compose,
|
||||
_tool_write_file,
|
||||
execute_tool,
|
||||
)
|
||||
@@ -104,52 +103,6 @@ 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({})
|
||||
@@ -667,7 +620,7 @@ class TestConstants:
|
||||
assert func["parameters"]["type"] == "object"
|
||||
|
||||
def test_tool_count(self) -> None:
|
||||
assert len(TOOLS) == 8
|
||||
assert len(TOOLS) == 7
|
||||
|
||||
def test_all_tools_have_implementations(self) -> None:
|
||||
from turnstone.bootstrap import TOOL_FUNCTIONS
|
||||
|
||||
@@ -37,55 +37,3 @@ class TestStripHtml:
|
||||
def test_self_closing_tags(self):
|
||||
result = strip_html("hello<br/>world")
|
||||
assert result == "helloworld"
|
||||
|
||||
# -- invisible element stripping -----------------------------------------
|
||||
|
||||
def test_strips_script_content(self):
|
||||
html = "<p>before</p><script>var x = 1;</script><p>after</p>"
|
||||
result = strip_html(html)
|
||||
assert "var x" not in result
|
||||
assert "before" in result
|
||||
assert "after" in result
|
||||
|
||||
def test_strips_style_content(self):
|
||||
html = "<style>.foo { color: red; }</style><p>visible</p>"
|
||||
result = strip_html(html)
|
||||
assert "color" not in result
|
||||
assert "visible" in result
|
||||
|
||||
def test_strips_template_content(self):
|
||||
html = "<template><div>hidden</div></template><p>shown</p>"
|
||||
result = strip_html(html)
|
||||
assert "hidden" not in result
|
||||
assert "shown" in result
|
||||
|
||||
def test_strips_noscript_content(self):
|
||||
html = "<noscript>Enable JS</noscript><p>content</p>"
|
||||
result = strip_html(html)
|
||||
assert "Enable JS" not in result
|
||||
assert "content" in result
|
||||
|
||||
def test_strips_multiple_script_blocks(self):
|
||||
html = "<script>a()</script><p>middle</p><script>b()</script>"
|
||||
result = strip_html(html)
|
||||
assert "a()" not in result
|
||||
assert "b()" not in result
|
||||
assert "middle" in result
|
||||
|
||||
def test_strips_multiline_script(self):
|
||||
html = "<script>\nfunction foo() {\n return 1;\n}\n</script><p>ok</p>"
|
||||
result = strip_html(html)
|
||||
assert "function" not in result
|
||||
assert "ok" in result
|
||||
|
||||
def test_strips_script_case_insensitive(self):
|
||||
html = "<SCRIPT>code()</SCRIPT><p>text</p>"
|
||||
result = strip_html(html)
|
||||
assert "code()" not in result
|
||||
assert "text" in result
|
||||
|
||||
def test_strips_script_with_attributes(self):
|
||||
html = '<script type="text/javascript" src="app.js">init();</script><p>done</p>'
|
||||
result = strip_html(html)
|
||||
assert "init()" not in result
|
||||
assert "done" in result
|
||||
|
||||
@@ -759,8 +759,6 @@ class TestTitleRetry:
|
||||
"""_generate_title resets _title_generated on failure."""
|
||||
|
||||
def test_title_generated_reset_on_failure(self, tmp_db):
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
session = _make_session()
|
||||
session._title_generated = True
|
||||
session.messages = [
|
||||
@@ -769,7 +767,6 @@ class TestTitleRetry:
|
||||
]
|
||||
# Mock provider to raise
|
||||
session._provider = MagicMock()
|
||||
session._provider.get_capabilities.return_value = ModelCapabilities()
|
||||
session._provider.create_completion.side_effect = RuntimeError("API error")
|
||||
|
||||
session._generate_title()
|
||||
@@ -777,8 +774,6 @@ class TestTitleRetry:
|
||||
assert session._title_generated is False
|
||||
|
||||
def test_title_generated_stays_true_on_success(self, tmp_db):
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
session = _make_session()
|
||||
session._title_generated = True
|
||||
session.messages = [
|
||||
@@ -788,7 +783,6 @@ class TestTitleRetry:
|
||||
result = MagicMock()
|
||||
result.content = "Test Title"
|
||||
session._provider = MagicMock()
|
||||
session._provider.get_capabilities.return_value = ModelCapabilities()
|
||||
session._provider.create_completion.return_value = result
|
||||
|
||||
with patch("turnstone.core.session.update_workstream_title"):
|
||||
@@ -799,8 +793,6 @@ class TestTitleRetry:
|
||||
|
||||
def test_title_skipped_after_resume_changes_ws_id(self, tmp_db):
|
||||
"""If ws_id changes (via resume) during title generation, discard the result."""
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
session = _make_session()
|
||||
session._title_generated = True
|
||||
session.messages = [
|
||||
@@ -811,7 +803,6 @@ class TestTitleRetry:
|
||||
result = MagicMock()
|
||||
result.content = "Test Title"
|
||||
session._provider = MagicMock()
|
||||
session._provider.get_capabilities.return_value = ModelCapabilities()
|
||||
session._provider.create_completion.return_value = result
|
||||
|
||||
# Simulate resume() changing ws_id while title generation is in flight
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
"""Tests for the storage backend registry."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage import (
|
||||
StorageUnavailableError,
|
||||
get_storage,
|
||||
init_storage,
|
||||
reset_storage,
|
||||
)
|
||||
from turnstone.core.storage._postgresql import PostgreSQLBackend
|
||||
from turnstone.core.storage import get_storage, init_storage, reset_storage
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@@ -61,69 +52,3 @@ class TestResetStorage:
|
||||
init_storage("sqlite", path=str(tmp_path / "test2.db"), run_migrations=False)
|
||||
s2 = get_storage()
|
||||
assert s1 is not s2
|
||||
|
||||
|
||||
class TestConnUnavailableLogging:
|
||||
"""Test that _conn() deduplicates DB unavailable/restored logging."""
|
||||
|
||||
def _make_backend(self, tmp_path):
|
||||
"""Create a minimal SQLite backend for testing _conn()."""
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
return SQLiteBackend(str(tmp_path / "test.db"), create_tables=True)
|
||||
|
||||
def test_logs_unavailable_once(self, tmp_path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
backend = self._make_backend(tmp_path)
|
||||
with patch.object(backend, "_engine") as mock_engine:
|
||||
mock_engine.connect.side_effect = sa.exc.OperationalError(
|
||||
"conn", {}, Exception("refused")
|
||||
)
|
||||
for _ in range(3):
|
||||
with pytest.raises(StorageUnavailableError), backend._conn():
|
||||
pass # pragma: no cover
|
||||
unavailable_msgs = [r for r in caplog.records if "database.unavailable" in r.message]
|
||||
assert len(unavailable_msgs) == 1
|
||||
|
||||
def test_logs_restored_on_recovery(self, tmp_path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.INFO)
|
||||
backend = self._make_backend(tmp_path)
|
||||
# Simulate outage
|
||||
with patch.object(backend, "_engine") as mock_engine:
|
||||
mock_engine.connect.side_effect = sa.exc.OperationalError(
|
||||
"conn", {}, Exception("refused")
|
||||
)
|
||||
with pytest.raises(StorageUnavailableError), backend._conn():
|
||||
pass # pragma: no cover
|
||||
assert backend._db_unavailable is True
|
||||
# Real connection — should log restored
|
||||
caplog.clear()
|
||||
with backend._conn():
|
||||
pass
|
||||
restored_msgs = [r for r in caplog.records if "database.connection_restored" in r.message]
|
||||
assert len(restored_msgs) == 1
|
||||
assert backend._db_unavailable is False
|
||||
|
||||
def test_postgresql_conn_raises_storage_unavailable(self) -> None:
|
||||
import threading
|
||||
|
||||
backend = PostgreSQLBackend.__new__(PostgreSQLBackend)
|
||||
backend._db_unavailable = False
|
||||
backend._db_unavailable_lock = threading.Lock()
|
||||
|
||||
def _raise_op_error():
|
||||
raise sa.exc.OperationalError("conn", {}, Exception("refused"))
|
||||
|
||||
mock_engine = type(
|
||||
"E",
|
||||
(),
|
||||
{
|
||||
"connect": staticmethod(_raise_op_error),
|
||||
"url": sa.engine.make_url("postgresql://user:pass@localhost/db"),
|
||||
},
|
||||
)()
|
||||
backend._engine = mock_engine
|
||||
with pytest.raises(StorageUnavailableError), backend._conn():
|
||||
pass # pragma: no cover
|
||||
assert backend._db_unavailable is True
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.0.3"
|
||||
__version__ = "1.1.0a2"
|
||||
|
||||
+37
-96
@@ -2,15 +2,14 @@
|
||||
|
||||
Entry point: turnstone-bootstrap
|
||||
|
||||
Walks users through configuring a Turnstone deployment via a conversational
|
||||
AI assistant. Generates compose.yaml, .env files, and post-start setup
|
||||
scripts.
|
||||
Walks users through configuring a single-node or multi-node Turnstone
|
||||
deployment via a conversational AI assistant. Generates .env files,
|
||||
docker-compose overrides, and post-start setup scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import importlib.resources
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
@@ -61,6 +60,8 @@ Turnstone is a multi-node AI orchestration platform. A deployment consists of:
|
||||
## Deployment Profiles (compose.yaml)
|
||||
- **Default** (no flag): console only (infrastructure, good for running external servers)
|
||||
- **Production** (`--profile production`): 1 server + console + PostgreSQL + channel (single node)
|
||||
- **Cluster** (`--profile cluster`): 10-node server fleet + PostgreSQL + channel + console (multi-node)
|
||||
- **ddgCluster** (`--profile ddgCluster`): Cluster + DuckDuckGo Search MCP sidecar (web search via MCP, no API key needed)
|
||||
|
||||
## Environment Variables (.env)
|
||||
The compose.yaml reads these from a `.env` file:
|
||||
@@ -77,9 +78,9 @@ For commercial providers (OpenAI, Anthropic-via-proxy), use the real key.
|
||||
|
||||
### Database
|
||||
- `DB_BACKEND` — `sqlite` (default) or `postgresql`
|
||||
- `DATABASE_URL` — PostgreSQL connection string (production only)
|
||||
- `DATABASE_URL` — PostgreSQL connection string (production/cluster only)
|
||||
- `POSTGRES_USER` — PostgreSQL username (default: turnstone)
|
||||
- `POSTGRES_PASSWORD` — PostgreSQL password (required for production)
|
||||
- `POSTGRES_PASSWORD` — PostgreSQL password (required for production/cluster)
|
||||
|
||||
### Authentication (always enabled)
|
||||
- `TURNSTONE_JWT_SECRET` — JWT signing secret (required). All services must share the same secret. \
|
||||
@@ -103,15 +104,16 @@ Generate with: `python -c "import secrets; print(secrets.token_hex(32))"`
|
||||
- `TURNSTONE_DISCORD_TOKEN` — Discord bot token
|
||||
- `TURNSTONE_DISCORD_GUILD` — Restrict to single guild ID
|
||||
|
||||
### Docker Image
|
||||
- `TURNSTONE_IMAGE_TAG` — Docker image tag (default: `latest`). \
|
||||
Set this to pin the image version (e.g., `1.1.0a3`, `stable`, `experimental`).
|
||||
|
||||
### MCP Integration (optional)
|
||||
- `MCP_CONFIG` — Path to MCP server config inside the container. \
|
||||
When set, servers connect to configured MCP servers on startup.
|
||||
- `MCP_CONFIG` — Path to MCP server config inside the container \
|
||||
(e.g., `/etc/turnstone/mcp-ddg.json`). When set, servers connect to configured MCP servers on startup.
|
||||
- The `ddgCluster` profile runs a DuckDuckGo Search MCP sidecar (Python) that provides \
|
||||
`duckduckgo_web_search` and `duckduckgo_fetch_content` tools to every node. No API key required. \
|
||||
The sidecar uses MCP streamable-http transport with DNS rebinding protection disabled \
|
||||
(required for Docker internal networking) and binds to 0.0.0.0:3000 via FastMCP settings. \
|
||||
Safe search is disabled by default.
|
||||
|
||||
### Other
|
||||
### Cluster
|
||||
- `APPROVAL_TIMEOUT` — Tool approval timeout in seconds (default: 3600)
|
||||
|
||||
## Auth Setup Flow
|
||||
@@ -152,28 +154,28 @@ Categories like "engineering", "analysis", etc.
|
||||
## Your Task
|
||||
Walk the user through setting up their deployment step by step:
|
||||
|
||||
1. **First**: Call `check_docker`, `read_file` on `.env`, and `read_file` on `compose.yaml` \
|
||||
to detect existing state. If `compose.yaml` does not exist, call `write_compose` to \
|
||||
extract the bundled production compose file. This is essential — without it, \
|
||||
`docker compose` will fail.
|
||||
2. **LLM provider for the deployment**: Which LLM backend their Turnstone will use \
|
||||
1. **First**: Call `check_docker` and `read_file` on `.env` to detect existing state.
|
||||
2. **Deployment mode**: Ask if they want single-node (`--profile production`) or multi-node \
|
||||
(`--profile cluster`). Explain trade-offs.
|
||||
3. **LLM provider for the deployment**: Which LLM backend their Turnstone will use \
|
||||
(may differ from this wizard's model). Ask for base URL, API key, model name.
|
||||
3. **Database**: SQLite (dev/simple) vs PostgreSQL (production). \
|
||||
PostgreSQL is recommended for production use.
|
||||
4. **Security**: Auth is always enabled and requires `TURNSTONE_JWT_SECRET`. \
|
||||
4. **Database**: SQLite (dev/simple) vs PostgreSQL (production/cluster). \
|
||||
PostgreSQL is required for cluster mode.
|
||||
5. **Security**: Auth is always enabled and requires `TURNSTONE_JWT_SECRET`. \
|
||||
Use `generate_secret` for JWT secret and Postgres password. \
|
||||
Always set `TURNSTONE_JWT_SECRET` in the .env. \
|
||||
Ask for initial admin username and password. \
|
||||
If the user's deployment will use an external identity provider (Okta, Azure AD, Google, etc.), \
|
||||
offer to configure OIDC SSO. Ask for the issuer URL, client ID, and client secret. \
|
||||
Optionally configure role mapping and OIDC-only mode.
|
||||
5. **Ports**: Check defaults with `check_port`, suggest alternatives if conflicts.
|
||||
6. **Optional features**: Discord integration, web search (Tavily key).
|
||||
7. **Generate .env**: Call `write_file` with the complete `.env` content. \
|
||||
Include `TURNSTONE_IMAGE_TAG` set to the version matching the installed package.
|
||||
8. **Generate setup.sh**: Call `write_file` with a post-start script that creates the admin \
|
||||
6. **Ports**: Check defaults with `check_port`, suggest alternatives if conflicts.
|
||||
7. **Optional features**: Discord integration, web search (Tavily key), \
|
||||
DuckDuckGo Search MCP (for cluster — uses `ddgCluster` profile with \
|
||||
`MCP_CONFIG=/etc/turnstone/mcp-ddg.json`, no API key needed).
|
||||
8. **Generate .env**: Call `write_file` with the complete `.env` content.
|
||||
9. **Generate setup.sh**: Call `write_file` with a post-start script that creates the admin \
|
||||
user and any roles/policies/skills the user wants.
|
||||
9. **Finish**: Call the `finish` tool with a summary of what was configured and the \
|
||||
10. **Finish**: Call the `finish` tool with a summary of what was configured and the \
|
||||
exact commands to run next (e.g., `docker compose --profile production up -d` then `./setup.sh`).
|
||||
|
||||
## Rules
|
||||
@@ -181,9 +183,14 @@ exact commands to run next (e.g., `docker compose --profile production up -d` th
|
||||
- NEVER echo API keys or passwords back to the user in your text responses.
|
||||
- ALWAYS use `generate_secret` for passwords and secrets — never invent them.
|
||||
- When writing files, use `write_file` — the user will see a preview and confirm.
|
||||
- If `compose.yaml` is missing, call `write_compose` before anything else. \
|
||||
The compose file uses pre-built images from ghcr.io — no local Docker build is needed.
|
||||
- If an existing .env is detected, summarize what's configured and ask what to change.
|
||||
- For cluster mode, the compose.yaml has a fixed 10-node fleet — no override needed.
|
||||
- For cluster + DuckDuckGo Search, use `--profile ddgCluster` instead of `--profile cluster`. \
|
||||
Set `MCP_CONFIG=/etc/turnstone/mcp-ddg.json` in `.env`. No API key needed. \
|
||||
The DuckDuckGo MCP sidecar starts automatically and all cluster nodes connect to it. \
|
||||
Note: the MCP SDK's DNS rebinding protection must be disabled for Docker-internal networking \
|
||||
(the compose.yaml handles this), and the server must bind to 0.0.0.0 (not 127.0.0.1) to be \
|
||||
reachable from other containers.
|
||||
- The `DATABASE_URL` for docker compose internal networking uses the hostname `postgres` \
|
||||
(e.g., `postgresql+psycopg://turnstone:<password>@postgres:5432/turnstone`).
|
||||
- For local LLM backends (vLLM, llama.cpp, Ollama, etc.), set `OPENAI_API_KEY=dummy` in the \
|
||||
@@ -335,23 +342,6 @@ TOOLS: list[dict[str, Any]] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "write_compose",
|
||||
"description": (
|
||||
"Write the production Docker Compose file to the project directory. "
|
||||
"This extracts the compose.yaml bundled with Turnstone, which uses "
|
||||
"pre-built images from ghcr.io (no local Docker build required). "
|
||||
"The user will be shown a preview and asked to confirm."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -571,54 +561,6 @@ def _tool_check_docker(args: dict[str, Any]) -> str:
|
||||
return "\n".join(results)
|
||||
|
||||
|
||||
def _tool_write_compose(project_dir: Path, args: dict[str, Any]) -> str:
|
||||
"""Extract the bundled production compose.yaml to the project directory."""
|
||||
dest = project_dir / "compose.yaml"
|
||||
|
||||
# Read the bundled template
|
||||
try:
|
||||
ref = importlib.resources.files("turnstone.deploy").joinpath("compose.yaml")
|
||||
content = ref.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
return f"Error: could not read bundled compose template: {exc}"
|
||||
|
||||
# Skip if identical
|
||||
if dest.exists():
|
||||
try:
|
||||
existing = dest.read_text(encoding="utf-8")
|
||||
if existing == content:
|
||||
return "compose.yaml already exists with identical content."
|
||||
except (OSError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
|
||||
|
||||
# Show preview
|
||||
print(f"\n{YELLOW} Writing compose.yaml ({line_count} lines){RESET}")
|
||||
print(f"{DIM}{'─' * 50}{RESET}")
|
||||
for line in content.split("\n")[:30]:
|
||||
print(f" {DIM}{line}{RESET}")
|
||||
if line_count > 30:
|
||||
print(f" {DIM}... ({line_count - 30} more lines){RESET}")
|
||||
print(f"{DIM}{'─' * 50}{RESET}")
|
||||
|
||||
try:
|
||||
choice = input(f"{BOLD}Write this file? [Y/n]{RESET} ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return "User cancelled the write."
|
||||
if choice in ("n", "no"):
|
||||
return "User declined to write compose.yaml."
|
||||
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
|
||||
return (
|
||||
f"compose.yaml written successfully. "
|
||||
f"It uses ghcr.io/turnstonelabs/turnstone images. "
|
||||
f"Add TURNSTONE_IMAGE_TAG={__version__} to .env to pin the image "
|
||||
f"to the currently installed version, or omit it to use 'latest'."
|
||||
)
|
||||
|
||||
|
||||
class _FinishError(Exception):
|
||||
"""Raised by the finish tool to signal the wizard is done."""
|
||||
|
||||
@@ -639,12 +581,11 @@ TOOL_FUNCTIONS: dict[str, Any] = {
|
||||
"check_port": _tool_check_port,
|
||||
"validate_api_key": _tool_validate_api_key,
|
||||
"check_docker": _tool_check_docker,
|
||||
"write_compose": _tool_write_compose,
|
||||
"finish": _tool_finish,
|
||||
}
|
||||
|
||||
# Tools that need the project_dir argument
|
||||
_PROJECT_DIR_TOOLS = frozenset({"read_file", "write_file", "write_compose"})
|
||||
_PROJECT_DIR_TOOLS = frozenset({"read_file", "write_file"})
|
||||
|
||||
|
||||
def execute_tool(name: str, args: dict[str, Any], project_dir: Path) -> str:
|
||||
|
||||
@@ -282,14 +282,10 @@ def main() -> None:
|
||||
|
||||
async def _heartbeat_loop() -> None:
|
||||
"""Periodically update service heartbeat."""
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
try:
|
||||
await asyncio.to_thread(storage.heartbeat_service, "channel", service_id)
|
||||
except StorageUnavailableError:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.exception("channel.heartbeat_failed")
|
||||
|
||||
|
||||
@@ -289,13 +289,9 @@ class ClusterCollector:
|
||||
|
||||
def _discovery_loop(self) -> None:
|
||||
"""Periodically scan the service registry for active nodes."""
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
self._discover_nodes()
|
||||
except StorageUnavailableError:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.exception("Node discovery error")
|
||||
time.sleep(self._discovery_interval)
|
||||
|
||||
@@ -19,7 +19,6 @@ from typing import TYPE_CHECKING, Any
|
||||
import structlog
|
||||
|
||||
from turnstone.core.hash_ring import RING_SIZE, RingNode, bucket_of
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
@@ -150,8 +149,6 @@ class Rebalancer:
|
||||
result = self.rebalance_once(trigger=trigger)
|
||||
self._last_result = result
|
||||
self._record_result_metrics(result)
|
||||
except StorageUnavailableError:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.exception("rebalancer.error")
|
||||
finally:
|
||||
|
||||
@@ -90,13 +90,9 @@ class TaskScheduler:
|
||||
|
||||
def _loop(self) -> None:
|
||||
"""Main scheduler loop — tick then sleep."""
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self._tick()
|
||||
except StorageUnavailableError:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.exception("scheduler.tick_error")
|
||||
self._stop_event.wait(self._check_interval)
|
||||
|
||||
@@ -1167,14 +1167,10 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
import asyncio
|
||||
|
||||
async def _console_heartbeat() -> None:
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
try:
|
||||
storage.heartbeat_service("console", "console")
|
||||
except StorageUnavailableError:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.warning("console.heartbeat_failed", exc_info=True)
|
||||
|
||||
|
||||
@@ -543,13 +543,9 @@ class AnthropicProvider:
|
||||
if extra_params and "thinking_budget_tokens" in extra_params:
|
||||
budget = extra_params["thinking_budget_tokens"]
|
||||
if budget > 0:
|
||||
# Budget must be strictly less than max_tokens (API requirement).
|
||||
# If max_tokens is too small to fit even a minimal thinking
|
||||
# budget alongside the response, disable thinking entirely.
|
||||
# Budget must leave room for the response
|
||||
if budget >= max_tokens:
|
||||
budget = max_tokens - 1024
|
||||
if budget < 1:
|
||||
return {}
|
||||
budget = max(1024, max_tokens - 1024)
|
||||
return {"thinking": {"type": "enabled", "budget_tokens": budget}}
|
||||
return {}
|
||||
|
||||
|
||||
+31
-53
@@ -924,8 +924,10 @@ class ChatSession:
|
||||
if asst_msg:
|
||||
snippet += f"\nAssistant: {asst_msg}"
|
||||
snippet += "\n\nTitle:"
|
||||
result = self._utility_completion(
|
||||
[
|
||||
result = self._provider.create_completion(
|
||||
client=self.client,
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
@@ -940,6 +942,9 @@ class ChatSession:
|
||||
{"role": "user", "content": snippet},
|
||||
],
|
||||
max_tokens=200,
|
||||
temperature=0.3,
|
||||
reasoning_effort="low",
|
||||
extra_params=self._provider_extra_params(reasoning_effort="low"),
|
||||
)
|
||||
raw = (result.content or "").strip()
|
||||
# Take first line, strip quotes
|
||||
@@ -1276,33 +1281,6 @@ class ChatSession:
|
||||
return {"chat_template_kwargs": kwargs}
|
||||
return None
|
||||
|
||||
def _utility_completion(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.3,
|
||||
reasoning_effort: str = "low",
|
||||
) -> CompletionResult:
|
||||
"""Run a lightweight internal completion (title gen, compaction, extraction).
|
||||
|
||||
Threads ``reasoning_effort`` through both the direct keyword (for
|
||||
commercial providers) and ``extra_params`` (for local model servers)
|
||||
so callers don't need to duplicate it. ``max_tokens`` is clamped to
|
||||
the model's advertised output limit so small models don't error.
|
||||
"""
|
||||
caps = self._get_capabilities()
|
||||
clamped = min(max_tokens, caps.max_output_tokens) if caps.max_output_tokens else max_tokens
|
||||
return self._provider.create_completion(
|
||||
client=self.client,
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
max_tokens=clamped,
|
||||
temperature=temperature,
|
||||
reasoning_effort=reasoning_effort,
|
||||
extra_params=self._provider_extra_params(reasoning_effort=reasoning_effort),
|
||||
)
|
||||
|
||||
# -- tool search helpers --------------------------------------------------
|
||||
|
||||
def _get_active_tools(self) -> list[dict[str, Any]] | None:
|
||||
@@ -2504,9 +2482,14 @@ class ChatSession:
|
||||
result: CompletionResult | None = None
|
||||
for attempt in range(self._MAX_RETRIES + 1):
|
||||
try:
|
||||
result = self._utility_completion(
|
||||
summary_msgs,
|
||||
result = self._provider.create_completion(
|
||||
client=self.client,
|
||||
model=self.model,
|
||||
messages=summary_msgs,
|
||||
max_tokens=summary_max_tokens,
|
||||
temperature=0.3,
|
||||
reasoning_effort="low",
|
||||
extra_params=self._provider_extra_params(reasoning_effort="low"),
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -6184,30 +6167,26 @@ class ChatSession:
|
||||
return call_id, msg
|
||||
|
||||
if not text.strip():
|
||||
msg = "Error: fetch returned empty response"
|
||||
self._report_tool_result(call_id, "web_fetch", msg, is_error=True)
|
||||
return call_id, msg
|
||||
return call_id, "(empty response from URL)"
|
||||
|
||||
original_len = len(text)
|
||||
self.ui.on_info(f"fetched {original_len} chars, extracting...")
|
||||
|
||||
# Phase 2: truncate for summarization context.
|
||||
# Reserve ~25% of the context window for the extraction prompt
|
||||
# overhead (system message, URL, question) and response tokens.
|
||||
# Convert token budget to chars using the calibrated ratio.
|
||||
max_content = int(self.context_window * self._chars_per_token * 0.75)
|
||||
max_content = min(max(max_content, 50_000), 500_000) # 50k–500k
|
||||
# Phase 2: truncate for summarization context
|
||||
max_content = 50_000
|
||||
if len(text) > max_content:
|
||||
# Prefer the beginning — page content is usually top-heavy.
|
||||
text = text[:max_content] + f"\n\n... [{len(text) - max_content} chars truncated] ...\n"
|
||||
text = (
|
||||
text[: max_content // 2]
|
||||
+ f"\n\n... [{len(text) - max_content} chars omitted] ...\n\n"
|
||||
+ text[-(max_content // 2) :]
|
||||
)
|
||||
|
||||
# Phase 3: summarization API call.
|
||||
# Use a generous max_tokens so thinking models don't starve the
|
||||
# visible answer, and pass reasoning_effort="low" to avoid wasting
|
||||
# budget on deep reasoning for a simple extraction task.
|
||||
# Phase 3: summarization API call
|
||||
try:
|
||||
result = self._utility_completion(
|
||||
[
|
||||
result = self._provider.create_completion(
|
||||
client=self.client,
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
@@ -6227,12 +6206,11 @@ class ChatSession:
|
||||
),
|
||||
},
|
||||
],
|
||||
max_tokens=8192,
|
||||
max_tokens=2000,
|
||||
temperature=0.2,
|
||||
extra_params=self._provider_extra_params(),
|
||||
)
|
||||
answer = result.content or ""
|
||||
if not answer:
|
||||
answer = "Error: extraction returned no answer"
|
||||
answer = result.content or "(no answer)"
|
||||
except Exception as e:
|
||||
answer = f"Extraction failed (page was fetched but summarization errored): {e}"
|
||||
|
||||
@@ -6240,7 +6218,7 @@ class ChatSession:
|
||||
call_id,
|
||||
"web_fetch",
|
||||
answer,
|
||||
is_error=answer.startswith(("Error:", "Extraction failed")),
|
||||
is_error=answer.startswith("Extraction failed"),
|
||||
)
|
||||
|
||||
return call_id, answer
|
||||
|
||||
@@ -4,16 +4,10 @@ Supports SQLite (default, zero-config) and PostgreSQL (multi-node, production).
|
||||
"""
|
||||
|
||||
from turnstone.core.storage._protocol import StorageBackend
|
||||
from turnstone.core.storage._registry import (
|
||||
StorageUnavailableError,
|
||||
get_storage,
|
||||
init_storage,
|
||||
reset_storage,
|
||||
)
|
||||
from turnstone.core.storage._registry import get_storage, init_storage, reset_storage
|
||||
|
||||
__all__ = [
|
||||
"StorageBackend",
|
||||
"StorageUnavailableError",
|
||||
"get_storage",
|
||||
"init_storage",
|
||||
"reset_storage",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,14 +15,6 @@ log = get_logger(__name__)
|
||||
_storage: StorageBackend | None = None
|
||||
|
||||
|
||||
class StorageUnavailableError(Exception):
|
||||
"""Raised when the database is unreachable.
|
||||
|
||||
The storage layer has already logged a clean one-liner — callers
|
||||
should catch this to avoid duplicate tracebacks.
|
||||
"""
|
||||
|
||||
|
||||
def init_storage(
|
||||
backend: str = "sqlite",
|
||||
*,
|
||||
|
||||
+175
-203
File diff suppressed because it is too large
Load Diff
@@ -271,13 +271,9 @@ class WatchRunner:
|
||||
# -- Main loop -----------------------------------------------------------
|
||||
|
||||
def _run(self) -> None:
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self._tick()
|
||||
except StorageUnavailableError:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.exception("watch_runner.tick_error")
|
||||
self._stop_event.wait(self._check_interval)
|
||||
|
||||
@@ -6,20 +6,14 @@ import socket
|
||||
from html import unescape as _html_unescape
|
||||
from urllib.parse import urlparse
|
||||
|
||||
_RE_INVISIBLE = re.compile(
|
||||
r"<(script|style|template|noscript)\b[^>]*>.*?</\1\s*>",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
_RE_TAGS = re.compile(r"<[^>]+>")
|
||||
_RE_WS = re.compile(r"[ \t]+")
|
||||
_RE_BLANKLINES = re.compile(r"\n{3,}")
|
||||
|
||||
|
||||
def strip_html(html: str) -> str:
|
||||
"""Convert HTML to plain text: strip invisible elements, tags, decode entities."""
|
||||
# Remove elements whose content should never appear as text
|
||||
text = _RE_INVISIBLE.sub("", html)
|
||||
text = _RE_TAGS.sub("", text)
|
||||
"""Convert HTML to plain text: strip tags, decode entities, collapse whitespace."""
|
||||
text = _RE_TAGS.sub("", html)
|
||||
text = _html_unescape(text)
|
||||
text = _RE_WS.sub(" ", text)
|
||||
text = _RE_BLANKLINES.sub("\n\n", text)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Bundled deployment templates (compose files, overlays).
|
||||
|
||||
These files are included in the wheel so that ``turnstone-bootstrap`` can
|
||||
extract them for users who install via pip/pipx and don't have a git clone.
|
||||
"""
|
||||
@@ -1,173 +0,0 @@
|
||||
# =============================================================================
|
||||
# Turnstone Docker Compose Stack — Production
|
||||
#
|
||||
# This file is bundled with the turnstone wheel and written by
|
||||
# turnstone-bootstrap for users who install via pip/pipx.
|
||||
# It pulls pre-built images from ghcr.io instead of building locally.
|
||||
#
|
||||
# Usage:
|
||||
# Infra only: docker compose up
|
||||
# Single node: docker compose --profile production up
|
||||
# Production (PG): docker compose --profile production up
|
||||
# (set DB_BACKEND, DATABASE_URL, POSTGRES_PASSWORD in .env)
|
||||
#
|
||||
# Set TURNSTONE_IMAGE_TAG in .env to pin the image version (default: latest).
|
||||
# =============================================================================
|
||||
|
||||
name: turnstone
|
||||
|
||||
networks:
|
||||
turnstone-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
turnstone-data:
|
||||
workspace:
|
||||
postgres-data:
|
||||
|
||||
services:
|
||||
# -------------------------------------------------------------------
|
||||
# PostgreSQL — production database (profile: production)
|
||||
# -------------------------------------------------------------------
|
||||
postgres:
|
||||
image: pgautoupgrade/pgautoupgrade:18-alpine
|
||||
profiles:
|
||||
- production
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- max_connections=${POSTGRES_MAX_CONNECTIONS:-300}
|
||||
- -c
|
||||
- shared_buffers=128MB
|
||||
environment:
|
||||
POSTGRES_DB: turnstone
|
||||
POSTGRES_USER: ${POSTGRES_USER:-turnstone}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required for production profile}
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- turnstone-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-turnstone}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
cpus: '1.0'
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# turnstone-server — Web UI + chat workstreams + LLM interaction
|
||||
# -------------------------------------------------------------------
|
||||
server:
|
||||
image: ghcr.io/turnstonelabs/turnstone:${TURNSTONE_IMAGE_TAG:-latest}
|
||||
profiles:
|
||||
- production
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
turnstone-server
|
||||
--host 0.0.0.0
|
||||
--port 8080
|
||||
--base-url "$${LLM_BASE_URL}"
|
||||
--api-key "$${OPENAI_API_KEY}"
|
||||
$${MODEL:+--model $$MODEL}
|
||||
$${SKIP_PERMISSIONS:+--skip-permissions}
|
||||
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
|
||||
ports:
|
||||
- "${SERVER_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- turnstone-data:/data
|
||||
- ${WORKSPACE_MOUNT:-workspace}:/workspace
|
||||
environment:
|
||||
- LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-dummy}
|
||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||
- SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- MODEL=${MODEL:-}
|
||||
- MCP_CONFIG=${MCP_CONFIG:-}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-}
|
||||
- TURNSTONE_NODE_ID=${TURNSTONE_NODE_ID:-}
|
||||
- TURNSTONE_ADVERTISE_URL=${TURNSTONE_ADVERTISE_URL:-http://server:8080}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# turnstone-console — Cluster dashboard
|
||||
# -------------------------------------------------------------------
|
||||
console:
|
||||
image: ghcr.io/turnstonelabs/turnstone:${TURNSTONE_IMAGE_TAG:-latest}
|
||||
command:
|
||||
- turnstone-console
|
||||
- --host=0.0.0.0
|
||||
- --port=8090
|
||||
ports:
|
||||
- "${CONSOLE_PORT:-8090}:8090"
|
||||
environment:
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-}
|
||||
- TURNSTONE_CONSOLE_URL=http://console:8090
|
||||
networks:
|
||||
- turnstone-net
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8090/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# turnstone-channel — Channel gateway (Discord, Slack, etc.)
|
||||
# Requires TURNSTONE_DISCORD_TOKEN to enable Discord adapter
|
||||
# -------------------------------------------------------------------
|
||||
channel:
|
||||
image: ghcr.io/turnstonelabs/turnstone:${TURNSTONE_IMAGE_TAG:-latest}
|
||||
profiles:
|
||||
- production
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
turnstone-channel
|
||||
--http-host=0.0.0.0
|
||||
$${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD}
|
||||
environment:
|
||||
- TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-}
|
||||
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
|
||||
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-postgresql}
|
||||
- TURNSTONE_DB_URL=${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
|
||||
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
@@ -2389,14 +2389,10 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
|
||||
async def _heartbeat_loop() -> None:
|
||||
"""Periodically update service heartbeat."""
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
try:
|
||||
await asyncio.to_thread(_svc_storage.heartbeat_service, "server", _svc_node_id)
|
||||
except StorageUnavailableError:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.exception("server.heartbeat_failed")
|
||||
|
||||
|
||||
@@ -1249,7 +1249,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.26.0"
|
||||
version = "1.27.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -1267,9 +1267,9 @@ dependencies = [
|
||||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2216,27 +2216,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.8"
|
||||
version = "0.15.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2496,7 +2496,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "turnstone"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0a2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
|
||||
Reference in New Issue
Block a user