Files
ai-agent/backend/settings/ui_state.py
Gabriel Vidal 0ff9e40242 refactor(backend): split main.py into domain packages with app.state injection
main.py (5146 lines, 106 routes) becomes an assembly only: one package per
domain — core, files, settings, dashboards, conversations, diff,
notifications, forms, runs, accounts, workers, models, cron, agents,
projects, services, goals, memories, plans, templates — each exposing an
APIRouter; the flat domain modules move into their package behind a barrel
that keeps the old `import conversations` / `import projects` spellings.

The shared singletons (store, meta_store, hub, indexer, …) are built once by
core.state.build_state() and attached to app.state.ai; routes take them as
the `deps: State` dependency and helpers as an explicit `deps: AppState`.
conversations/pricing.py carries the per-model rates out of the parser.

Verified: route table and OpenAPI byte-identical; 90 read endpoints
golden-diffed against the monolith on a copy of the live data (identical);
write routes smoke-tested; 66 backend tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:55:47 +02:00

91 lines
3.2 KiB
Python

"""
Server-side persistence for the PWA's small client state — the Settings panel
config (theme, currency, subscription, prompt shortcuts) and the notification
feed's seen/new bookkeeping — that used to live in the browser's ``localStorage``.
Moving it into a file (``/data/ui-state.json``) makes the config follow the user
across browsers and devices instead of being trapped per-browser, and survives a
localStorage clear. The store is a dumb key→string map: each key is a Zustand
``persist`` store name (``ai-agent-settings`` / ``ai-agent-store``) and the value
is that store's already-serialized JSON blob — the backend never inspects it, it
just holds the opaque string the frontend hands it.
Single JSON file, whole-map rewrite on every set (atomic via a temp file + rename),
mirroring ``meta.py``. The map is tiny, so this is cheap.
Before the first write of each day the current file is copied to
``ui-state.json.bak-YYYY-MM-DD`` (the last ``BACKUP_DAYS`` kept): a client that
ever persists its defaults over the real config — as happened once to a whole
prompt-shortcut set — then costs a restore, not the config.
"""
import datetime
import shutil
import json
import os
import pathlib
import re
import threading
# Keys are Zustand persist store names — restrict to a safe, fixed shape so a
# bogus key can never escape the single JSON blob or collide with anything else.
_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
BACKUP_DAYS = 14
def valid_key(key: str) -> bool:
return bool(_KEY_RE.match(key))
class UiStateStore:
def __init__(self, path: str):
self.path = pathlib.Path(path)
self._lock = threading.Lock()
self._data: dict[str, str] = {}
self._load()
def _load(self) -> None:
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
# Keep only string values; drop anything unexpected on the floor.
self._data = {k: v for k, v in data.items() if isinstance(v, str)}
except (OSError, ValueError):
self._data = {}
def get(self, key: str) -> str | None:
with self._lock:
return self._data.get(key)
def set(self, key: str, value: str) -> None:
with self._lock:
self._data[key] = value
self._save()
def delete(self, key: str) -> None:
with self._lock:
if key in self._data:
del self._data[key]
self._save()
def _backup(self) -> None:
"""Daily snapshot of the file as it was before today's first write."""
bak = self.path.with_name(f"{self.path.name}.bak-{datetime.date.today():%Y-%m-%d}")
if bak.exists() or not self.path.exists():
return
try:
shutil.copy2(self.path, bak)
for old in sorted(self.path.parent.glob(f"{self.path.name}.bak-*"))[:-BACKUP_DAYS]:
old.unlink(missing_ok=True)
except OSError:
pass
def _save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self._backup()
tmp = self.path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(self._data, indent=2, sort_keys=True),
encoding="utf-8")
os.replace(tmp, self.path)