From 33aa485a12124d4c3e35625c19e02c906f5740bf Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 23 May 2026 07:12:19 -0700 Subject: [PATCH] [eric] cycles: extract settings persistence into store leaf --- backend/apps/service/client.py | 8 +-- backend/apps/settings/settings.py | 74 ++++----------------------- backend/apps/settings/store.py | 76 ++++++++++++++++++++++++++++ backend/tests/test_service.py | 8 +-- backend/tests/test_service_legacy.py | 2 +- 5 files changed, 94 insertions(+), 74 deletions(-) create mode 100644 backend/apps/settings/store.py diff --git a/backend/apps/service/client.py b/backend/apps/service/client.py index 14d0ad14..3ad79a60 100644 --- a/backend/apps/service/client.py +++ b/backend/apps/service/client.py @@ -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("/") diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index aa99cafe..11a9bb9e 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -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() diff --git a/backend/apps/settings/store.py b/backend/apps/settings/store.py new file mode 100644 index 00000000..e7f9edca --- /dev/null +++ b/backend/apps/settings/store.py @@ -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) diff --git a/backend/tests/test_service.py b/backend/tests/test_service.py index 9b98bce8..84a14323 100644 --- a/backend/tests/test_service.py +++ b/backend/tests/test_service.py @@ -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 diff --git a/backend/tests/test_service_legacy.py b/backend/tests/test_service_legacy.py index b6daf79d..34bcc21c 100644 --- a/backend/tests/test_service_legacy.py +++ b/backend/tests/test_service_legacy.py @@ -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