mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-22 12:42:22 +02:00
[eric] cycles: extract settings persistence into store leaf
This commit is contained in:
@@ -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("/")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user