Files
ai-agent/backend/notifications/read_ledger.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

107 lines
3.9 KiB
Python

"""Server-side "read" ledger for the notification feed.
The feed's "new" (unread) state used to live only in the browser (a Zustand
persist store). That was fine for the PWA but opaque to anything else — in
particular the desk **phone**, which wants to fetch *unread* notifications and
read them aloud without a browser in the loop.
This is the small server-side counterpart: a set of notification ids that have
been **opened/seen in the UI**. Unread = feed ids not in this set. Two rules the
phone flow depends on:
* A read-only caller (the phone, presenting the read-only API key) fetching the
unread list must **not** mutate this — *listening on the phone doesn't count
as reading*. Only an explicit ``POST /api/notifications/seen`` (the PWA,
swiping/opening a card) marks ids read.
* A first sync from a fresh client shouldn't flood everything as "unread": the
``seed`` op adopts the current feed as the read baseline in one shot.
Store: one JSON file (``/data/notif-read.json``), atomic rewrite, same pattern as
``meta.py``/``notify.py``. Shape: ``{"seen": ["<id>", …], "seeded": bool}``. The
set is capped (newest kept by insertion order) so it can't grow without bound.
"""
import json
import os
import pathlib
import threading
_MAX_SEEN = 5000
class NotifReadStore:
def __init__(self, path: str):
self.path = pathlib.Path(path)
self._lock = threading.Lock()
self._seen: list[str] = []
self._seen_set: set[str] = set()
self._seeded = False
self._load()
def _load(self) -> None:
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
seen = [str(x) for x in (data.get("seen") or [])]
self._seen = seen
self._seen_set = set(seen)
self._seeded = bool(data.get("seeded"))
except (OSError, ValueError):
self._seen, self._seen_set, self._seeded = [], set(), False
def _save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".json.tmp")
tmp.write_text(
json.dumps({"seen": self._seen, "seeded": self._seeded}, indent=2),
encoding="utf-8",
)
os.replace(tmp, self.path)
def is_seen(self, nid: str) -> bool:
with self._lock:
return nid in self._seen_set
def seeded(self) -> bool:
with self._lock:
return self._seeded
def unread(self, ids: list[str]) -> list[str]:
"""Which of ``ids`` are unread. Pure read — never mutates the ledger.
Before the first ``seed``, nothing is considered read (so a fresh install
surfaces recent notifications rather than reporting zero); after seeding,
only ids not in the seen set are unread.
"""
with self._lock:
if not self._seeded:
return list(ids)
return [i for i in ids if i not in self._seen_set]
def _add(self, ids: list[str]) -> None:
for nid in ids:
if nid and nid not in self._seen_set:
self._seen_set.add(nid)
self._seen.append(nid)
if len(self._seen) > _MAX_SEEN:
drop = len(self._seen) - _MAX_SEEN
for nid in self._seen[:drop]:
self._seen_set.discard(nid)
self._seen = self._seen[drop:]
def mark_seen(self, ids: list[str]) -> int:
"""Mark ``ids`` read. Returns the total seen count. Also sets seeded so a
subsequent client that never called seed still gets a sane baseline."""
with self._lock:
self._add(ids)
self._seeded = True
self._save()
return len(self._seen)
def seed(self, ids: list[str]) -> int:
"""Adopt ``ids`` as the read baseline (idempotent first-sync)."""
with self._lock:
self._add(ids)
self._seeded = True
self._save()
return len(self._seen)