mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-28 06:44:51 -06:00
101afd84da
* feat: database-backed settings (ConfigStore) with admin API
Replace config.toml for non-bootstrap settings on the server with a
database-backed ConfigStore. ~40 settings across model, session,
tools, server, mcp, ratelimit, health, judge, and memory sections are
now managed via the admin Settings API. CLI flags for these settings
removed from the server entry point (CLI standalone tool unchanged).
Storage: system_settings table (migration 015) with composite PK
(key, node_id) for per-node overrides. ON CONFLICT upsert in both
SQLite and PostgreSQL. admin.settings permission granted to
builtin-admin role.
Settings registry (settings_registry.py): code-defined catalog of
all known settings with types, defaults, validation, descriptions.
Registry defaults aligned with previous argparse defaults.
ConfigStore (config_store.py): thread-safe in-memory cache loaded
from storage on init. Lock-free reads via dict snapshot swap.
reload() for hot-reload via internal endpoint.
Secret settings (judge.api_key) blocked from write via admin API
(403) — must be configured via config.toml or env vars.
warn_migrated_settings() logs warnings for config.toml keys that
overlap with ConfigStore-managed settings.
Console admin API: GET /v1/api/admin/settings (list with effective
values), GET .../schema (registry catalog), PUT .../{key} (update),
DELETE .../{key} (reset to default). Audit trail on mutations.
MQ: ConfigChangeEvent for cross-node cache invalidation (emission
from console deferred to bridge integration).
Python + TypeScript SDK methods. 63 new tests. Feature docs at
docs/settings.md, PlantUML diagram 24-settings-architecture.
* fix: address PR review — config-reload scope, registry defaults, doc alignment
- config-reload endpoint requires approve scope (was write)
- config-reload handler is sync def (avoids blocking event loop)
- reasoning_effort passes empty string through (removes `or "medium"`)
- ratelimit.requests_per_second changed to float (matches RateLimiter)
- session.retention_days allows 0 (disable pruning)
- ratelimit.trusted_proxies added to registry + wired in server
- admin_list_settings filters to global settings only (no node_id ambiguity)
- admin_update_setting validates "value" key presence (400 if missing)
- Console config change fans out reload to nodes directly (no MQ dep)
- Docs aligned with actual API response shapes and masking ("***")
194 lines
6.3 KiB
Python
194 lines
6.3 KiB
Python
"""Tests for ConfigStore database-backed configuration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.config_store import ConfigStore
|
|
from turnstone.core.settings_registry import SETTINGS
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(tmp_path):
|
|
return SQLiteBackend(str(tmp_path / "test.db"))
|
|
|
|
|
|
@pytest.fixture
|
|
def store(storage):
|
|
return ConfigStore(storage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGet:
|
|
def test_returns_registry_default_when_nothing_stored(self, store):
|
|
defn = SETTINGS["tools.timeout"]
|
|
assert store.get("tools.timeout") == defn.default
|
|
|
|
def test_returns_stored_value_after_set(self, store):
|
|
store.set("tools.timeout", 60)
|
|
assert store.get("tools.timeout") == 60
|
|
|
|
def test_explicit_default_for_unknown_key(self, store):
|
|
# Unknown keys fall back to explicit default
|
|
assert store.get("nonexistent.key", 42) == 42
|
|
|
|
def test_none_for_unknown_key_without_default(self, store):
|
|
assert store.get("nonexistent.key") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# set() — validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSet:
|
|
def test_rejects_unknown_key(self, store):
|
|
with pytest.raises(ValueError, match="Unknown setting"):
|
|
store.set("bogus.key", "value")
|
|
|
|
def test_rejects_out_of_range(self, store):
|
|
with pytest.raises(ValueError, match="minimum"):
|
|
store.set("tools.timeout", 0)
|
|
|
|
def test_rejects_above_max(self, store):
|
|
with pytest.raises(ValueError, match="maximum"):
|
|
store.set("tools.timeout", 9999)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# set() + get() round-trips
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSetGetRoundTrip:
|
|
def test_int(self, store):
|
|
store.set("tools.timeout", 30)
|
|
assert store.get("tools.timeout") == 30
|
|
assert isinstance(store.get("tools.timeout"), int)
|
|
|
|
def test_float(self, store):
|
|
store.set("model.temperature", 0.42)
|
|
assert store.get("model.temperature") == 0.42
|
|
assert isinstance(store.get("model.temperature"), float)
|
|
|
|
def test_bool(self, store):
|
|
store.set("tools.skip_permissions", True)
|
|
assert store.get("tools.skip_permissions") is True
|
|
store.set("tools.skip_permissions", False)
|
|
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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# delete()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDelete:
|
|
def test_reverts_to_default(self, store):
|
|
store.set("tools.timeout", 30)
|
|
assert store.get("tools.timeout") == 30
|
|
store.delete("tools.timeout")
|
|
defn = SETTINGS["tools.timeout"]
|
|
assert store.get("tools.timeout") == defn.default
|
|
|
|
def test_returns_false_for_non_existent(self, store):
|
|
assert store.delete("tools.timeout") is False
|
|
|
|
def test_rejects_unknown_key(self, store):
|
|
with pytest.raises(ValueError, match="Unknown setting"):
|
|
store.delete("nonexistent.key")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# reload()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestReload:
|
|
def test_picks_up_external_storage_changes(self, storage, store):
|
|
# Write directly to storage, bypassing ConfigStore
|
|
from turnstone.core.settings_registry import serialize_value
|
|
|
|
storage.upsert_system_setting(
|
|
key="tools.timeout",
|
|
value=serialize_value(99),
|
|
node_id="",
|
|
is_secret=False,
|
|
changed_by="external",
|
|
)
|
|
# Not visible yet (cached)
|
|
defn = SETTINGS["tools.timeout"]
|
|
assert store.get("tools.timeout") == defn.default
|
|
# Reload and verify
|
|
store.reload()
|
|
assert store.get("tools.timeout") == 99
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# all_effective()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAllEffective:
|
|
def test_merges_stored_with_defaults(self, store):
|
|
store.set("tools.timeout", 30)
|
|
effective = store.all_effective()
|
|
# Stored value
|
|
assert effective["tools.timeout"] == 30
|
|
# Default for unstored
|
|
assert effective["memory.relevance_k"] == SETTINGS["memory.relevance_k"].default
|
|
# All registry keys present
|
|
assert set(effective.keys()) == set(SETTINGS.keys())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# stored_keys()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStoredKeys:
|
|
def test_returns_correct_set(self, store):
|
|
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.delete("tools.timeout")
|
|
assert store.stored_keys() == frozenset({"model.name"})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# version
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestVersion:
|
|
def test_increments_on_set(self, store):
|
|
v0 = store.version
|
|
store.set("tools.timeout", 30)
|
|
assert store.version == v0 + 1
|
|
|
|
def test_increments_on_delete(self, store):
|
|
store.set("tools.timeout", 30)
|
|
v0 = store.version
|
|
store.delete("tools.timeout")
|
|
assert store.version == v0 + 1
|
|
|
|
def test_increments_on_reload(self, store):
|
|
v0 = store.version
|
|
store.reload()
|
|
assert store.version == v0 + 1
|