Compare commits

...

8 Commits

Author SHA1 Message Date
Patrick Buckley 06c41f0a59 chore: bump version to 1.0.2 2026-04-03 15:40:23 -07:00
Patrick Buckley d107e6edf0 Fix/web fetch reliability (#290)
* fix: improve web_fetch reliability — strip scripts, dynamic truncation, more tokens

- strip_html() now removes <script>, <style>, <template>, <noscript>
  element content instead of just their tags
- Truncation budget scales with context window (75% in chars, 50k floor)
  and takes from the beginning only instead of head+tail splice
- max_tokens bumped from 2000 to 8192 so thinking models don't starve
  the visible extraction answer
- reasoning_effort="low" on summarization call to avoid wasting tokens
- Empty responses and empty extractions now report as tool errors

* refactor: extract _utility_completion to fix reasoning_effort duplication

Callers previously had to pass reasoning_effort both as a direct keyword
(for commercial providers) and via _provider_extra_params (for local
model servers).  This duplication was easy to get wrong — web_fetch was
already missing the direct keyword.

_utility_completion threads it through both paths from a single call,
used by title generation, compaction, and web_fetch extraction.

* fix: disable thinking when max_tokens too small, cap extraction at 500k

_reasoning_params now returns empty dict when max_tokens can't fit a
thinking budget (e.g. title gen with max_tokens=200).  Previously
produced budget_tokens >= max_tokens which is an API error on
manual-thinking Anthropic models.

Also caps web_fetch content truncation at 500k chars — the dynamic
context-window calc was producing 3M chars on 1M-context models.

* fix: clamp utility max_tokens to model output limit, add strip_html tests

_utility_completion now clamps max_tokens to the model's advertised
max_output_tokens so small/local models don't reject 8192-token
requests.

Adds 8 tests for invisible element stripping (script, style, template,
noscript) including multiline, case-insensitive, and attribute cases.

* fix: mock get_capabilities in title retry tests for _utility_completion

_utility_completion calls _get_capabilities to clamp max_tokens.  The
existing title tests mocked _provider as a bare MagicMock, so
caps.max_output_tokens was a truthy MagicMock instead of an int.  Set
get_capabilities to return a real ModelCapabilities instance.
2026-04-03 15:39:34 -07:00
Patrick Buckley 22c20a8dbd fix: share single Docker image across all compose services
Build the image once via the profileless console service and reference
it as turnstone:local from server/channel.  Prevents stale images when
users run docker compose build without --profile.
2026-04-03 15:39:34 -07:00
Patrick Buckley 179143431d fix: include prompt .md files in wheel, add wheel-completeness CI (#289) (#291)
* fix: include prompt .md files in wheel, add wheel-completeness CI (#289)

Prompt markdown files were missing from PyPI wheels since the modular
prompts refactor, causing FileNotFoundError on startup for pip-installed
users.  Add the missing include pattern and a new CI job that diffs
source-tree data files against wheel contents so omissions are caught
before merge.

* fix: sanitise ALLOW patterns in wheel-completeness check

Strip blank lines and leading whitespace from the allowlist before
passing to grep -vFxf so empty patterns cannot silently match all lines.
2026-04-03 15:39:34 -07:00
Patrick Buckley d4a6866045 fix: log clean one-liner when PostgreSQL becomes unavailable (#288)
* fix: log clean one-liner when PostgreSQL becomes unavailable

Wrap all 174 connection sites in PostgreSQLBackend through a _conn()
context manager that catches OperationalError, emits a single
database.unavailable log line (with connection URL), and suppresses
repeats until the connection is restored (database.connection_restored).

* fix: add StorageUnavailableError and cover all heartbeat loops

Address review feedback:
- Separate connect-phase from execution-phase in _conn() so that
  OperationalError during caller code (e.g. BEGIN IMMEDIATE lock
  contention) is not misclassified as a connectivity failure.
- Add StorageUnavailableError exception class so callers can
  distinguish transient DB outages without redundant tracebacks.
- Apply the same _conn() wrapper to SQLiteBackend for consistency.
- Catch StorageUnavailableError in all 7 periodic loops: watch
  runner, server heartbeat, channel heartbeat, console heartbeat,
  collector discovery, rebalancer, and scheduler.
- Guard dedup flag with threading.Lock.
- Add tests for dedup logging and PostgreSQL path.
2026-04-03 15:39:34 -07:00
Patrick Buckley c4abd62226 fix: add concurrency groups to publish workflows
Multiple CI completions for the same commit (tag push + branch push)
caused duplicate publish and docker runs. Concurrency group keyed on
head_sha ensures only one publish runs per commit.
2026-04-03 15:39:34 -07:00
Patrick Buckley b3926c372a chore: bump version to 1.0.1 2026-04-02 20:29:02 -07:00
Patrick Buckley 5efe52d433 fix: chunk IN clauses to stay within DB parameter limits (#286)
* fix: chunk IN clauses to stay within DB parameter limits

psycopg caps query parameters at 65 535 and SQLite defaults to 999.
assign_buckets, prune_workstreams, and count_skill_resources_bulk were
passing unbounded lists into single IN(...) clauses, causing
OperationalError during rebalancer runs on full-size hash rings.

Chunk sizes: 10 000 (PostgreSQL), 500 (SQLite).

* fix: deduplicate assign_buckets input, add chunking regression tests

Address review feedback: deduplicate bucket list before chunking to
prevent inflated rowcount from cross-chunk duplicates. Add tests that
exercise the multi-chunk path (1200 buckets > SQLite chunk_size of 500)
and verify dedup preserves accurate counts.
2026-04-02 20:28:54 -07:00
25 changed files with 840 additions and 461 deletions
+47
View File
@@ -74,6 +74,53 @@ 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:
+4
View File
@@ -5,6 +5,10 @@ on:
workflows: ["CI"]
types: [completed]
concurrency:
group: docker-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
permissions:
contents: read
packages: write
+4
View File
@@ -5,6 +5,10 @@ on:
workflows: ["CI"]
types: [completed]
concurrency:
group: publish-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
permissions:
contents: write
id-token: write
+3 -6
View File
@@ -60,9 +60,7 @@ services:
# turnstone-server — Web UI + chat workstreams + LLM interaction
# -------------------------------------------------------------------
server:
build:
context: .
dockerfile: Dockerfile
image: turnstone:local
profiles:
- production
command:
@@ -115,6 +113,7 @@ services:
# turnstone-console — Cluster dashboard
# -------------------------------------------------------------------
console:
image: turnstone:local
build:
context: .
dockerfile: Dockerfile
@@ -145,9 +144,7 @@ services:
# Requires TURNSTONE_DISCORD_TOKEN to enable Discord adapter
# -------------------------------------------------------------------
channel:
build:
context: .
dockerfile: Dockerfile
image: turnstone:local
profiles:
- production
- cluster
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.0.0"
version = "1.0.2"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
@@ -67,6 +67,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",
+15
View File
@@ -41,6 +41,21 @@ class TestHashRingBuckets:
# Empty list returns 0
assert storage.assign_buckets([], "node-x") == 0
def test_assign_large_list_exceeds_chunk_size(self, storage):
"""Regression: lists larger than chunk_size must not hit param limits."""
n = 1200 # exceeds SQLite chunk_size (500) and exercises multi-chunk path
storage.seed_ring_buckets([(i, "node-a") for i in range(n)])
count = storage.assign_buckets(list(range(n)), "node-b")
assert count == n
rows = storage.list_ring_buckets()
assert all(r["node_id"] == "node-b" for r in rows)
def test_assign_deduplicates_input(self, storage):
"""Duplicates in the input list should not inflate rowcount."""
storage.seed_ring_buckets([(0, "node-a"), (1, "node-a")])
count = storage.assign_buckets([0, 1, 0, 1, 0], "node-b")
assert count == 2
class TestBucketStats:
def test_increment_creates_row(self, storage):
+52
View File
@@ -37,3 +37,55 @@ 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
+9
View File
@@ -759,6 +759,8 @@ 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 = [
@@ -767,6 +769,7 @@ 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()
@@ -774,6 +777,8 @@ 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 = [
@@ -783,6 +788,7 @@ 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"):
@@ -793,6 +799,8 @@ 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 = [
@@ -803,6 +811,7 @@ 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
+77 -2
View File
@@ -1,8 +1,17 @@
"""Tests for the storage backend registry."""
import pytest
from unittest.mock import patch
from turnstone.core.storage import get_storage, init_storage, reset_storage
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._sqlite import SQLiteBackend
@@ -52,3 +61,69 @@ 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 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "1.0.0"
__version__ = "1.0.2"
+4
View File
@@ -282,10 +282,14 @@ 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")
+4
View File
@@ -289,9 +289,13 @@ 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)
+3
View File
@@ -19,6 +19,7 @@ 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
@@ -149,6 +150,8 @@ 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:
+4
View File
@@ -90,9 +90,13 @@ 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)
+4
View File
@@ -1167,10 +1167,14 @@ 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)
+6 -2
View File
@@ -543,9 +543,13 @@ class AnthropicProvider:
if extra_params and "thinking_budget_tokens" in extra_params:
budget = extra_params["thinking_budget_tokens"]
if budget > 0:
# Budget must leave room for the response
# 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.
if budget >= max_tokens:
budget = max(1024, max_tokens - 1024)
budget = max_tokens - 1024
if budget < 1:
return {}
return {"thinking": {"type": "enabled", "budget_tokens": budget}}
return {}
+53 -31
View File
@@ -924,10 +924,8 @@ class ChatSession:
if asst_msg:
snippet += f"\nAssistant: {asst_msg}"
snippet += "\n\nTitle:"
result = self._provider.create_completion(
client=self.client,
model=self.model,
messages=[
result = self._utility_completion(
[
{
"role": "system",
"content": (
@@ -942,9 +940,6 @@ 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
@@ -1281,6 +1276,33 @@ 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:
@@ -2482,14 +2504,9 @@ class ChatSession:
result: CompletionResult | None = None
for attempt in range(self._MAX_RETRIES + 1):
try:
result = self._provider.create_completion(
client=self.client,
model=self.model,
messages=summary_msgs,
result = self._utility_completion(
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:
@@ -6167,26 +6184,30 @@ class ChatSession:
return call_id, msg
if not text.strip():
return call_id, "(empty response from URL)"
msg = "Error: fetch returned empty response"
self._report_tool_result(call_id, "web_fetch", msg, is_error=True)
return call_id, msg
original_len = len(text)
self.ui.on_info(f"fetched {original_len} chars, extracting...")
# Phase 2: truncate for summarization context
max_content = 50_000
# 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) # 50k500k
if len(text) > max_content:
text = (
text[: max_content // 2]
+ f"\n\n... [{len(text) - max_content} chars omitted] ...\n\n"
+ text[-(max_content // 2) :]
)
# Prefer the beginning — page content is usually top-heavy.
text = text[:max_content] + f"\n\n... [{len(text) - max_content} chars truncated] ...\n"
# Phase 3: summarization API call
# 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.
try:
result = self._provider.create_completion(
client=self.client,
model=self.model,
messages=[
result = self._utility_completion(
[
{
"role": "system",
"content": (
@@ -6206,11 +6227,12 @@ class ChatSession:
),
},
],
max_tokens=2000,
max_tokens=8192,
temperature=0.2,
extra_params=self._provider_extra_params(),
)
answer = result.content or "(no answer)"
answer = result.content or ""
if not answer:
answer = "Error: extraction returned no answer"
except Exception as e:
answer = f"Extraction failed (page was fetched but summarization errored): {e}"
@@ -6218,7 +6240,7 @@ class ChatSession:
call_id,
"web_fetch",
answer,
is_error=answer.startswith("Extraction failed"),
is_error=answer.startswith(("Error:", "Extraction failed")),
)
return call_id, answer
+7 -1
View File
@@ -4,10 +4,16 @@ Supports SQLite (default, zero-config) and PostgreSQL (multi-node, production).
"""
from turnstone.core.storage._protocol import StorageBackend
from turnstone.core.storage._registry import get_storage, init_storage, reset_storage
from turnstone.core.storage._registry import (
StorageUnavailableError,
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
+8
View File
@@ -15,6 +15,14 @@ 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",
*,
File diff suppressed because it is too large Load Diff
+4
View File
@@ -271,9 +271,13 @@ 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)
+8 -2
View File
@@ -6,14 +6,20 @@ 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 tags, decode entities, collapse whitespace."""
text = _RE_TAGS.sub("", html)
"""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)
text = _html_unescape(text)
text = _RE_WS.sub(" ", text)
text = _RE_BLANKLINES.sub("\n\n", text)
+4
View File
@@ -2389,10 +2389,14 @@ 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")
Generated
+1 -1
View File
@@ -2496,7 +2496,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "1.0.0"
version = "1.0.2"
source = { editable = "." }
dependencies = [
{ name = "alembic" },