Files
ai-agent/backend/ui_state.py
Gabriel Vidal 96ab64b0f2 fix(ui-state): never persist defaults over the server file after a failed read
A failed settings read (expired Authelia session -> synthetic 401, network
blip, HTML login page) left the store on its defaults, and the next settings
change PUT them over ui-state.json — wiping the whole prompt-shortcut config.
The same path could also push a stale pre-July localStorage copy.

- serverStorage: writes are held until the server value was actually read;
  failed reads are retried on focus/online/30s via retryHydration(store);
  the localStorage bridge only runs on a confirmed-empty server.
- ui_state.py: daily ui-state.json.bak-YYYY-MM-DD snapshot, 14 kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 12:04:32 +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)