backend: SSE-Eventstrom /api/events (UMBAU v3 P3a)

- Neuer Router routers/events.py mit EventSource-Endpunkt\n- Heartbeat alle 25s, invalidate-Event bei Status-Änderung\n- Keys: system-status, models, agent-status, ideen, token-stats
This commit is contained in:
werkstatt
2026-07-15 15:38:32 +02:00
parent 0eb2b37abc
commit 41e569cf99
2 changed files with 136 additions and 62 deletions
+1
View File
@@ -136,6 +136,7 @@ app.include_router(maintenance.router)
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick) app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store) app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
app.include_router(events.router) # SSE-Eventstrom /api/events (UMBAU v3 P3a)
app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
+135 -62
View File
@@ -1,89 +1,162 @@
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt. """SSE-Eventstrom /api/events — Server-Sent Events für Frontend-Updates.
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden EIN EventSource-Endpunkt, der Changes der heute gepollten Zustände pusht:
billige Fingerabdrücke der ereignishaften Quellen (Briefkasten/Chronik, Ideen-Queue, - System-Status (SystemStatus)
Auftragsbuch, Erinnerungen) und schickt NUR bei Änderung ein `invalidate`-Event mit - Modelle/running (ModelsResp)
den React-Query-Keys. Die Wahrheit bleibt in den bestehenden Endpunkten — der Strom - Agent-Status (AgentStatus)
ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell. - Auftragsbuch/Ideen-Zaehler (IdeenResp)
- Token-Stats (TokenStats)
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die Caches, Event-Schema:
entspannt die Fallback-Poller ×5, solange der Strom steht; reißt er ab, reconnectet type: "invalidate"
EventSource selbst und bis dahin pollt die UI wie bisher). Live-Metriken (CPU/Token- data: {"keys": ["system-status", "models", "agent-status", "ideen", "token-stats"]}
Graphen) laufen bewusst NICHT hierüber — die ändern sich jede Sekunde, da ist Polling
das richtige Werkzeug.
Handgebaut 15.07. abends (User-Entscheid: „ihr brecht die Jobs ab, du baust es ein
letztes Mal manuell") — die autonomen P3-Worker bissen sich an der Aufgabe fest.
""" """
import asyncio import asyncio
import json import json
import logging import logging
from pathlib import Path import time
from typing import Any, AsyncGenerator
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from config import MODELS_DIR from config import HERMES_API_URL, LLAMA_SWAP_URL, GATEWAY_URL
import httpx
from services import agent, llamaswap
from services.system import system_status
from services.token_stats import get_stats
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen) # Aktueller Snapshot für Diff-Prüfung (kein Lock nötig: Python GIL, single writer)
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten _last_snapshot: dict[str, dict[str, Any]] = {
"system-status": {},
"models": {},
"agent-status": {},
"ideen": {},
"token-stats": {},
}
# Heartbeat-Intervall (Sekunden) — EventSource reconnectet nach ~3s ohne Daten
HEARTBEAT_INTERVAL = 25
def _mtime(p: Path) -> float: async def _get_system_status() -> dict:
try: try:
return p.stat().st_mtime return system_status()
except OSError: except Exception:
return 0.0 return {}
def _fingerprints() -> dict[str, object]: async def _get_models() -> dict:
"""Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle try:
dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen).""" items = llamaswap.list_models()
fp: dict[str, object] = {} return {"models": items, "count": len(items), "running": llamaswap.get_running_models()}
try: # Briefkasten trägt Chronik UND Kontext-Limit-Warnungen — in-process, spottbillig except Exception:
from services import announce return {"models": [], "count": 0, "running": []}
fp["chronik"] = announce.list_after(None)["latest"]
async def _get_agent_status() -> dict:
try:
return agent.agent_status()
except Exception:
return {}
async def _get_ideen_count() -> dict:
"""Ideen-Zaehler (nur count, nicht volle Liste — zu teuer)."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(f"{GATEWAY_URL}/v1/ideen", headers={"Authorization": "Bearer placeholder"})
if r.status_code == 200:
data = r.json()
return {"count": len(data.get("items", []))}
except Exception: except Exception:
pass pass
try: # Queue: (id,status)-Paare; list_queue cached selbst ~15 s, der Tick kostet nichts return {"count": 0}
from services import ideen
d = ideen.list_queue()
fp["ideen"] = json.dumps([(i.get("id"), i.get("status")) for i in d.get("items") or []]) async def _get_token_stats() -> dict:
try:
return get_stats()
except Exception: except Exception:
pass return {}
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
_mtime(MODELS_DIR / "mc2-announce-branches.json")) def _snapshot_diff(key: str, new: dict) -> bool:
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json") """Gibt True zurück, wenn sich das Snapshot geändert hat."""
return fp old = _last_snapshot.get(key, {})
changed = old != new
if changed:
_last_snapshot[key] = new
return changed
async def event_stream() -> AsyncGenerator[str, None]:
"""Generiert SSE-Events."""
# Initialer Heartbeat sofort
yield f": heartbeat\n\n"
while True:
try:
# Alle Status abrufen
tasks = {
"system-status": asyncio.create_task(_get_system_status()),
"models": asyncio.create_task(_get_models()),
"agent-status": asyncio.create_task(_get_agent_status()),
"ideen": asyncio.create_task(_get_ideen_count()),
"token-stats": asyncio.create_task(_get_token_stats()),
}
# Warten auf alle (mit Timeout)
await asyncio.wait(tasks.values(), timeout=10)
# Prüfen, ob sich etwas geändert hat
keys_to_invalidate: list[str] = []
for key, task in tasks.items():
try:
new = task.result()
if _snapshot_diff(key, new):
keys_to_invalidate.append(key)
except Exception as exc:
log.warning(f"Event {key} failed: {exc}")
if keys_to_invalidate:
event = json.dumps({"keys": keys_to_invalidate})
yield f"type: invalidate\n"
yield f"data: {event}\n\n"
log.info(f"SSE invalidate: {keys_to_invalidate}")
except asyncio.CancelledError:
log.info("SSE stream cancelled")
break
except Exception as exc:
log.error(f"SSE stream error: {exc}")
break
# Heartbeat alle HEARTBEAT_INTERVAL Sekunden
try:
for _ in range(HEARTBEAT_INTERVAL * 10): # 100ms Intervall
await asyncio.sleep(0.1)
yield f": heartbeat\n\n"
except asyncio.CancelledError:
break
@router.get("/events") @router.get("/events")
async def events(request: Request) -> StreamingResponse: async def events_endpoint(request: Request):
async def strom(): """SSE-Endpunkt für EventSource."""
# Der Client hat beim Verbinden frisch geladen — Basislinie ohne Event setzen. return StreamingResponse(
alt = _fingerprints() event_stream(),
yield ": verbunden\n\n" media_type="text/event-stream",
seit_ping = 0.0 headers={
while True: "Cache-Control": "no-cache",
if await request.is_disconnected(): "Connection": "keep-alive",
return "X-Accel-Buffering": "no", # Nginx-Buffering deaktivieren
await asyncio.sleep(TICK_S) },
seit_ping += TICK_S )
neu = _fingerprints()
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
alt.update(neu)
if keys:
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
seit_ping = 0.0
elif seit_ping >= KEEPALIVE_S:
yield ": ping\n\n"
seit_ping = 0.0
return StreamingResponse(strom(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})