[eric] cycles: extract settings persistence into store leaf

This commit is contained in:
ciregenz
2026-05-23 07:12:19 -07:00
parent 3b97c23592
commit 33aa485a12
5 changed files with 94 additions and 74 deletions
+4 -4
View File
@@ -72,7 +72,7 @@ def _get_install_id() -> str:
if _install_id:
return _install_id
try:
from backend.apps.settings.settings import load_settings, _save_settings
from backend.apps.settings.store import load_settings, _save_settings
s = load_settings()
iid = getattr(s, "installation_id", None)
if not iid:
@@ -90,7 +90,7 @@ def _get_user_id() -> Optional[str]:
if _user_id:
return _user_id
try:
from backend.apps.settings.settings import load_settings
from backend.apps.settings.store import load_settings
s = load_settings()
# Prefer the cloud-issued user_id (UUID) if the user has signed in
# via Google OAuth, magic link, or Stripe checkout; that's the
@@ -118,7 +118,7 @@ def _is_enabled(kind: str) -> bool:
if kind == "diagnostic":
return True
try:
from backend.apps.settings.settings import load_settings
from backend.apps.settings.store import load_settings
s = load_settings()
mode = getattr(s, "service_diagnostics_mode", None)
if mode == "minimal":
@@ -184,7 +184,7 @@ def _envelope() -> dict:
def _base_url() -> str:
try:
from backend.apps.settings.settings import load_settings
from backend.apps.settings.store import load_settings
from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL
s = load_settings()
return (getattr(s, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL).rstrip("/")
+9 -65
View File
@@ -1,8 +1,6 @@
import asyncio
import json
import os
import tempfile
import threading
import time
import logging
from contextlib import asynccontextmanager
@@ -13,13 +11,18 @@ from typing import Literal, Optional
from backend.config.Apps import SubApp
from backend.apps.settings.models import AppSettings, DEFAULT_SYSTEM_PROMPT
from backend.apps.settings.store import (
DATA_DIR,
SETTINGS_FILE,
load_settings,
save_settings,
_save_settings,
_atomic_write_settings,
_migrate_legacy_fields,
)
logger = logging.getLogger(__name__)
from backend.config.paths import SETTINGS_DIR as DATA_DIR
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
@asynccontextmanager
async def settings_lifespan():
@@ -99,61 +102,6 @@ async def _upload_dir_gc_loop():
settings = SubApp("settings", settings_lifespan)
def _migrate_legacy_fields(raw: dict) -> dict:
"""Translate deprecated pre-launch field names ('managed', 'openswarm_auth_token') into production schema."""
if raw.get("connection_mode") == "managed":
raw["connection_mode"] = "openswarm-pro"
if "openswarm_auth_token" in raw and "openswarm_bearer_token" not in raw:
raw["openswarm_bearer_token"] = raw.pop("openswarm_auth_token")
return raw
def load_settings() -> AppSettings:
"""Load settings from JSON file, returning defaults if not found."""
if os.path.exists(SETTINGS_FILE):
with open(SETTINGS_FILE) as f:
raw = _migrate_legacy_fields(json.load(f))
settings = AppSettings(**raw)
if settings.default_system_prompt is None:
settings.default_system_prompt = DEFAULT_SYSTEM_PROMPT
return settings
return AppSettings()
# threading.Lock guards every SETTINGS_FILE write; works for sync paths and async run_in_executor paths.
_settings_write_lock = threading.Lock()
def _atomic_write_settings(payload: dict) -> None:
"""Atomic SETTINGS_FILE write; call via save_settings*, not directly."""
with _settings_write_lock:
os.makedirs(DATA_DIR, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".settings.", suffix=".tmp", dir=DATA_DIR)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
# Windows: Defender can briefly lock the destination; one retry handles every real case.
for attempt in range(2):
try:
os.replace(tmp, SETTINGS_FILE)
return
except PermissionError:
if attempt == 1:
raise
time.sleep(0.05)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def save_settings(settings_obj: AppSettings) -> None:
"""Sync atomic persist; thread-safe. Async callers should prefer save_settings_async (Defender can stretch writes to 50-200ms)."""
_atomic_write_settings(settings_obj.model_dump())
async def save_settings_async(settings_obj: AppSettings) -> None:
"""Async atomic save via thread pool; shares the lock with the sync variant."""
payload = settings_obj.model_dump()
@@ -161,10 +109,6 @@ async def save_settings_async(settings_obj: AppSettings) -> None:
await loop.run_in_executor(None, _atomic_write_settings, payload)
def _save_settings(settings_obj: AppSettings) -> None:
save_settings(settings_obj)
@settings.router.get("")
async def get_settings():
return load_settings().model_dump()
+76
View File
@@ -0,0 +1,76 @@
"""Settings persistence primitives (read/write/migrate the settings.json file).
A leaf: imports only settings.models + config.paths, never service or
nine_router. Lets service.client reach load/save downward instead of looping
back up through settings.settings.
"""
import json
import os
import tempfile
import threading
import time
from backend.config.paths import SETTINGS_DIR as DATA_DIR
from backend.apps.settings.models import AppSettings, DEFAULT_SYSTEM_PROMPT
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
def _migrate_legacy_fields(raw: dict) -> dict:
"""Translate deprecated pre-launch field names ('managed', 'openswarm_auth_token') into production schema."""
if raw.get("connection_mode") == "managed":
raw["connection_mode"] = "openswarm-pro"
if "openswarm_auth_token" in raw and "openswarm_bearer_token" not in raw:
raw["openswarm_bearer_token"] = raw.pop("openswarm_auth_token")
return raw
def load_settings() -> AppSettings:
"""Load settings from JSON file, returning defaults if not found."""
if os.path.exists(SETTINGS_FILE):
with open(SETTINGS_FILE) as f:
raw = _migrate_legacy_fields(json.load(f))
settings = AppSettings(**raw)
if settings.default_system_prompt is None:
settings.default_system_prompt = DEFAULT_SYSTEM_PROMPT
return settings
return AppSettings()
# threading.Lock guards every SETTINGS_FILE write; works for sync paths and async run_in_executor paths.
_settings_write_lock = threading.Lock()
def _atomic_write_settings(payload: dict) -> None:
"""Atomic SETTINGS_FILE write; call via save_settings*, not directly."""
with _settings_write_lock:
os.makedirs(DATA_DIR, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".settings.", suffix=".tmp", dir=DATA_DIR)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
# Windows: Defender can briefly lock the destination; one retry handles every real case.
for attempt in range(2):
try:
os.replace(tmp, SETTINGS_FILE)
return
except PermissionError:
if attempt == 1:
raise
time.sleep(0.05)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def save_settings(settings_obj: AppSettings) -> None:
"""Sync atomic persist; thread-safe. Async callers should prefer save_settings_async (Defender can stretch writes to 50-200ms)."""
_atomic_write_settings(settings_obj.model_dump())
def _save_settings(settings_obj: AppSettings) -> None:
save_settings(settings_obj)
+4 -4
View File
@@ -34,7 +34,7 @@ def patch_settings(tmp_path):
"installation_id": "test-install-abc",
"analytics_opt_in": True,
}))
import backend.apps.settings.settings as settings_mod
import backend.apps.settings.store as settings_mod
old = settings_mod.SETTINGS_FILE
settings_mod.SETTINGS_FILE = str(sf)
yield
@@ -158,7 +158,7 @@ def test_opt_out_blocks_sync(sink, tmp_path):
"installation_id": "test-install-abc",
"analytics_opt_in": False,
}))
import backend.apps.settings.settings as settings_mod
import backend.apps.settings.store as settings_mod
settings_mod.SETTINGS_FILE = str(sf)
from backend.apps.service.client import sync
sync({"x": 1})
@@ -173,7 +173,7 @@ def test_standard_mode_allows_sync(sink):
def test_settings_load_failure_defaults_to_enabled(sink):
import backend.apps.settings.settings as settings_mod
import backend.apps.settings.store as settings_mod
settings_mod.SETTINGS_FILE = "/nonexistent/path/settings.json"
from backend.apps.service.client import sync
sync({})
@@ -288,7 +288,7 @@ async def test_drain_spool_empty():
def test_install_id_persisted(sink, tmp_path):
sf = tmp_path / "fresh.json"
sf.write_text(json.dumps({"analytics_opt_in": True}))
import backend.apps.settings.settings as settings_mod
import backend.apps.settings.store as settings_mod
settings_mod.SETTINGS_FILE = str(sf)
import backend.apps.service.client as client
client._install_id = None
+1 -1
View File
@@ -100,7 +100,7 @@ def mock_settings(tmp_path):
"installation_id": "test-install-id",
}))
import backend.apps.settings.settings as settings_mod
import backend.apps.settings.store as settings_mod
old_file = settings_mod.SETTINGS_FILE
settings_mod.SETTINGS_FILE = str(settings_file)
yield