Files
turnstone/tests/test_config_store.py
Patrick Buckley 7a06f5e8bc refactor(session): make ModelLane the provider boundary (#979) (#989)
* refactor(session): make ModelLane the provider boundary (#979)

## Summary

This closes the model-lane ownership gap left by #832: `ChatSession` no longer stores raw provider/client handles. `ResolvedModelBinding` now carries the provider, client, model, capabilities, registry generation, and backend-auth configuration as one coherent snapshot.

- Atomically rebind existing sessions after model-registry changes while pinning each in-flight send, fallback, judge, output guard, task agent, title, compaction, perception, and voice operation to its initiating principal and binding.
- Fence UI publication, canonical trajectory folds, durable writes, streams, retries, child scopes, and judge work by generation. Stop can hand off to a successor without accepting late state; cancelled tools retain typed effect receipts, and concurrent approval batches resolve by exact cycle or call.
- Make create, fork, open, close, and delete race-safe with hidden `creating` reservations, incarnation-aware state tails, and an ACL-rechecked transaction that clones checkpoint-bounded history, configuration, project/persona state, and attachment references.
- Extend REST/OpenAPI and Python/TypeScript SDK contracts for create/fork inputs, routed-create metadata, live-workstream probes, targeted approvals, and structured cancellation results.
- Update architecture, storage, authentication, judge, channel, console, API, and SDK documentation, including regenerated architecture diagrams and OpenAPI artifacts.

## Validation

- SQLite suite: 11,188 passed, 9 skipped, 10 deselected
- PostgreSQL suite: 11,195 passed, 2 skipped, 10 deselected
- Live backend: 3 passed
- SSE recovery: 6 passed; browser recovery harness passed all scenarios
- Ruff: clean; 595 files correctly formatted
- mypy: 243 source files clean
- TypeScript: typecheck/build and 35 tests passed
- OpenAPI artifacts fresh; all 14 changed diagrams reproduce byte-for-byte
- `git diff --check` and Git LFS integrity clean

Closes #979.

* fix(deps): update nanoid for GHSA-2v37-7h3g-55p8

Refresh the transitive lock entry admitted by PostCSS so the TypeScript security gate no longer resolves the vulnerable custom-generator implementation.

Validation:
- npm ci
- npm audit --audit-level=moderate: 0 vulnerabilities
- TypeScript typecheck and build
- TypeScript tests: 35 passed

* fix(test): assert canonical model registry URLs

Replace prefix checks with exact canonical base URL assertions so the tests do not model incomplete URL validation.

Validation: tests/test_model_registry.py (185 passed); Ruff check/format; mypy.
2026-08-08 16:13:35 -07:00

479 lines
16 KiB
Python

"""Tests for ConfigStore database-backed configuration."""
from __future__ import annotations
import threading
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.default_alias", "gpt5-prod")
assert store.get("model.default_alias") == "gpt5-prod"
def test_task_alias(self, store):
store.set("model.task_alias", "fast")
assert store.get("model.task_alias") == "fast"
def test_task_effort(self, store):
store.set("model.task_effort", "low")
assert store.get("model.task_effort") == "low"
# ---------------------------------------------------------------------------
# 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):
result = store.delete("tools.timeout")
assert result 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
def test_reload_cannot_overwrite_a_concurrent_set(self, storage, store, monkeypatch):
store.set("tools.timeout", 30)
reload_captured = threading.Event()
release_reload = threading.Event()
setter_waiting = threading.Event()
setter_done = threading.Event()
errors: list[BaseException] = []
real_bulk = storage.get_system_settings_bulk
def blocked_bulk(*, node_id=""):
raw = real_bulk(node_id=node_id)
if threading.current_thread().name == "stale-reload":
reload_captured.set()
if not release_reload.wait(2):
raise TimeoutError("reload/set test did not release the stale read")
return raw
monkeypatch.setattr(storage, "get_system_settings_bulk", blocked_bulk)
real_mutation_lock = store._mutation_lock
class _ObservedMutationLock:
def __enter__(self):
if threading.current_thread().name == "new-set":
setter_waiting.set()
real_mutation_lock.acquire()
return self
def __exit__(self, *_exc_info):
real_mutation_lock.release()
store._mutation_lock = _ObservedMutationLock()
def reload_worker() -> None:
try:
store.reload()
except BaseException as exc:
errors.append(exc)
def set_worker() -> None:
try:
store.set("tools.timeout", 60)
except BaseException as exc:
errors.append(exc)
finally:
setter_done.set()
reload_thread = threading.Thread(target=reload_worker, name="stale-reload")
setter_thread = threading.Thread(target=set_worker, name="new-set")
reload_thread.start()
assert reload_captured.wait(2)
setter_thread.start()
assert setter_waiting.wait(2)
assert not setter_done.is_set()
release_reload.set()
reload_thread.join(timeout=2)
setter_thread.join(timeout=2)
assert not reload_thread.is_alive()
assert not setter_thread.is_alive()
assert errors == []
assert store.get("tools.timeout") == 60
assert ConfigStore(storage).get("tools.timeout") == 60
@pytest.mark.parametrize("later_operation", ["set", "delete"])
def test_mutations_publish_in_storage_order(
self,
storage,
store,
monkeypatch,
later_operation,
):
first_committed = threading.Event()
release_first = threading.Event()
later_waiting = threading.Event()
later_done = threading.Event()
errors: list[BaseException] = []
real_upsert = storage.upsert_system_setting
def blocked_upsert(**kwargs):
real_upsert(**kwargs)
if threading.current_thread().name == "first-set":
first_committed.set()
if not release_first.wait(2):
raise TimeoutError("mutation-order test did not release the first write")
monkeypatch.setattr(storage, "upsert_system_setting", blocked_upsert)
real_mutation_lock = store._mutation_lock
class _ObservedMutationLock:
def __enter__(self):
if threading.current_thread().name == "later-mutation":
later_waiting.set()
real_mutation_lock.acquire()
return self
def __exit__(self, *_exc_info):
real_mutation_lock.release()
store._mutation_lock = _ObservedMutationLock()
def first_worker() -> None:
try:
store.set("tools.timeout", 30)
except BaseException as exc:
errors.append(exc)
def later_worker() -> None:
try:
if later_operation == "set":
store.set("tools.timeout", 60)
else:
store.delete("tools.timeout")
except BaseException as exc:
errors.append(exc)
finally:
later_done.set()
first_thread = threading.Thread(target=first_worker, name="first-set")
later_thread = threading.Thread(target=later_worker, name="later-mutation")
first_thread.start()
assert first_committed.wait(2)
later_thread.start()
assert later_waiting.wait(2)
assert not later_done.is_set()
release_first.set()
first_thread.join(timeout=2)
later_thread.join(timeout=2)
assert not first_thread.is_alive()
assert not later_thread.is_alive()
assert errors == []
expected = 60 if later_operation == "set" else SETTINGS["tools.timeout"].default
assert store.get("tools.timeout") == expected
assert ConfigStore(storage).get("tools.timeout") == expected
# ---------------------------------------------------------------------------
# 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())
def test_effective_snapshot_closes_cache_swap_version_window(self, store):
store.set("judge.smart_approvals", False)
store.set("judge.confidence_threshold", 0.95)
old_version = store.version
old_first_key = store.get("judge.smart_approvals")
new_cache = {
**store._cache,
"judge.smart_approvals": True,
"judge.confidence_threshold": 0.4,
}
swapped = threading.Event()
release = threading.Event()
snapshot_waiting = threading.Event()
errors: list[BaseException] = []
real_lock = store._lock
class _ObservedLock:
def __enter__(self):
if threading.current_thread().name == "snapshot-reader":
snapshot_waiting.set()
real_lock.acquire()
return self
def __exit__(self, *_exc_info):
real_lock.release()
store._lock = _ObservedLock()
def writer() -> None:
try:
with store._lock:
store._cache = new_cache
swapped.set()
if not release.wait(2):
raise TimeoutError("snapshot test did not release writer")
store._version += 1
except BaseException as exc:
errors.append(exc)
writer_thread = threading.Thread(target=writer, name="snapshot-writer")
writer_thread.start()
assert swapped.wait(2)
# This is the exact impossible pair the old per-key/version bracket
# accepted while a writer paused between its two assignments.
assert store._version == old_version
new_second_key = store.get("judge.confidence_threshold")
assert (old_first_key, new_second_key) == (False, 0.4)
result: list[tuple[int, dict[str, object]]] = []
def reader() -> None:
try:
result.append(store.effective_snapshot())
except BaseException as exc:
errors.append(exc)
reader_thread = threading.Thread(target=reader, name="snapshot-reader")
reader_thread.start()
assert snapshot_waiting.wait(2)
assert result == []
release.set()
writer_thread.join(timeout=2)
reader_thread.join(timeout=2)
assert not writer_thread.is_alive()
assert not reader_thread.is_alive()
assert errors == []
version, values = result[0]
assert version == old_version + 1
assert values["judge.smart_approvals"] is True
assert values["judge.confidence_threshold"] == 0.4
# ---------------------------------------------------------------------------
# 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.default_alias", "gpt5-prod")
assert store.stored_keys() == frozenset({"tools.timeout", "model.default_alias"})
store.delete("tools.timeout")
assert store.stored_keys() == frozenset({"model.default_alias"})
# ---------------------------------------------------------------------------
# version
# ---------------------------------------------------------------------------
class TestVersion:
def test_waits_for_in_progress_cache_publication(self, store):
old_version = store.version
new_cache = {**store._cache, "tools.timeout": 31}
cache_swapped = threading.Event()
release_writer = threading.Event()
reader_observed = threading.Event()
reader_lock_attempted = threading.Event()
errors: list[BaseException] = []
result: list[int] = []
real_lock = store._lock
class _ObservedLock:
def __enter__(self):
if threading.current_thread().name == "version-reader":
reader_lock_attempted.set()
reader_observed.set()
real_lock.acquire()
return self
def __exit__(self, *_exc_info):
real_lock.release()
store._lock = _ObservedLock()
def writer() -> None:
try:
with store._lock:
store._cache = new_cache
cache_swapped.set()
if not release_writer.wait(2):
raise TimeoutError("version test did not release the writer")
store._version += 1
except BaseException as exc:
errors.append(exc)
def reader() -> None:
try:
result.append(store.version)
except BaseException as exc:
errors.append(exc)
finally:
reader_observed.set()
writer_thread = threading.Thread(target=writer, name="version-writer")
reader_thread = threading.Thread(target=reader, name="version-reader")
writer_thread.start()
cache_swapped_seen = cache_swapped.wait(2)
reader_thread.start()
reader_reached_accessor = reader_observed.wait(2)
result_before_release = list(result)
release_writer.set()
writer_thread.join(timeout=2)
reader_thread.join(timeout=2)
assert cache_swapped_seen
assert reader_reached_accessor
assert not writer_thread.is_alive()
assert not reader_thread.is_alive()
assert errors == []
assert reader_lock_attempted.is_set()
assert result_before_release == []
assert result == [old_version + 1]
assert store.get("tools.timeout") == 31
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