mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-26 22:04:46 -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 ("***")
172 lines
6.1 KiB
Python
172 lines
6.1 KiB
Python
"""Tests for settings registry validation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from turnstone.core.settings_registry import (
|
|
BOOTSTRAP_SECTIONS,
|
|
SETTINGS,
|
|
deserialize_value,
|
|
serialize_value,
|
|
validate_key,
|
|
validate_value,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_key
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateKey:
|
|
def test_known_key(self):
|
|
defn = validate_key("memory.relevance_k")
|
|
assert defn.key == "memory.relevance_k"
|
|
assert defn.type == "int"
|
|
|
|
def test_unknown_key(self):
|
|
with pytest.raises(ValueError, match="Unknown setting"):
|
|
validate_key("nonexistent.key")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_value — type coercion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateValueCoercion:
|
|
def test_int(self):
|
|
assert validate_value("tools.timeout", "60") == 60
|
|
assert validate_value("tools.timeout", 60) == 60
|
|
assert isinstance(validate_value("tools.timeout", "60"), int)
|
|
|
|
def test_float(self):
|
|
assert validate_value("model.temperature", "0.7") == 0.7
|
|
assert validate_value("model.temperature", 1.5) == 1.5
|
|
assert isinstance(validate_value("model.temperature", "0.7"), float)
|
|
|
|
def test_bool_native(self):
|
|
assert validate_value("tools.skip_permissions", True) is True
|
|
assert validate_value("tools.skip_permissions", False) is False
|
|
|
|
def test_bool_string_true(self):
|
|
for s in ("true", "True", "1", "yes"):
|
|
assert validate_value("tools.skip_permissions", s) is True
|
|
|
|
def test_bool_string_false(self):
|
|
for s in ("false", "False", "0", "no"):
|
|
assert validate_value("tools.skip_permissions", s) is False
|
|
|
|
def test_bool_garbage_string(self):
|
|
with pytest.raises(ValueError, match="Cannot convert"):
|
|
validate_value("tools.skip_permissions", "banana")
|
|
|
|
def test_none_rejected_for_numeric(self):
|
|
"""None is not a valid value for numeric settings."""
|
|
with pytest.raises((ValueError, TypeError)):
|
|
validate_value("model.temperature", None)
|
|
with pytest.raises((ValueError, TypeError)):
|
|
validate_value("tools.timeout", None)
|
|
|
|
def test_str(self):
|
|
assert validate_value("model.name", "gpt-5") == "gpt-5"
|
|
assert validate_value("session.instructions", "be nice") == "be nice"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_value — range constraints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateValueRange:
|
|
def test_min_value(self):
|
|
with pytest.raises(ValueError, match="minimum"):
|
|
validate_value("tools.timeout", 0) # min_value=1
|
|
|
|
def test_max_value(self):
|
|
with pytest.raises(ValueError, match="maximum"):
|
|
validate_value("tools.timeout", 9999) # max_value=3600
|
|
|
|
def test_min_value_float(self):
|
|
with pytest.raises(ValueError, match="minimum"):
|
|
validate_value("model.temperature", -0.1) # min_value=0.0
|
|
|
|
def test_max_value_float(self):
|
|
with pytest.raises(ValueError, match="maximum"):
|
|
validate_value("model.temperature", 2.1) # max_value=2.0
|
|
|
|
def test_boundary_ok(self):
|
|
# Exact boundary values should pass
|
|
assert validate_value("tools.timeout", 1) == 1
|
|
assert validate_value("tools.timeout", 3600) == 3600
|
|
assert validate_value("model.temperature", 0.0) == 0.0
|
|
assert validate_value("model.temperature", 2.0) == 2.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_value — choices
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateValueChoices:
|
|
def test_valid_choice(self):
|
|
assert validate_value("tools.search", "auto") == "auto"
|
|
assert validate_value("tools.search", "on") == "on"
|
|
assert validate_value("tools.search", "off") == "off"
|
|
|
|
def test_invalid_choice(self):
|
|
with pytest.raises(ValueError, match="not in"):
|
|
validate_value("tools.search", "maybe")
|
|
|
|
def test_reasoning_effort_choices(self):
|
|
for ch in ("", "none", "low", "medium", "high", "max"):
|
|
assert validate_value("model.reasoning_effort", ch) == ch
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# serialize / deserialize round-trip
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSerializeDeserialize:
|
|
def test_int_round_trip(self):
|
|
v = 42
|
|
assert deserialize_value("tools.timeout", serialize_value(v)) == v
|
|
|
|
def test_float_round_trip(self):
|
|
v = 0.75
|
|
assert deserialize_value("model.temperature", serialize_value(v)) == v
|
|
|
|
def test_bool_round_trip(self):
|
|
for v in (True, False):
|
|
assert deserialize_value("tools.skip_permissions", serialize_value(v)) is v
|
|
|
|
def test_str_round_trip(self):
|
|
v = "hello world"
|
|
assert deserialize_value("model.name", serialize_value(v)) == v
|
|
|
|
def test_str_round_trip_empty(self):
|
|
assert deserialize_value("model.name", serialize_value("")) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry integrity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRegistryIntegrity:
|
|
def test_all_keys_have_valid_types(self):
|
|
valid_types = {"int", "float", "str", "bool"}
|
|
for key, defn in SETTINGS.items():
|
|
assert defn.type in valid_types, f"{key} has invalid type {defn.type!r}"
|
|
|
|
def test_no_bootstrap_section_keys(self):
|
|
for key, defn in SETTINGS.items():
|
|
assert defn.section not in BOOTSTRAP_SECTIONS, (
|
|
f"{key} in bootstrap section {defn.section!r}"
|
|
)
|
|
|
|
def test_all_entries_have_descriptions(self):
|
|
for key, defn in SETTINGS.items():
|
|
assert defn.description, f"{key} has empty description"
|